[iOS] 해싱
용어 정리
- key : 해싱되기 전의 값
- digest : 해싱된 후의 값
- rainbow table : 여러 값들을 대입해보면서 얻은 다이제스트들을 모아놓은 테이블
해시 함수
- 단방향 함수로만 작동함
- input 값이 아주 미세하게 달라져도 output 값은 전혀 달라짐 → Avalanche Effect
- 해시 함수의 원래 설계 목적은 빠른 검색을 위함임 (OS 부분 해시테이블 참고)
- input 값이 같으면 output 값은 항상 같음 (즉 함수로서 기능함)
해시 함수의 위험성
- input이 같으면 output은 항상 같으므로 해싱된 문자열의 원문을 레인보우 테이블에서 찾을 위험성이 존재함.
- brute-force
- 원래 해시 함수가 빠른 검색을 위해 설계되었다 보니 실제 런타임에서의 속도도 빠른 건 맞음. 하지만 그렇다면 해커도 똑같이 빠르게 값을 얻을 수 있음.
위험성 보완 방법
- salt
- 효과적인 솔트 : 사용자별 고유의 솔트를 가져야 하며 솔트의 길이는 32비트 이상이어야 함
- key stretching
- 입력값 -해싱→ 다이제스트 -해싱→ 다이제스트’ -해싱→ 다이제스트’’ 방식으로 여러번 해싱하는 것
해싱 알고리즘 종류
MD5 계열
- 가장 쉽게 사용 가능하지만 안전하지 않음
- 해시값이 단순하게 바뀌는 것이기 때문에 구글링으로 원본 값을 찾기 쉬움
- 사용하지 않는 것이 권장됨
SHA 계열
- SHA-0 : 수없이 많이 노출됨. 보안이 거의 적용되지 않음. 사용하지 않는 것을 권장.
- SHA-1 : SHA-0에서 개선된 버전
- SHA-2 : SHA-256이 여기 포함됨
- SHA-3 : SHA-384가 여기 포함됨
Key Derivation Functions
- 키 유도 함수는 솔팅, 키 스트레칭을 사용해 기존 해시 함수의 약점 보완
- PBKDF2
- 키 유도 함수 중 하나로 키 값에 솔트를 넣고 이를 원하는 만큼 키스트레칭하는 방식
- DIGEST = PBKDF2(PRF, Password, Salt, c, DLen)
- PRF: 난수 (HMAC)
- Password: key 값
- c: 키 스트레칭을 원하는 횟수
- DLen: 원하는 다이제스트 길이
- Scrypt
- 키 유도 함수 중 하나로 다이제스트 생성 시 메모리 오버헤트를 갖게 하여 brute force 공격 가능성을 보완함.
인코딩 리서치
SHA-256의 결과물 : 무조건 256 bit 리턴 → 32 byte
base64 인코딩 시 바이트의 길이가 3배수일 때만 패딩이 들어가지 않음.

SHA-256의 결과물의 경우 마지막 2byte 인코딩 방법
만약 SHA-256을 한 결과물이 X라고 함. X를 Base64로 인코딩할 때 앞의 30byte는 40글자로 잘 인코딩이 될 것임. 그럼 남은 2byte의 인코딩 방식은 다음과 같음. 예를 들어 남은 2byte의 바이너리 값이 0101010101010101 이라고 가정.

마지막 =은 0이어서 =이 들어간게 아니고 패딩값으로 들어간 널의 의미를 가진 0이기 때문에 패딩의 개념으로 들어간 =임. 어쨌든 SHA-256을 Base64로 인코딩한 결과물은 44글자인데 마지막 한 글자는 고정적으로 =임. 따라서 여기서 생각해본 방안이 두 가지임.
- 마지막 글자를 =이 아닌 base64 내부의 랜덤 난수 값으로 만들고 44글자 그대로 전송
- 마지막 글자 자르고 43글자만 전달 텍스트에 붙이기
= 값을 사용하지 않기 위해 위의 두 가지 방법을 사용했을 때 웹서버의 동작은 다음과 같음. 크게 다르지 않다고 판단.
- 전달받은 P.T를 S.K를 사용해서 A.T, C.T으로 decrypt
- 복호화한 값 중 C.T 값에 미리 정해놓은 salt를 넣은 것을 SHA-256으로 해싱하면 OTP가 만들어짐
- OTP를 Base64로 인코딩하면 44글자가 나올 것임.
- 생성한 OTP의 앞 43 글자를 클라이언트로부터 전달 받은 OTP와 비교하면 됨.
Salt 리서치
솔트 값을 타임 베이스의 무언가로 만들거임. 만들어진 솔트 값의 interval를 x초라고 하면 아래와 같이 생성될 것임.
시간 | Salt |
0 ~ x - 1 | salt0 |
x ~ 2x - 1 | salt1 |
2x ~ 3x - 1 | salt2 |
… | … |
kx ~ (k + 1)x - 1 | saltk |
만약 10초 간격이라고 한다면 아래와 같이 생성될 것임.
시간(초) | Salt |
0 ~ 9 | salt0 |
10 ~ 19 | salt1 |
20 ~ 29 | salt2 |
… | … |
k * 10 ~ (k + 1) * 10 - 1 | saltk |
이게 초 단위로 할 때의 솔트 값 생성임. 단 이렇게 되면 interval 동안 만들어지는 api들의 OTP 값은 같다는게 단점임. 어쨌든 api 동작은 대체로 1초 안에 이뤄지기는 하는데 1초동안 이뤄지는 여러개의 api들은 OTP가 모두 같음. 보안적으로 신경쓰이는 부분이라고 하셨음.
import Foundation
import Alamofire
import CommonCrypto
enum EncryptError : Error {
case serverErr
case clientErr
case encodingStringErr
}
class Encryptor {
init() {
}
// test할 때 사용 방법
private func test() {
do {
Task {
let token = try getDigest()
}
}
}
// MARK: 통신에 필요한 value 가져오기 위한 함수
func getDigest() throws -> String {
do {
let ct = try KeyChainUtil.shared.getStringTypeFromKeyChain(type: .ct)
let ctDigest = hashString(from: appendSalt(originalValue: ct))
let pt = try KeyChainUtil.shared.getStringTypeFromKeyChain(type: .pt)
let result = ctDigest.appending(pt)
if let encodedResult: String = result.addingPercentEncoding(withAllowedCharacters: .alphanumerics) {
return encodedResult
} else {
throw EncryptError.encodingStringErr
}
} catch (let keyChainException) {
throw keyChainException
}
}
func fetchTokenFromServer() async throws -> String {
return try await withCheckedThrowingContinuation { continuation in
WebServerSignInService().serverAction(type: WebSignInModel.self) { [self] response in
switch response {
case .success(let data):
guard let data = data as? WebSignInModel else { return }
if let dataToken: String = data.token {
try? KeyChainUtil.shared.storeStringTypeInKeyChain(type: .pt, data: dataToken)
// 엄밀히 말하면 이 아래 코드들은 분리되어야 함. 받아온 토큰으로 해싱된 다이제스트 만드는 과정임.
let digest = hashString(from: appendSalt(originalValue: "1234"))
let result = digest.appending(dataToken)
let encodedResult = result.addingPercentEncoding(withAllowedCharacters: .alphanumerics)
if let value: String = encodedResult {
continuation.resume(returning: value)
} else {
continuation.resume(throwing: EncryptError.encodingStringErr)
}
} else {
continuation.resume(throwing: EncryptError.clientErr)
}
case .pathErr(_):
continuation.resume(throwing: EncryptError.clientErr)
default:
continuation.resume(throwing: EncryptError.serverErr)
}
}
}
}
func appendSalt(originalValue: String) -> String {
// 비공개
}
func hashString(from originalValue: String) -> String {
return originalValue.sha256()
}
}
extension Data{
public func sha256() -> String{
return base64StringFromData(input: digest(input: self as NSData))
}
private func digest(input : NSData) -> NSData {
let digestLength = Int(CC_SHA256_DIGEST_LENGTH)
var hash = [UInt8](repeating: 0, count: digestLength)
CC_SHA256(input.bytes, UInt32(input.length), &hash)
return NSData(bytes: hash, length: digestLength)
}
private func base64StringFromData(input: NSData) -> String {
var result = input.base64EncodedString()
return result
}
}
public extension String {
// String 타입의 값을 NSData 타입으로 변환 후 SHA256 변환
func sha256() -> String {
if let stringData = self.data(using: String.Encoding.utf8) {
return stringData.sha256()
}
return ""
}
}
여기 코드에서 결론적으로 hash 관련 부분은 appendsalt(), hashString(), Data.sha256(), digest(), base64StringFromData(), String.sha256() 이거임. 동작 과정은 아래와 같음.
- String.sha256()을 불러 파라미터를 인코딩해줌. 인코딩해 준 결과물에 대해 Data.sha256()을 실행
- Data.sha256()에서 파라미터를 NSData format으로 digest() 함수로 넘김
- digest는 전달받은 값으로 SHA256 해싱을 하고 결과를 리턴함
- 2에서 digest()의 결과물에 대해 NSData 형식을 base64로 인코딩하는 과정이 필요함. 이를 base64StringFromData() 함수를 불러 수행함
- base64StringFromData() 함수에서는 넘겨받은 값을 인코딩해줌. SHA256에 의해 마지막 글자는 꼭 패딩이 들어감. Padding 값인 =를 제외하고 싶으면 result = String(result.dropLast()) 이 코드 추가해주면 됨.
- 5까지 완료하면 2에서 최종 값을 리턴해줌.