2026년 8월 15일 · 10분 읽기
CryptoKit 종단간 암호화: libsodium 없이 키 계층 만들기
libsodium을 제거하고 CryptoKit만으로 종단간 암호화 키 계층을 옮긴 기록입니다. 테스트를 쓰다가 만들어진 순간부터 열 수 없는 암호 봉투를 발견합니다.
2026년 8월 23일 업데이트
암호 기반 봉투가 있었습니다. 생성한 순간부터 영구히 열 수 없는 상태였습니다.
테스트를 쓰면서 발견했습니다. 봉투를 만들 때 쓴 salt를 어디에도 저장하지 않았습니다. 같은 KEK를 다시 만들 수 없으니 그 봉투는 데이터일 뿐 자물쇠가 아니었습니다.
그리고 그 봉투가 유일한 Argon2 사용처였습니다. Argon2를 위해 libsodium을 넣고 있었습니다.
한눈에 보기
| 문제 | 암호 기반 봉투가 있었습니다. |
| 결정 | 테스트를 쓰면서 발견했습니다. |
| 결과 | 발견한 것을 테스트 이름으로 남겼습니다. |
| 제약 | 첨부 저장에 iCloud가 필요합니다. |
알고리즘을 계약으로 고정했습니다
암호화 코드에서 가장 먼저 정할 것은 알고리즘입니다. 나중에 바꾸면 옛 데이터를 못 엽니다.
// JustSendCrypto.swift:6-9
/// 알고리즘 계약(크로스플랫폼 고정): AES-256-GCM(96-bit nonce, 128-bit tag),
/// HKDF-SHA256, 키 계층 ARK→IK→per-item CK, 인코딩 Base64 std(no-wrap).
/// **이 구현이 KAT 벡터의 레퍼런스**다 — 웹/안드로이드는 여기 산출 바이트를
/// 그대로 재현해야 한다(JustSendCryptoTests의 NIST 벡터 + 왕복 벡터가 계약).
마지막 문장이 이 주석의 목적입니다.
웹과 안드로이드를 나중에 만들 것입니다. 그때 "같은 알고리즘"이라는 말로는 부족합니다. 같은 바이트가 나와야 합니다.
인코딩까지 계약에 넣었습니다. Base64에 줄바꿈이 들어가는 구현이 있습니다. 표준이고 줄바꿈 없음입니다.
테스트가 계약을 지킵니다. NIST 공개 벡터로 알고리즘 자체를 검증하고, 왕복 벡터로 우리 구현의 산출 바이트를 고정합니다.
저장 형식도 계약입니다. 같은 알고리즘인데 바이트를 어떻게 담는지가 두 가지입니다.
| 필드 | 담는 것 | 왜 그렇게 |
|---|---|---|
ciphertext |
ct || tag |
nonce가 별도 필드로 있다 |
nonce |
12B | 서버의 size_bytes가 ciphertext 길이여야 한다 |
wrapped_* |
nonce || ct || tag |
필드가 하나뿐이라 자기완결이어야 한다 |
같은 함수가 두 형식을 만듭니다. 항목 내용은 nonce를 떼어 담고, 감싼 키는 붙여서 담습니다. 서버가 size_bytes로 용량을 세는데 그 값에 nonce 12바이트가 섞이면 안 되고, 감싼 키는 저장할 칸이 하나뿐이라 nonce를 안에 넣어야 합니다.
이 차이를 문서로만 두면 다음 플랫폼이 틀립니다. 그래서 주석에 필드 이름까지 적어 뒀습니다.
키가 세 층입니다
키 하나로 모든 것을 암호화하지 않습니다. 세 층으로 나눕니다.
| 층 | 이름 | 무엇을 감싸는가 |
|---|---|---|
| 1 | ARK (계정 루트 키) | IK |
| 2 | IK (항목 키) | 각 항목의 CK |
| 3 | CK (항목별 콘텐츠 키) | 그 항목의 내용 |
각 층이 무엇으로 감싸이는지도 정해져 있습니다.
| 키 | 무엇으로 감싸이나 | 어디에 저장되나 |
|---|---|---|
| ARK | 기기 키, 복구 코드 | 기기 키체인, 서버의 감싼 사본 |
| IK | ARK | 서버 |
| CK | IK | 그 항목 행 |
서버는 감싼 것만 갖습니다. 감싼 것을 벗길 키는 기기와 사용자 머릿속에만 있습니다. 서버 데이터베이스를 통째로 가져가도 열 것이 없습니다.
층을 나누는 이유는 교체 범위입니다.
항목 하나의 키가 새면 그 항목만 위험합니다. 다른 항목은 각자 다른 CK로 잠겨 있습니다.
ARK가 새면 전부 위험합니다. 그래서 ARK는 어디에도 평문으로 저장하지 않습니다.
감싸기와 봉인이 형식이 다릅니다
같은 AES-GCM을 쓰는데 두 가지 형식이 있습니다.
// JustSendCrypto.swift:39-81
// 봉인 (항목 내용): nonce를 따로, 태그는 암호문에 붙여서
static func seal(_ plaintext: Data, key: SymmetricKey, ...) throws -> AEAD {
return AEAD(ciphertext: box.ciphertext + box.tag, nonce: Data(gcmNonce))
}
// 감싸기 (키): nonce(12) || ct || tag 를 한 덩어리로
static func wrap(_ key: SymmetricKey, with kek: SymmetricKey, ...) throws -> Data {
let aead = try seal(rawBytes(key), key: kek, nonce: explicitNonce)
return aead.nonce + aead.ciphertext
}
항목 내용은 nonce를 별도 컬럼에 저장합니다. 서버 스키마에 nonce 필드가 따로 있습니다.
감싼 키는 한 덩어리입니다. 주석에 == CryptoKit combined라고 적어 뒀습니다. CryptoKit의 combined 표현과 같은 바이트 배열입니다.
형식이 둘인 이유는 저장 위치입니다. 항목은 관계형 테이블의 행이라 컬럼을 나눌 수 있습니다. 감싼 키는 한 문자열로 다뤄야 편합니다.
nonce는 반드시 새로 만듭니다
// JustSendCrypto.swift:48
/// CSPRNG nonce를 쓴다(같은 키에 nonce 재사용은 GCM 치명 취약).
GCM에서 같은 키로 같은 nonce를 두 번 쓰면 평문을 복원할 수 있습니다. 카운터 모드의 성질입니다.
함수가 nonce를 인자로 받게 해 뒀습니다. 테스트 벡터를 재현하기 위해서입니다. 기본값은 nil이고, 그때 CryptoKit이 난수로 만듭니다.
도메인 분리를 HKDF로 합니다
키 하나에서 여러 용도의 키를 파생할 때 용도를 섞으면 안 됩니다.
// JustSendCrypto.swift:93-98
/// HKDF-SHA256 서브키 파생(§3.3). salt/info로 도메인 분리.
static func hkdf(secret: Data, salt: Data, info: Data, length: Int = keyByteCount) -> SymmetricKey {
HKDF<SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: secret),
salt: salt, info: info, outputByteCount: length
)
}
info에 용도 문자열을 넣습니다. 같은 비밀에서 파생해도 용도가 다르면 다른 키가 나옵니다.
실제 사용을 보면 문자열에 버전이 있습니다.
// RecoveryCode.swift:30-32
salt: Data("justsend/recovery-kek/v1".utf8)
// ShareSnapshotSeal.swift:52
info: Data("justsend-share-attachment-v1".utf8)
버전을 넣어 두면 나중에 파생 규칙을 바꿀 때 옛 것과 구분됩니다.
언락 경로가 셋입니다
설계의 중심 질문은 하나입니다. ARK를 어떻게 얻는가. 세 경로가 있습니다.
// AccountBootstrap.swift:33-34
/// 실사용 언락은 (a) 이 기기의 자가-ECDH 기기 봉투, (b) 다른 기기의 grant,
/// (c) 복구 코드(HKDF-SHA256) 세 경로이며 모두 Argon2에 의존하지 않는다.
세 경로를 나란히 놓으면 필요한 것과 사람 개입이 다릅니다.
| 경로 | KEK를 만드는 재료 | 사람이 해야 할 것 | 언제 쓰나 |
|---|---|---|---|
| (a) 기기 봉투 | 이 기기의 Curve25519 키로 자가 ECDH | 없음 | 평소 |
| (b) 다른 기기 승인 | 기존 기기가 감싸 준 ARK | 기존 기기에서 승인 | 새 기기 추가 |
| (c) 복구 코드 | 코드 문자열에서 HKDF-SHA256 | 코드 입력 | 기기가 전부 없을 때 |
셋 중 (a)만 사용자가 아무것도 하지 않습니다. 그래서 평소 경로가 (a)이고, 나머지 둘은 (a)가 없을 때만 씁니다. 그리고 셋 다 Argon2를 쓰지 않습니다. 이것이 뒤에서 의존성을 뺄 수 있었던 이유입니다.
(a) 이 기기의 봉투
기기마다 Curve25519 키 쌍을 만듭니다. 자기 공개키로 ECDH를 해서 KEK를 만들고 그것으로 ARK를 감쌉니다.
// JustSendDeviceKeys.swift:20-32
/// ECDH(ourPrivate, theirPublic) → HKDF-SHA256 → 32B KEK. 기기 봉투(c):
/// `wrap(ARK, KEK_device)`. 양쪽이 같은 salt/info로 동일 KEK를 파생한다.
static func agreementKEK(
ourPrivate: Curve25519.KeyAgreement.PrivateKey,
theirPublic: Data, salt: Data = Data(), info: Data = deviceKEKInfo
) throws -> SymmetricKey {
let peer = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: theirPublic)
let shared = try ourPrivate.sharedSecretFromKeyAgreement(with: peer)
return shared.hkdfDerivedSymmetricKey(...)
}
기기 개인키는 키체인에 있습니다. 앱이 시작하면 그것으로 ARK를 풉니다.
(b) 다른 기기의 승인
새 기기가 계정에 붙으면 ARK가 없습니다. 기존 기기가 줘야 합니다.
새 기기가 자기 공개키를 서버에 올립니다. 기존 기기가 그 공개키로 ECDH를 해서 KEK를 만들고 ARK를 감싸 올립니다. 새 기기가 자기 개인키로 풉니다.
서버는 감싸인 것만 봅니다. 열 수 없습니다.
(c) 복구 코드
기기가 전부 없어졌을 때의 안전망입니다. 코드 문자열에서 KEK를 파생합니다.
// RecoveryCode.swift:7-9
/// 언락 경로이고, 이 키는 신뢰 기기가 전부 없어졌을 때만 쓰는 안전망이다.
/// 코드 문자열에서 HKDF-SHA256로 KEK를 파생하므로, 표시 형식(대소문자/하이픈)이
/// 달라도 정규화 후 동일 KEK가 나온다.
정규화를 넣은 이유가 있습니다. 사용자가 코드를 손으로 옮겨 적습니다.
// RecoveryCode.swift:11
/// Crockford Base32(혼동 문자 I/L/O 제외).
Crockford Base32는 I, L, O를 제외합니다. 1, 1, 0과 헷갈리기 때문입니다.
정규화는 대문자로 바꾸고, 하이픈과 공백을 지우고, 혼동 문자를 치환합니다. 사용자가 소문자로 쓰거나 하이픈을 빼도 같은 키가 나옵니다.
코드 자체는 16바이트 난수입니다. 128비트 엔트로피를 Crockford Base32로 인코딩하고 네 글자마다 하이픈을 넣습니다. 사용자가 옮겨 적을 때 끊어 읽을 수 있게 하는 표기입니다.
난수는 SecRandomCopyBytes로 만듭니다. 그것이 실패하면 CryptoKit의 난수 키에서 앞 16바이트를 씁니다. 난수원이 하나 죽어도 코드는 만들어집니다.
이 코드는 설정에서 사용자가 직접 켜야 생깁니다. 기본 언락 경로는 로그인과 기기 승인이고, 복구 코드는 신뢰 기기가 전부 없어졌을 때만 씁니다.
열 수 없는 봉투가 하나 더 있었습니다
네 번째 경로가 있었습니다. 암호 기반 봉투입니다.
사용자가 암호를 입력하고 그것으로 KEK를 만듭니다. Argon2로 파생합니다. 그래서 libsodium이 필요했습니다.
테스트를 쓰면서 이것이 동작하지 않는다는 것을 알았습니다.
// JustSendBootstrapTests.swift:55-56
/// 그 값을 어디에도 저장하지 않았다. 따라서 그 봉투는 생성 즉시 영구히 열 수 없었다.
/// 실사용 언락은 device(자가/크로스 grant)와 recovery(HKDF) 봉투가 담당한다.
Argon2는 salt가 필요합니다. 같은 암호에서 같은 키를 얻으려면 같은 salt를 써야 합니다.
봉투를 만들 때 salt를 난수로 만들고 저장하지 않았습니다.
그래서 그 봉투는 만들어진 순간부터 열 수 없었습니다. 사용자가 정확한 암호를 넣어도 열리지 않습니다.
다른 경로가 항상 먼저 성공했습니다
기기 봉투가 있으면 그것으로 풉니다. 암호 봉투에 도달하지 않습니다.
기기 봉투가 없으면 새 기기이고, 그때는 승인이나 복구 코드를 씁니다. 여기서도 암호 봉투에 도달하지 않습니다.
암호 봉투는 코드에 있고 데이터에 있는데 실행되지 않았습니다.
테스트로 사실을 고정했습니다
발견한 것을 테스트 이름으로 남겼습니다.
// JustSendBootstrapTests.swift:40-57
/// PIN(특성화) — recovery 봉투는 이미 HKDF-SHA256 경로이며 Argon2에 의존하지 않는다.
func testRecoveryEnvelopeIsArgon2Independent_characterization() throws { ... }
func testBootstrapProducesNoPassphraseEnvelope() throws { ... }
첫 번째는 복구 봉투가 Argon2와 무관하다는 것을 고정합니다.
두 번째는 부트스트랩이 암호 봉투를 만들지 않는다는 것을 고정합니다. 누군가 다시 넣으면 이 테스트가 실패합니다.
이름에 characterization을 붙였습니다. 현재 동작을 기술하는 테스트라는 뜻입니다. 설계 의도가 아니라 사실을 고정합니다.
의존성 하나가 사라졌습니다
Argon2가 필요 없어지면 libsodium이 필요 없어집니다.
libsodium을 빼면 얻는 것이 있습니다.
| 이전 | 이후 | |
|---|---|---|
| 암호 라이브러리 | libsodium + CryptoKit | CryptoKit |
| 수출 규정 문서 | 서드파티 암호 신고 | Apple 제공 암호만 |
두 번째가 실무에서 큽니다. 앱스토어에 올릴 때 암호화 사용을 신고합니다. Apple이 제공하는 암호만 쓰면 절차가 짧습니다.
동작하지 않는 코드를 지우는 것이 이 이득을 데려왔습니다.
한 곳이 아직 libsodium을 말합니다
소스에서 libsodium을 찾으면 Swift 코드와 프로젝트 설정에는 0건입니다. 남아 있는 곳이 하나입니다.
JustSend/Resources/Legal/OPEN-SOURCE-NOTICES.txt:29
swift-sodium / libsodium — ISC오픈소스 고지 파일입니다. 링크하지 않는 라이브러리를 고지하고 있습니다.
틀린 고지는 없는 고지만큼 나쁩니다. 이 파일을 읽는 사람은 앱이 그 라이브러리를 쓴다고 믿습니다. 앞 편의 문서 낡음과 같은 종류의 문제이고, 여기서는 검사하는 스크립트도 없습니다.
공유 링크는 서버가 키를 못 봅니다
공유 링크는 다른 문제입니다. 받는 사람이 우리 앱을 안 씁니다. 브라우저로 엽니다.
키를 URL fragment에 넣습니다.
// ShareServiceV2.swift:828-829
/// 실리지 않으므로(RFC 3986 §3.5) 서버는 이 값을 관측할 수 없다.
static func withKeyFragment(_ url: URL, key: SymmetricKey) -> URL { ... }
fragment는 # 뒤의 부분입니다. 브라우저가 서버로 보내지 않습니다. RFC에 그렇게 정의돼 있습니다.
https://share.example.com/abc123#키가여기
^^^^^^^^ 서버로 안 갑니다서버는 암호문을 갖고 키를 모릅니다. 링크를 받은 사람의 브라우저가 fragment에서 키를 읽어 복호화합니다.
공유마다 새 키입니다
// ShareSnapshotSeal.swift:43-44
/// 공유 1건마다 새로 만든다. 재발행하면 새 키가 나오고 옛 fragment는 무용지물이 된다.
static func newKey() -> SymmetricKey { SymmetricKey(size: .bits256) }
재발행이 옛 링크를 무효화합니다. 이것이 취소 기능이 됩니다. 서버에서 뭘 지우지 않아도 됩니다.
첨부마다 키를 분리합니다
// ShareSnapshotSeal.swift:46-53
/// 첨부 하나마다 공유 키를 분리한다. assetId를 salt로 써서 같은 공유 안의
/// 첨부가 서로의 암호문을 재사용할 수 없게 한다.
static func attachmentKey(shareKey: SymmetricKey, assetID: String) -> SymmetricKey {
HKDF<SHA256>.deriveKey(
inputKeyMaterial: shareKey, salt: Data(assetID.utf8),
info: Data("justsend-share-attachment-v1".utf8), ...)
}
같은 공유에 파일이 셋 있으면 각각 다른 키로 잠깁니다. 하나의 암호문을 다른 파일 자리에 넣어도 열리지 않습니다.
청크마다 위치를 인증합니다
큰 파일은 나눠서 암호화합니다. 각 청크의 nonce에 순번을 넣습니다.
// ShareSnapshotSeal.swift:82-90
nonceData.append(contentsOf: uint64BE(UInt64(index)))
let nonce = try AES.GCM.Nonce(data: nonceData)
let box = try AES.GCM.seal(chunk, using: key, nonce: nonce,
authenticating: attachmentAAD(assetID: assetID, index: index, count: count))
authenticating에 넣은 값은 암호화되지 않고 인증됩니다. 파일 id, 청크 번호, 전체 개수가 들어갑니다.
청크 순서를 바꾸거나 하나를 빼면 복호화가 실패합니다. 청크 자체가 유효해도 위치가 다르면 열리지 않습니다.
암호화가 기능을 막지 않게 했습니다
암호화를 넣으면 게이트가 생깁니다. 키가 없으면 아무것도 못 합니다.
게이트를 어디까지 넓힐지 판단이 필요했습니다.
// ConnectionMode.swift:41-43
/// 게이트를 거기까지 넓히면 iCloud를 끈 사용자가 기록 동기화를 통째로 잃는데
/// 그렇게 지켜지는 것이 없다. 그들의 키는 서버 `key_envelopes`와 기기
/// Keychain에 있고 기기 승인 경로도 살아 있다.
첨부 저장에 iCloud가 필요합니다. iCloud를 끈 사용자는 첨부를 못 씁니다.
그것을 기록 동기화까지 막는 데 쓰면 안 됩니다. 기록의 키는 서버 봉투와 키체인에 있고 iCloud와 무관합니다.
막아서 지켜지는 것이 없으면 막지 않습니다.