Windows에서는 같은 암호로 파일을 복호화할 수 있는데 iOS로 옮기면 오류가 난다고 해서 바로 데이터를 초기화할 필요는 없습니다. 암호화 알고리즘이 아니라 암호 인코딩, 반복 횟수, 파생 길이가 서로 다른 것이 원인일 수 있습니다. 먼저 클라우드 Mac에서 알려진 정답 테스트를 실행한 뒤, iOS 테스트 타깃에서 실제 래퍼를 호출하면 문제가 발생하는 계층을 명확히 좁힐 수 있습니다.
입력 규약 고정하기
이 글은 이미 PBKDF2 프로토콜을 사용하는 크로스 플랫폼 프로젝트를 대상으로 합니다. 모든 신규 시스템에서 PBKDF2를 기본으로 선택하라는 뜻은 아닙니다. 먼저 알고리즘, PRF, 암호의 바이트 인코딩, 솔트 바이트, 반복 횟수, 출력 길이의 여섯 항목을 명시해야 합니다. 여기서는 PBKDF2-HMAC-SHA256을 사용하고 출력 길이를 32바이트로 고정합니다.
암호는 UTF-8로 인코딩하며, 공백 제거, 대소문자 변환, 유니코드 정규화를 자동으로 수행하지 않습니다. 눈으로 보기에 같은 문자열도 바이트는 다를 수 있습니다. 기존 프로토콜에 정규화 규칙이 있다면 반드시 그대로 유지하고, 해당 규칙도 버전 관리에 포함해야 합니다.
알려진 정답 테스트는 “계산이 올바른가”를 확인하고, 기기 측정은 “작업 계수가 적절한가”를 확인합니다. 테스트 벡터의 반복 횟수인 1회나 2회를 그대로 프로덕션 설정에 사용해서는 안 됩니다.
솔트로 전달하는 값이 16진수 텍스트가 아니라 원시 바이트인지도 확인해야 합니다. 문자열 73616c74를 솔트로 쓰는 것과 단어 salt의 바이트를 전달하는 것은 서로 다른 입력입니다.
표준 테스트 벡터 실행하기
전체 개발 도구 체인이 선택된 Mac에서 다음 내용을 pbkdf2-check.swift로 저장합니다. 시스템의 CommonCrypto를 직접 호출하므로 의존성을 다운로드할 필요가 없습니다. 검증에 실패하면 실행이 중단되며, 암호나 파생 결과는 출력하지 않습니다.
import Foundation
import CommonCrypto
enum DerivationError: Error {
case invalidInput
case failed(Int32)
}
func derive(
password: String,
salt: [UInt8],
rounds: UInt32
) throws -> [UInt8] {
guard rounds > 0, !salt.isEmpty else {
throw DerivationError.invalidInput
}
let passwordBytes = password.utf8CString
var output = [UInt8](repeating: 0, count: 32)
let status = passwordBytes.withUnsafeBufferPointer { p in
salt.withUnsafeBufferPointer { s in
output.withUnsafeMutableBufferPointer { o in
CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2),
p.baseAddress,
p.count - 1,
s.baseAddress,
s.count,
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
rounds,
o.baseAddress,
o.count
)
}
}
}
guard status == kCCSuccess else {
throw DerivationError.failed(status)
}
return output
}
func hex(_ bytes: [UInt8]) -> String {
bytes.map { String(format: "%02x", $0) }.joined()
}
let salt = Array("salt".utf8)
let vectors: [(UInt32, String)] = [
(1, "120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b"),
(2, "ae4d0c95af6b46d32d0adff928f06dd02a303f8ef3c251dfd6e2d85a95474c43")
]
for (rounds, expected) in vectors {
let actual = try derive(
password: "password",
salt: salt,
rounds: rounds
)
precondition(hex(actual) == expected)
}
let full = try derive(password: "ab\u{0000}cd", salt: salt, rounds: 2)
let prefix = try derive(password: "ab", salt: salt, rounds: 2)
precondition(full != prefix)
let composed = try derive(password: "\u{00e9}", salt: salt, rounds: 2)
let decomposed = try derive(password: "e\u{0301}", salt: salt, rounds: 2)
precondition(composed != decomposed)
xcrun swift pbkdf2-check.swift를 실행한 뒤 종료 코드가 0이면 위 단언문을 모두 통과한 것입니다. 여기서는 인터프리터 방식으로 실행합니다. 단언문을 비활성화하는 최적화 빌드로 바꾸지 마세요.
코드에서는 암호의 바이트 길이를 명시적으로 전달하여 끝의 종료 문자는 제외하되, 중간에 포함된 널 문자는 유지합니다. 널 종료 문자를 기준으로 길이를 계산하는 인터페이스로 바꾸면 암호의 뒷부분이 잘립니다.
실제 앱 래퍼에 연결하기
스크립트가 통과하면 같은 샘플을 iOS의 XCTest 타깃에 추가하되, 앱에서 실제로 사용하는 키 파생 진입점을 호출해야 합니다. 테스트 전용 구현을 하나 더 복사하는 것으로 끝내서는 안 됩니다. 그러면 하위 계층의 계산은 정확해도 상위 계층에서 솔트를 잘못 디코딩하는 문제는 남을 수 있습니다.
최소한 다음 네 가지 검사를 추가합니다.
- 빈 암호: 프로토콜에 따라 허용하거나 거부한다고 명시하고, 알리지 않은 채 다른 값으로 바꾸지 않습니다.
- 중국어, 이모지, 내장 널 문자: 양쪽 플랫폼의 입력 바이트와 최종 결과를 대조합니다.
- 반복 횟수 0, 손상된 솔트 인코딩, 알 수 없는 알고리즘 식별자: 파생 함수를 호출하기 전에 거부합니다.
- 같은 암호와 서로 다른 솔트: 고정 샘플로 결과가 다른지 확인하여 상수 솔트의 오용을 방지합니다.
예제 함수가 빈 솔트를 거부하는 것은 이 프로젝트의 입력 정책입니다. 알고리즘이 빈 솔트를 처리할 수 없다는 뜻은 아닙니다. 프로덕션 솔트는 시스템의 암호학적으로 안전한 난수원으로 생성해야 합니다. 난수원에 문제가 생기면 반드시 오류를 반환해야 하며, 타임스탬프로 대체해서는 안 됩니다.
테스트 계층 구분하기
Mac 스크립트는 알고리즘 호출이 올바르게 연결되었는지 검증하고, 시뮬레이터 테스트는 앱 래퍼를 검증하며, 실제 기기 테스트는 소요 시간과 UI에 미치는 영향을 측정합니다. 세 계층에서 동일한 입력 규약을 기록하되, Mac의 속도를 휴대전화의 속도로 간주해서는 안 됩니다.
갱신에 필요한 매개변수 저장하기
키 파생 레코드에는 최소한 형식 버전, 알고리즘 식별자, 반복 횟수, 솔트, 출력 길이를 저장해야 합니다. 텍스트 인코딩이 사용된다면 솔트에 어떤 Base64 규칙을 적용하는지 등 형식도 명시합니다. 솔트는 암호가 아니며, 숨겨야만 보안이 유지되는 값도 아닙니다.
레코드를 읽을 때는 필드의 타입, 길이, 계산량 한도를 검증한 뒤 파생 연산을 실행합니다. 손상된 파일에 지나치게 큰 반복 횟수가 들어 있어 앱이 오랫동안 CPU를 점유하는 상황을 막아야 합니다. 작업 계수는 보안 요구 사항과 지원 기기의 실측 결과를 바탕으로 정해야 하며, 테스트 벡터에서 그대로 가져와서는 안 됩니다.
매개변수를 갱신할 때 전역 상수만 덮어쓴 뒤 이전 레코드를 읽어서는 안 됩니다. 이전 레코드는 반드시 이전 매개변수로 검증해야 합니다. 필요한 평문을 성공적으로 확보한 다음에 새 솔트와 새 정책으로 새 버전의 레코드를 생성합니다.
파생 결과를 복호화에 사용한다면, 이전 암호문만으로 매개변수를 바로 교체할 수는 없습니다. 먼저 복호화에 성공한 뒤 다시 암호화해야 하며, 새 레코드를 완전히 기록하고 검증하기 전까지는 이전 레코드를 보존해야 합니다. 저장하는 값이 암호 검증값이라면 비교할 때 검토를 거친 상수 시간 비교 구현을 사용하고, 예제의 일반적인 배열 비교를 그대로 가져다 쓰지 마세요.
병합 검사 마련하기
CI에는 독립적인 검사 두 가지를 둡니다. 표준 테스트 벡터는 바이트 단위로 일치해야 하고, 앱의 레코드 처리 로직은 이전 버전을 읽을 수 있으면서 잘못된 매개변수는 거부해야 합니다. 여기에 테스트 전용 이전 레코드도 하나 추가하여 갱신 후에도 열 수 있는지, 새로 기록한 레코드가 실제로 새 버전을 사용하는지 확인합니다.
실패 보고서에는 테스트 케이스 이름, 알고리즘, 민감하지 않은 매개변수만 남깁니다. 암호, 파생 키, 실제 사용자 레코드는 업로드하지 않습니다. 성능 측정은 조건을 통제한 실제 기기 작업에서 수행하고, 공유 실행 환경에서 한 번 측정한 소요 시간으로 병합을 차단하지 않습니다.
마지막으로 마이그레이션 실패 경로도 확인합니다. 취소, 디스크 쓰기 실패, 앱 종료 후에도 이전 레코드는 여전히 읽을 수 있어야 합니다. 이렇게 해야 단순한 암호 함수가 아니라, 매개변수 갱신으로 데이터가 손실되지 않는 전체 처리 경로를 검증할 수 있습니다.
자주 묻는 질문
예제의 반복 횟수 1회와 2회를 운영 환경에 써도 되나요?
안 됩니다. 알고리즘과 인자 연결을 확인하는 정답 벡터 전용 값입니다. 운영 반복 횟수는 보안 정책과 대상 기기 측정으로 정하고, 외부에서 읽은 값에는 계산량 상한도 적용해야 합니다.
Mac에서 통과하면 iOS 검증도 끝난 건가요?
아닙니다. Mac 스크립트는 알고리즘과 바이트 계약을 빠르게 점검합니다. iOS 테스트 타깃에서 실제 서비스 래퍼를 호출하고, 대상 실기기에서 처리 시간과 사용자 조작에 미치는 영향도 측정해야 합니다.
클라우드 Mac 대여
일간, 주간, 월간, 분기 단위로 전용 물리 서버를 이용할 수 있습니다. 프로젝트 일정에 맞춰 빌드하고, 기간이 끝나면 연장하지 않는 한 서비스가 중지됩니다.
기종 선택