-
Notifications
You must be signed in to change notification settings - Fork 0
Cryptographic Protocol
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 | 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.
| 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.
| 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.
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)
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.
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.
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.
Identity verification uses ECDSA:
Algorithm: SecKeyAlgorithm.ecdsaSignatureMessageX962SHA256
Encoding: X9.62 DER
Digest: SHA-256
Output: hex-encoded signature string
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))