Blog

August 15, 2026 · 13 min read

CryptoKit End-to-End Encryption: A Key Hierarchy Without libsodium

Building a key hierarchy on CryptoKit alone: ECDH agreement, HKDF derivation, and per-record keys wrapped for each device. What the scheme covers and what it deliberately does not.

  • CryptoKit
  • E2EE
  • encryption
  • iOS

There was a password-based envelope. From the moment it was created, it could never be opened.

I found it while writing tests. I had not stored the salt used to create the envelope anywhere. Since I could not derive the same KEK again, that envelope was data, not a lock.

And that envelope was the only place Argon2 was used. I was adding libsodium for Argon2.

From the account root key to each item's content key

I fixed the algorithms as a contract

The first thing I had to decide in encryption code was the algorithm. If I changed it later, I would not be able to open old data.

// 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 벡터 + 왕복 벡터가 계약).

The last sentence was the purpose of this comment.

I would build the web and Android implementations later. At that point, saying “the same algorithm” would not be enough. They would have to produce the same bytes.

I included encoding in the contract as well. Some Base64 implementations insert line breaks. Ours was standard Base64 with no line breaks.

The tests enforced the contract. I used public NIST vectors to verify the algorithm itself, and round-trip vectors to fix the output bytes of our implementation.

The storage format was also part of the contract. The algorithm was the same, but there were two ways to store the bytes.

Field Contents Why
ciphertext ct || tag The nonce was in a separate field
nonce 12B The server's size_bytes had to be the length of ciphertext
wrapped_* nonce || ct || tag There was only one field, so it had to be self-contained

The same function produced both formats. I stored item content with the nonce separated, and stored wrapped keys with the nonce prepended. The server counted capacity using size_bytes, so the 12-byte nonce could not be included in that value. Wrapped keys had only one storage field, so the nonce had to be included inside them.

If I left this difference only in documentation, the next platform would get it wrong. I had therefore written the field names directly into the comments.

There were three key layers

I did not encrypt everything with one key. I divided it into three layers.

Layer Name What it wrapped
1 ARK (account root key) IK
2 IK (item key) Each item's CK
3 CK (per-item content key) The item's content

I also defined what wrapped each layer.

Key Wrapped by Where it was stored
ARK Device key, recovery code Device Keychain, wrapped copy on the server
IK ARK Server
CK IK The item's row

The server held only wrapped values. The keys needed to unwrap them existed only on the device and in the user's memory. Even if someone took the entire server database, there would be nothing to open.

The reason for separating the layers was the scope of replacement.

If one item's key leaked, only that item was at risk. The other items were locked with their own CKs.

If the ARK leaked, everything was at risk. I therefore never stored the ARK in plaintext anywhere.

Wrapping and sealing had different formats

I used the same AES-GCM, but there were two formats.

// 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
}

I stored the nonce for item content in a separate column. The server schema had a separate nonce field.

A wrapped key was one block. I had written == CryptoKit combined in the comment. It was the same byte array as CryptoKit's combined representation.

The reason for the two formats was where they were stored. Items were rows in a relational table, so their values could be split across columns. Wrapped keys were more convenient to handle as one string.

I always generated a new nonce

// JustSendCrypto.swift:48
/// CSPRNG nonce를 쓴다(같은 키에 nonce 재사용은 GCM 치명 취약).

If I used the same nonce twice with the same key in GCM, the plaintext could be recovered. That was a property of counter mode.

I made the function accept a nonce as an argument so that I could reproduce test vectors. The default was nil, in which case CryptoKit generated one randomly.

I used HKDF for domain separation

When deriving keys for multiple purposes from one key, I could not mix the purposes.

// 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
    )
}

I put the purpose string in info. Even when I derived keys from the same secret, different purposes produced different keys.

The actual uses included versions in their strings.

// RecoveryCode.swift:30-32
salt: Data("justsend/recovery-kek/v1".utf8)
// ShareSnapshotSeal.swift:52
info: Data("justsend-share-attachment-v1".utf8)

Including a version allowed me to distinguish old values if I changed the derivation rules later.

There were three unlock paths

The central design question was how to obtain the ARK. There were three paths.

// AccountBootstrap.swift:33-34
/// 실사용 언락은 (a) 이 기기의 자가-ECDH 기기 봉투, (b) 다른 기기의 grant,
/// (c) 복구 코드(HKDF-SHA256) 세 경로이며 모두 Argon2에 의존하지 않는다.

When I placed the three paths side by side, their requirements and the amount of user involvement differed.

Path Material used to create the KEK What the user had to do When it was used
(a) Device envelope Self-ECDH using this device's Curve25519 key Nothing Normally
(b) Approval by another device ARK wrapped by an existing device Approve on the existing device Adding a new device
(c) Recovery code HKDF-SHA256 from the code string Enter the code When all devices were gone

Only (a) required no action from the user. That made (a) the normal path; I used the other two only when (a) was unavailable. None of the three used Argon2. That was why I could remove the dependency later.

(a) This device's envelope

I generated a Curve25519 key pair for each device. I performed ECDH with the device's own public key to create a KEK, then wrapped the ARK with it.

// 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(...)
}

The device's private key was in the Keychain. When the app started, I used it to unwrap the ARK.

(b) Approval by another device

A new device had no ARK when it joined the account. An existing device had to provide it.

The new device uploaded its public key to the server. The existing device performed ECDH with that public key, created a KEK, and uploaded the wrapped ARK. The new device unwrapped it with its private key.

The server saw only the wrapped value. It could not open it.

(c) Recovery code

This was the safety net for when all devices were gone. I derived a KEK from the code string.

// RecoveryCode.swift:7-9
/// 언락 경로이고, 이 키는 신뢰 기기가 전부 없어졌을 때만 쓰는 안전망이다.
/// 코드 문자열에서 HKDF-SHA256로 KEK를 파생하므로, 표시 형식(대소문자/하이픈)이
/// 달라도 정규화 후 동일 KEK가 나온다.

There was a reason to normalize the input. Users copied the code by hand.

// RecoveryCode.swift:11
/// Crockford Base32(혼동 문자 I/L/O 제외).

Crockford Base32 excluded I, L, and O because they could be confused with 1, 1, and 0.

Normalization converted the input to uppercase, removed hyphens and spaces, and replaced ambiguous characters. The same key resulted whether the user entered lowercase characters or omitted the hyphens.

The code itself was 16 bytes of random data. I encoded 128 bits of entropy using Crockford Base32 and inserted a hyphen every four characters. This was only a display format to make the code easier to read when copying it by hand.

I generated the random bytes with SecRandomCopyBytes. If that failed, I used the first 16 bytes from a random CryptoKit key. The code could still be generated if one source of randomness failed.

The user had to enable this code manually in Settings before it was created. The default unlock paths were login and device approval; I used the recovery code only when all trusted devices were gone.

There was another envelope that could not be opened

There had been a fourth path: a password-based envelope.

The user entered a password, and I used it to create a KEK. I derived the key with Argon2. That was why libsodium was needed.

I learned while writing tests that this did not work.

// JustSendBootstrapTests.swift:55-56
/// 그 값을 어디에도 저장하지 않았다. 따라서 그 봉투는 생성 즉시 영구히 열 수 없었다.
/// 실사용 언락은 device(자가/크로스 grant)와 recovery(HKDF) 봉투가 담당한다.

Argon2 required a salt. To obtain the same key from the same password, I had to use the same salt.

I generated the salt randomly when creating the envelope and did not store it.

That meant the envelope could not be opened from the moment it was created. It would not open even if the user entered the exact password.

Another path always succeeded first

When a device envelope existed, I used it to unlock the ARK. The password envelope was never reached.

When no device envelope existed, it meant this was a new device, so I used approval or the recovery code. The password envelope was not reached there either.

The password envelope existed in the code and in the data, but it was never executed.

I fixed the facts with tests

I left what I found in the test names.

// JustSendBootstrapTests.swift:40-57
/// PIN(특성화) — recovery 봉투는 이미 HKDF-SHA256 경로이며 Argon2에 의존하지 않는다.
func testRecoveryEnvelopeIsArgon2Independent_characterization() throws { ... }

func testBootstrapProducesNoPassphraseEnvelope() throws { ... }

The first fixed the fact that the recovery envelope was independent of Argon2.

The second fixed the fact that bootstrap did not create a password envelope. If someone added it again, this test would fail.

I added characterization to the name. It meant that the test described the current behavior. It fixed a fact rather than a design intention.

One dependency disappeared

Once Argon2 was no longer needed, libsodium was no longer needed either.

Removing libsodium provided these benefits.

Before After
Cryptography libraries libsodium + CryptoKit CryptoKit
Export compliance documentation Third-party cryptography declaration Apple-provided cryptography only

The second benefit was significant in practice. When submitting the app to the App Store, I had to declare the use of encryption. Using only cryptography provided by Apple shortened the process.

Deleting code that did not work brought this benefit.

One place still mentioned libsodium

When I searched the source for libsodium, I found 0 occurrences in the Swift code and project settings. One place remained.

JustSend/Resources/Legal/OPEN-SOURCE-NOTICES.txt:29
swift-sodium / libsodium — ISC

It was an open-source notices file. It listed a library that was no longer linked.

An incorrect notice was as bad as having no notice. Someone reading this file would believe that the app used that library. It was the same kind of problem as the outdated documentation from the previous section, and there was no script checking it here either.

Shared links were a different problem. The recipient did not use our app. They opened the link in a browser.

I put the key in the URL fragment.

// ShareServiceV2.swift:828-829
/// 실리지 않으므로(RFC 3986 §3.5) 서버는 이 값을 관측할 수 없다.
static func withKeyFragment(_ url: URL, key: SymmetricKey) -> URL { ... }

The fragment was the part after #. The browser did not send it to the server. That was how it was defined in the RFC.

https://share.example.com/abc123#키가여기
                                 ^^^^^^^^ 서버로 안 갑니다

The server held the ciphertext but did not know the key. The recipient's browser read the key from the fragment and decrypted the content.

Each share had a new key

// ShareSnapshotSeal.swift:43-44
/// 공유 1건마다 새로 만든다. 재발행하면 새 키가 나오고 옛 fragment는 무용지물이 된다.
static func newKey() -> SymmetricKey { SymmetricKey(size: .bits256) }

Reissuing a share invalidated the old link. That provided revocation without requiring me to delete anything from the server.

I separated keys for each attachment

// 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), ...)
}

If a share had three files, each was locked with a different key. Replacing one file's ciphertext with another file's ciphertext did not produce a decryptable result.

I authenticated the position of each chunk

I encrypted large files in chunks. I included the sequence number in each chunk's 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))

The values passed to authenticating were not encrypted; they were authenticated. They included the file ID, chunk number, and total count.

If I changed the chunk order or removed one, decryption failed. Even if the chunk itself was valid, it could not be opened if its position was wrong.

I kept encryption from blocking unrelated features

Adding encryption created a gate. Without a key, nothing could work.

I had to decide how far to extend that gate.

// ConnectionMode.swift:41-43
/// 게이트를 거기까지 넓히면 iCloud를 끈 사용자가 기록 동기화를 통째로 잃는데
/// 그렇게 지켜지는 것이 없다. 그들의 키는 서버 `key_envelopes`와 기기
/// Keychain에 있고 기기 승인 경로도 살아 있다.

iCloud was required for attachment storage. Users who disabled iCloud could not use attachments.

I should not use that to block record synchronization as well. The keys for records were in server envelopes and the Keychain, and were unrelated to iCloud.

If blocking something protected nothing, I did not block it.