Skip to content

Encryption Flow

Yura Filatov edited this page Jun 27, 2026 · 1 revision

Encryption Flow

Once you have a contact's verified public key, you can encrypt any data for them. The flow differs depending on whether prekeys are available.


First Message (Long-Term Fallback)

1. Retrieve contact's stored public key (decrypted from SwiftData)
2. If contact has QuantumKeyMaterial:
     HKDF(ECDH_secret || ML-KEM_secrets, info: kHybridTransportKeyInfo) → sessionKey
   Else:
     HKDF(ECDH_secret, info: kTransportKeyInfo) → sessionKey
3. Encode SealedPayload { message, prekeyBatch: nil }
4. AES-GCM.seal(SealedPayload, using: sessionKey, authenticating: AAD)
5. Contact detects fallback → generates our prekeys → sends them back

Subsequent Messages (Forward Secret)

1. Pop oldest prekey from contact's stored batch
2. generateEphemeralKeyPair() → throwaway key, never persisted
3. If contact has QuantumKeyMaterial:
     HKDF(ECDH(ephPriv, prekey) || ML-KEM_secrets, info: kHybridFSTransportKeyInfo) → sessionKey
   Else:
     HKDF(ECDH(ephPriv, prekey), info: kTransportKeyInfo) → sessionKey
4. Encode SealedPayload { message, prekeyBatch: pendingBatch or nil }
5. AES-GCM.seal(SealedPayload, using: sessionKey, authenticating: AAD)
6. Ephemeral private key discarded; recipient deletes prekey from SE on decrypt

Decryption

Decryption mirrors encryption: the recipient reconstructs the session key using their Secure Enclave prekey private key and the sender's ephemeral public key (plus ML-KEM secrets if available), verifies the GCM tag, decodes the payload, and deletes the prekey. The session key never existed in persistent storage on either side.

Clone this wiki locally