Skip to content

Cryptographic Protocol

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

Cryptographic Protocol

Key Generation

Each device generates one P-256 (secp256r1) identity key pair on first launch. The private key is created using the Secure Enclave — it is wrapped by the SE's hardware key so that only this specific SE chip can use it. The wrapped key blob is stored in the Keychain database. The key cannot be extracted, exported, read in plaintext, or included in device backups. The ThisDeviceOnly accessibility attribute prevents the wrapped blob from migrating to other devices via backup or restore.

Key type:       P-256 (kSecAttrKeyTypeECSECPrimeRandom)
Key size:       256 bits
Protection:     Apple Secure Enclave (kSecAttrTokenIDSecureEnclave)
Access control: kSecAttrAccessibleWhenUnlockedThisDeviceOnly + privateKeyUsage
Persistence:    kSecAttrIsPermanent = true
Tag:            "master.key.privacy.turtles.are.cute"

The public key is exported in X9.63 uncompressed point format (65 bytes: 0x04 || X || Y) for exchange and storage.


Key Storage Model

Secure Enclave–protected keys (Keychain, wrapped by SE hardware)

Key Tag / Account Purpose
P-256 identity key master.key.privacy.turtles.are.cute Long-term identity, ECDH for transport and local DB
P-256 local DB key local.db.se.key.occulta ECDH component of hybrid local encryption key
P-256 prekeys prekey.<contactID>.<uuid> Per-message forward secrecy, deleted after single use
P-256 Secure Mode key app.layer.key.occulta.v1 PIN verifier derivation and blob key derivation (HKDF input)

These keys are stored in the Keychain database as SE-wrapped blobs. The Secure Enclave never releases the unwrapped key material — all cryptographic operations (ECDH, signing) are performed inside the SE chip. An attacker who extracts the Keychain database gets wrapped blobs that are unusable without physical access to the specific SE that created them.

Keychain items (not SE-protected)

Item Account Purpose
Random component local.db.random.key.occulta 256-bit random half of hybrid local DB encryption key

Stored as kSecClassGenericPassword with kSecAttrAccessibleWhenUnlockedThisDeviceOnly. Not SE-wrapped, but encrypted at rest by iOS Data Protection and excluded from backups.

SwiftData (encrypted at application layer)

Data Encryption key Purpose
Contact names, metadata Hybrid local DB key Contact records at rest
Peer P-256 public keys Hybrid local DB key Stored for ECDH when encrypting to a contact
QuantumKeyMaterial Hybrid local DB key ML-KEM shared secrets + ciphertexts from exchange
ForwardSecrecy struct Hybrid local DB key Inbound prekey batches, pending outbound batch

All SwiftData fields containing sensitive data are encrypted with AES-256-GCM before being written to the database. The encryption key is derived from both the SE-protected local DB key and the random Keychain component via HKDF. Decryption requires access to both the specific Secure Enclave and the specific Keychain — neither alone is sufficient.


Shared Secret Derivation

Classical path (v1 contacts or iOS < 26)

Step 1 — ECDH:
  algorithm:  ecdhKeyExchangeCofactorX963SHA256
  output:     32-byte raw shared secret

Step 2 — HKDF:
  KDF:        HKDF<SHA256>
  IKM:        raw ECDH shared secret (32 bytes)
  Salt:       XOR(peerPublicKey_bytes, ourPublicKey_bytes)  [65 bytes each]
  Info:       "Occulta-v1-transport-2025" (UTF-8)
  Output:     32 bytes → SymmetricKey (AES-256)

Hybrid post-quantum path (contacts exchanged on iOS 26+ with ML-KEM-1024)

Step 1 — ECDH:
  algorithm:  ecdhKeyExchangeCofactorX963SHA256
  output:     32-byte raw ECDH shared secret

Step 2 — ML-KEM shared secrets:
  Two independent 32-byte shared secrets from mutual encapsulation (Option A).
  Sorted lexicographically so both sides produce identical input.

Step 3 — HKDF:
  KDF:        HKDF<SHA256>
  IKM:        ECDH_secret || sorted(ML-KEM_secret_1, ML-KEM_secret_2)  [96 bytes]
  Salt:       XOR(peerPublicKey_bytes, ourPublicKey_bytes)  [65 bytes each]
  Info:       "Occulta-v2-hybrid-pq-transport-2026" (UTF-8)
  Output:     32 bytes → SymmetricKey (AES-256)

The hybrid construction is secure if either algorithm remains unbroken. Contacts with QuantumKeyMaterial use the hybrid path; contacts without use the classical path. There is no fallback chain or guessing.


Message & File Encryption

All content encryption uses AES-256-GCM:

Algorithm:  AES-GCM
Key size:   256 bits (derived via HKDF above)
Nonce:      96-bit random nonce (AES.GCM.Nonce(), generated per message)
AAD:        version || sortedKeys(JSON(SecrecyContext))
Output:     combined = nonce || ciphertext || tag  (CryptoKit combined format)

AES-256-GCM is quantum-resistant — Grover's algorithm reduces effective key strength to 128-bit equivalent, which remains computationally infeasible.


Local Database Encryption

Component 1 — SE-derived:
  ECDH(localDB_SE_privkey, G)  →  32 bytes
  where G is the P-256 generator base point (fixed, embedded in app)

Component 2 — Random:
  256-bit random value stored in Keychain
  Generated once via SecRandomCopyBytes, never rotated

Hybrid key:
  HKDF<SHA256>(
    IKM:    SE_component || random_component   [64 bytes]
    Salt:   localDB_SE_public_key_x963         [65 bytes]
    Info:   "Occulta-v2-local-db-pq-2026"
    Output: 32 bytes → SymmetricKey (AES-256)
  )

The SE component provides hardware binding. The random component provides post-quantum resistance — a quantum adversary who recovers the SE private key via Shor's algorithm still faces ~2^128 (Grover's bound) on the 256-bit random half. An attacker needs both components to derive the hybrid key.

Restoring a backup to a different device renders local data permanently inaccessible. This is the intended security posture.


Signing

Identity verification uses ECDSA:

Algorithm:  SecKeyAlgorithm.ecdsaSignatureMessageX962SHA256
Encoding:   X9.62 DER
Digest:     SHA-256
Output:     hex-encoded signature string

Verification Words (Diceware)

After a key exchange completes, both parties see a set of human-readable Diceware words derived from the shared key material. Both parties must read these words aloud and confirm they match before the key is stored.

For hybrid PQ exchanges, the Diceware derivation includes both exchange nonces (16 bytes each, committed in the discovery message before proximity is confirmed) so that verification words are unique on every exchange session, even between the same key pairs.

Classical:  HKDF(ECDH_secret, info: "Occulta-v1-transport-2025")
Hybrid PQ:  HKDF(ECDH_secret || ML-KEM_secrets, info: "Occulta-v2-diceware-2026" || sorted(nonce_A, nonce_B))

Clone this wiki locally