Skip to content

Security: cypherair/cypherair

Security

docs/SECURITY.md

Security Model

Purpose: Complete description of the encryption scheme, key lifecycle, authentication flows, security invariants, and AI coding boundaries for CypherAir. Audience: Human developers, security auditors, and AI coding tools.

1. Encryption Scheme

All cryptographic operations use Sequoia PGP 2.3.0. Two profiles with different algorithm suites:

Profile A (Universal Compatible)

Purpose Algorithm Notes
Primary key (sign/certify) Ed25519 (legacy EdDSA) v4 key format
Encryption subkey X25519 (legacy ECDH) v4 key format
Symmetric encryption AES-256 256-bit key
Message format SEIPDv1 (MDC) Non-AEAD; GnuPG compatible
Hash SHA-512 Accepts SHA-256 for legacy verification
S2K (key export) Iterated+Salted (mode 3) GnuPG compatible
Compression DEFLATE (read-only) Enabled for reading compatibility; outgoing messages must not use compression
Random SecRandomCopyBytes Via getrandom crate on Apple platforms

Profile B (Advanced Security)

Purpose Algorithm Notes
Primary key (sign/certify) Ed448 v6 key format; ~224-bit security
Encryption subkey X448 v6 key format; inherent AES-256 key wrap
Symmetric encryption AES-256 256-bit key
AEAD OCB (primary), GCM (secondary) SEIPDv2; OCB mandatory per RFC 9580
Hash SHA-512
S2K (key export) Argon2id (512 MB / p=4 / ~3s) Memory-hard
Compression DEFLATE (read-only) Enabled for reading compatibility; outgoing messages must not use compression
Random SecRandomCopyBytes Via getrandom crate on Apple platforms

Interoperability: Profile A output compatible with GnuPG 2.1+ and all PGP tools. Profile B output compatible with Sequoia 2.0+, OpenPGP.js 6.0+, GopenPGP 3.0+, Bouncy Castle 1.82+. The App reads v4 keys, v6 keys, SEIPDv1, SEIPDv2 (OCB/GCM), Iterated+Salted S2K, and Argon2id S2K. Compression (deflate) read-only for compatibility; outgoing messages never compressed. Bzip2 excluded (extra C dependency).

2. Key Lifecycle

Generate (Profile A: Ed25519+X25519 v4 / Profile B: Ed448+X448 v6)
    │
    ├──→ SE Wrap (P-256 self-ECDH + HKDF + AES-GCM)
    │       │
    │       └──→ Store private-key material in Keychain (3 protected items per identity)
    │
    ├──→ Store PGPKeyIdentity metadata in ProtectedData `key-metadata`
    │       └──→ Opened after app-session authentication; no private-key material
    │
    ├──→ Auto-generate revocation certificate
    │       └──→ Prompt user to export separately
    │
    └──→ Prompt user to back up private key + share public key

Use (decrypt / sign):
    Keychain retrieve → SE reconstruct (biometric auth) → HKDF → AES-GCM unseal
    → Perform PGP operation → Zeroize private key from memory

Export (backup):
    Authenticate → User enters strong passphrase
    → Profile A: Iterated+Salted S2K protect → .asc file via Share Sheet
    → Profile B: Argon2id protect → .asc file via Share Sheet

Import (restore):
    .asc file → User enters passphrase → S2K derive (detect mode automatically) → Recover key
    → Generate key-level revocation signature for imported key
    → Generate new SE wrapping key → SE wrap → Store in Keychain

Revocation:
    Export ASCII-armored revocation signature → Distribute to contacts → They import → Key marked revoked

Deletion:
    Double-confirm → Delete SE key from Keychain → Delete salt + sealed box
    → Delete protected key-metadata entry
    → Key permanently inaccessible

Metadata storage note: PGPKeyIdentity metadata is non-sensitive indexing data, but it now lives in the ProtectedData key-metadata domain so key-list loading happens only after app-session authentication opens protected app data. Legacy metadata rows may still exist in the dedicated metadata Keychain account (KeychainConstants.metadataAccount) or older default-account locations; those rows are migration/cleanup sources only and are read after app-session authentication, using the authenticated LAContext handoff when the default account requires it. Private-key blobs, salts, and sealed boxes remain in the protected private-key namespace.

Revocation storage/export note: CypherAir stores revocation signatures internally as binary OpenPGP signature packets. Export converts those bytes to ASCII armor on demand. Imported keys now receive key-level revocation export capability as part of import. Older imported keys that predate this support lazily backfill the binary revocation at export time, then immediately zeroize the temporarily unwrapped secret certificate bytes after use.

Selective revocation note: Subkey and User ID selective revocations are generated and exported on demand. They do not write back into PGPKeyIdentity.revocationCert, and they do not introduce an implicit persisted selective-revocation history alongside the key-level revocation slot.

Certificate-signature workflow note: Generated User ID certification signatures are saved in the protected contacts domain only when the user explicitly runs Certify This Contact and the generated signature verifies against the selected contact key and exact User ID selector. Saved certification artifacts are stored as canonical binary OpenPGP signature bytes with validation metadata and are armored only for explicit export/share. Raw text/file verify/import preview remains non-mutating until the user chooses Save Signature. Certification persistence does not insert signatures into a stored contact certificate, change per-key manual fingerprint verification state, or introduce trust / web-of-trust policy semantics.

Profile-specific behavior:

  • Generation: Profile A → CipherSuite::Cv25519 + Profile::RFC4880. Profile B → CipherSuite::Cv448 + Profile::RFC9580.
  • Export: Profile A → Iterated+Salted S2K. Profile B → Argon2id S2K.
  • Encryption format: Determined by recipient key version, not sender profile. See TDD Section 1.4.

3. Secure Enclave Wrapping Scheme

The Secure Enclave supports only P-256. Private keys (Ed25519, X25519, Ed448, or X448) are protected via an indirect wrapping scheme. The wrapping scheme is identical for all key algorithms — the SE wraps raw private key bytes regardless of algorithm.

Future Apple Secure Enclave Custody note: This section describes the current shipped wrapping model, where CypherAir unwraps a complete OpenPGP secret certificate into application memory for signing, decryption, export, and key mutation. The proposed Apple Secure Enclave Custody mode is separate future private-key custody, not a third OpenPGP algorithm profile. In that mode, separate P-256 signing and key-agreement private-key operations are performed by Secure Enclave and long-term private scalars are never exported to CypherAir. Do not conflate that future mode with this wrapping scheme. Current security planning lives in APPLE_SECURE_ENCLAVE_CUSTODY_SECURITY_REQUIREMENTS; the POC security notes are archived as historical context in APPLE_SECURE_ENCLAVE_CUSTODY_SECURITY.

Rust external signer proof note: The Phase 2A Rust proof keeps external P-256 signing behind an internal test-backed adapter only. The adapter sees the public key, requested hash algorithm, and digest, and accepts only fixed-shape ECDSA r/s output that verifies against that public key and digest. Phase 2C negative tests also reject key-agreement-role keys, wrong digest signatures, wrong public-key signatures, malformed responses, and external failures. It does not store Apple handle locators, use response files, or fall back to secret-certificate material when the external signer fails. The Phase 4A public certificate callback boundary carries only typed cancellation and sanitized failure categories, not free-form error text, fingerprints, Keychain locators, digests, signatures, or temporary capability paths.

Rust external ECDH proof note: The Phase 2B Rust proof keeps external P-256 key agreement behind an internal test-backed adapter only. The adapter sends only public key-agreement material to the external operation and accepts only a fixed-shape raw 32-byte shared secret before handing it to Sequoia for OpenPGP ECDH KDF, AES Key Wrap unwrap, session-key validation, and payload authentication. Phase 2C negative tests also reject signing-role keys, unsupported key/ciphertext shapes, wrong public-key bindings, shape-valid but wrong shared secrets, and tampered SEIPDv1/MDC or SEIPDv2/AEAD payloads after session-key acceptance. It does not store Apple handle locators, use response files, add a Security-layer handle store, expose a UniFFI API, or fall back to secret-certificate material when the external ECDH operation fails. Diagnostics must not include shared secrets, session keys, KEKs, plaintext, fingerprints, or temporary capability paths.

Secure Enclave custody handle-store note: Phase 3A/3B/3C introduce a Security-owned, hidden handle lifecycle, cleanup, recovery-classification, and guarded device-evidence boundary for future custody. It creates two separate permanent Secure Enclave P-256 SecKey private-key rows using kSecAttrTokenIDSecureEnclave, kSecAttrKeyTypeECSECPrimeRandom, 256 bits, and kSecAttrAccessControl with WhenUnlockedThisDeviceOnly + .privateKeyUsage + .biometryAny. Creation, load, inventory, and delete paths use the data-protection Keychain domain consistently. Creation does not set Keychain usage flags such as kSecAttrCanSign or kSecAttrCanDerive; SEP-backed key creation rejects false usage-flag values, and Apple's Secure Enclave creation example relies on access control plus key attributes instead. Role trust comes from separate role-tagged handles, public-key binding, and future router policy. The custody store must not use .devicePasscode, .or, or the current AuthenticationMode.createAccessControl() helper. Application tags use a random local handle-set id plus role and must not include fingerprints. Load and inspect paths fail closed unless the stored role and uncompressed X9.63 public-key binding shape match the expected values. Reset All Local Data inventories and deletes only app-owned custody kSecClassKey rows, including malformed app-owned tags identified by raw prefix bytes, and treats list/delete/remaining-row failures as sanitized cleanup or recovery failures. Logs, errors, UI, ProtectedData, and Rust must not expose raw application tags, handle-set identifiers, fingerprints, public-key bytes, or Keychain locators. Guarded device tests now validate real hardware creation/load/delete, biometric signing/ECDH private operations, unauthorized interaction failure, missing/partial/wrong-public handle states, cleanup, and sanitized traces. These handles are not exposed to UI, Rust/UniFFI, or ProtectedData metadata in Phase 3.

Hidden Secure Enclave custody generation note: Phase 4A adds an internal hidden/test-only generation path. It creates the two role-tagged Secure Enclave P-256 handles, loads the signing handle for digest signing, and asks Rust to build a public-only v4/v6 OpenPGP certificate plus key-level revocation artifact. Rust receives only public X9.63 points and SHA-256 digest-signing callbacks; it verifies each returned fixed-width ECDSA r/s response against the signing public key and digest. The resulting PGPKeyIdentity persists P-256 configuration and .appleSecureEnclavePrivateOperations custody in key-metadata schema v2, but still does not persist Apple application tags, handle-set identifiers, access-control policy, private material, digests, or signatures. Current software-key generation and UI exposure remain unchanged, and production capability resolution still reports Secure Enclave generation as policy-unavailable unless the internal hidden/test policy is explicitly used.

Phase 4B adds hidden recovery classification for generated custody identities. Rust/UniFFI can inspect a stored public-only P-256 Secure Enclave-shaped certificate and return only the public signing/key-agreement X9.63 bindings plus fingerprint/version metadata. Swift compares those public bindings with Security-owned inventory through SecureEnclaveCustodyHandleStore and stores only an in-memory sanitized report. Missing, partial, ambiguous, wrong-public, public-certificate mismatch, metadata mismatch, missing-revocation, and inventory/list failures are classified with shared categories. The report must not contain Apple application tags, handle-set identifiers, Keychain locators, fingerprints, public key bytes, digests, signatures, plaintext, or temp paths. Startup/load classification does not delete orphan handles; Reset All Local Data remains the cleanup path for app-owned custody rows.

Phase 4C closes public and revocation export coverage for hidden custody identities. Public-key export uses only stored public certificate bytes, and revocation export uses only the stored key-level revocation signature packet. Missing Secure Enclave custody revocation artifacts fail closed with a sanitized unavailable category; export does not try to re-sign, unwrap software bundles, or backfill from private material. Secure Enclave custody private-key backup/export is explicitly unsupported and must not touch the legacy se-key / salt / sealed-key bundle path.

Private-operation router foundation note: Phase 5A adds only hidden/internal routing contracts. PGPKeyCapabilityResolver gates Secure Enclave generation, signing-class operations, and key-agreement operations independently; production policy still blocks Secure Enclave private operations. PrivateKeyOperationRouter must consult the resolver before Security handle lookup, must return software routes without unwrapping secret certificates, and may return a Secure Enclave signer route only after the stored public certificate, fingerprint, key version, role, and public-key bindings agree with the Security-owned handle pair. Secure Enclave decrypt/key-agreement remains a blocked not-implemented route for this phase. Shared failure mapping must expose only stable categories and must not log or return fingerprints, handle tags, public binding bytes, Keychain locators, plaintext, private material, session keys, or temporary capability paths.

Secure Enclave cleartext signing pilot note: Phase 5B wires only SigningService.signCleartext through the router. Software-custody routes still unwrap a complete secret certificate only for the existing signing operation and zeroize it afterward. Secure Enclave signer routes pass the stored public certificate, expected signing-key fingerprint, and loaded signing handle through the Rust/UniFFI external P-256 signer API; Rust selects a matching policy-valid P-256 signing key and never extracts a secret keypair. Production policy still blocks Secure Enclave custody, blocked routes map to sanitized unavailable categories, and there is no software fallback for a Secure Enclave signer route. Sign-plus-encrypt, password-message signing, detached file signing, certification, revocation, expiry/binding refresh, and decrypt remain outside this pilot.

Secure Enclave text sign-plus-encrypt pilot note: Phase 5C wires only optional signing for EncryptionService.encryptText through the same router. Recipient resolution, encrypt-to-self, profile/message-format selection, and unsigned text encryption remain the existing encryption-service behavior. Software-custody signer routes unwrap and zeroize the complete secret certificate exactly as before. Secure Enclave signer routes call the Rust/UniFFI external P-256 signer encrypt API with public signing certificate bytes, the inspected signing-key fingerprint, and a loaded Security-owned signing handle; the callback result preserves typed cancellation and unavailable categories. Production policy still blocks Secure Enclave custody, blocked routes fail without software fallback, and password-message signing before Phase 5D, file streaming, detached signing, certification, revocation, expiry/binding refresh, and decrypt remain outside this pilot. Failure mapping must stay sanitized and must not include fingerprints, handle tags, public binding bytes, Keychain locators, plaintext, private material, session keys, or temporary capability paths.

Secure Enclave password-message signing pilot note: Phase 5D wires only optional signing for PasswordMessageService.encryptText and PasswordMessageService.encryptBinary through the same private-operation router. Password encryption/decryption, SKESK handling, password-message format selection, and verification remain owned by the password-message path. Software-custody signer routes unwrap and zeroize the complete secret certificate exactly as before. Secure Enclave signer routes call the Rust/UniFFI external P-256 password encrypt APIs with public signing certificate bytes, the inspected signing-key fingerprint, and a loaded Security-owned signing handle; the callback result preserves typed cancellation and unavailable categories. Production policy still blocks Secure Enclave custody, blocked routes fail without software fallback, and certification, revocation, expiry/binding refresh, decrypt, and streaming file workflows remain outside this pilot. Failure mapping must stay sanitized and must not include fingerprints, handle tags, public binding bytes, Keychain locators, plaintext, private material, session keys, or temporary capability paths.

Secure Enclave detached file signing pilot note: Phase 5E wires only SigningService.signDetachedStreaming through the same private-operation router. File selection, detached-signature output handling, streaming progress, and detached verification remain owned by the signing path. Software-custody signer routes unwrap and zeroize the complete secret certificate exactly as before. Secure Enclave signer routes call the Rust/UniFFI external P-256 detached file signing API with public signing certificate bytes, the inspected signing-key fingerprint, a loaded Security-owned signing handle, and the existing progress bridge; the callback result preserves typed cancellation and unavailable categories. Production policy still blocks Secure Enclave custody, blocked routes fail without software fallback, and certification, revocation, expiry/binding refresh, decrypt, and encryption workflows remain outside this pilot. Failure mapping must stay sanitized and must not include fingerprints, handle tags, public binding bytes, Keychain locators, plaintext, private material, session keys, or temporary capability paths.

Secure Enclave streaming file encrypt-plus-sign pilot note: Phase 5F wires only optional signing for EncryptionService.encryptFileStreaming through the same private-operation router. Recipient lookup, disk-space checks, encrypt-to-self resolution, temporary artifact creation/protection/cleanup, streaming progress, binary file output, and SEIPDv1/SEIPDv2 selection remain owned by the streaming file-encryption path. Software-custody signer routes unwrap and zeroize the complete secret certificate exactly as before. Secure Enclave signer routes call the Rust/UniFFI external P-256 file-encryption API with public signing certificate bytes, the inspected signing-key fingerprint, a loaded Security-owned signing handle, optional encrypt-to-self material, and the existing progress bridge; the callback result preserves typed cancellation and unavailable categories. Production policy still blocks Secure Enclave custody, blocked routes fail without software fallback, and certification, revocation, expiry/binding refresh, and decrypt remain outside this pilot. Failure mapping must stay sanitized and must not include fingerprints, handle tags, public binding bytes, Keychain locators, plaintext, private material, session keys, or temporary capability paths.

Secure Enclave expiry mutation signer-route note: Phase 5G wires only KeyMutationService.modifyExpiry through the same private-operation router. Software-custody routes keep the existing unwrap, Rust modifyExpiry, rewrap/promotion, pending-bundle recovery, catalog update, recovery journal, and zeroization behavior. Secure Enclave signer routes call the Rust/UniFFI public-only expiry mutation API with stored public certificate bytes, the inspected signing-key fingerprint, and a loaded Security-owned signing handle; the callback result preserves typed cancellation and unavailable categories. The Secure Enclave path updates only public metadata/catalog state and must not create pending software bundles, modify-expiry recovery journal entries, or software fallback attempts. Production policy still blocks Secure Enclave custody, standalone refreshBinding remains explicitly not implemented for Secure Enclave custody, and certification, revocation, and decrypt remain outside this pilot. The Phase 5G follow-up also refreshes explicit subkey validity bindings for Secure Enclave-style ECDH subkeys; relying only on primary/User ID expiry bindings can leave transport subkeys expired after a public-only mutation. Modify-expiry can extend or remove expiry after a local key is already expired, using expiry-specific primary signer selection that does not relax ordinary signing workflow liveness checks. Secure Enclave expiry writeback must merge against the current catalog identity and fail if the identity was deleted, so late public-only signing results cannot overwrite newer local flags or recreate metadata. Failure mapping must stay sanitized and must not include fingerprints, handle tags, public binding bytes, Keychain locators, plaintext, private material, session keys, or temporary capability paths.

Secure Enclave selective revocation signer-route note: Phase 5H wires only subkey and User ID selective revocation export through the same private-operation router. Selector validation remains public-only and must finish before any unwrap, handle lookup, authentication, or external-signing callback. Software-custody routes keep the existing unwrap/zeroize path and Sequoia revocation-builder hash defaults. Secure Enclave signer routes call public-only Rust/UniFFI external P-256 revocation APIs with stored public certificate bytes, the inspected primary signing fingerprint, and a loaded Security-owned signing handle; those external builders pass SHA-256 explicitly because the callback contract signs only SHA-256 digests. Key-level stored revocation-artifact export remains a public artifact export and missing Secure Enclave key-level artifacts still fail closed instead of being lazily regenerated. Production policy still blocks Secure Enclave custody, blocked routes fail without software fallback, and decrypt plus key-level revocation-artifact generation remain outside this pilot. Failure mapping must stay sanitized and must not include fingerprints, handle tags, public binding bytes, Keychain locators, plaintext, private material, session keys, or temporary capability paths.

Secure Enclave contact certification signer-route note: Phase 5I wires only User ID contact certification generation through the same private-operation router. CertificateSignatureService keeps target User ID selector validation, verification, generated-artifact validation, and signer identity resolution at the service boundary before private signing dispatch. Software-custody routes keep the existing unwrap/zeroize path and Sequoia certification hash defaults. Secure Enclave signer routes call a public-only Rust/UniFFI external P-256 User ID certification API with stored public certificate bytes, the inspected primary signing fingerprint, and a loaded Security-owned signing handle; the external builder passes SHA-256 explicitly because the callback contract signs only SHA-256 digests. Generated contact certification remains an exported or saved signature artifact only after the existing validation path accepts it; the signer route does not mutate Contacts, catalog metadata, keychain rows, trust state, or stored contact certificates. Production policy still blocks Secure Enclave custody, blocked routes fail without software fallback, and direct-key certification, decrypt, and key-level revocation-artifact generation remain outside this pilot. Failure mapping must stay sanitized and must not include fingerprints, handle tags, public binding bytes, Keychain locators, plaintext, private material, session keys, or temporary capability paths.

Secure Enclave Phase 5 closure audit note: Phase 5J adds audit coverage and documentation only; it does not add a Rust/UniFFI operation, Security handle semantics, UI surface, product copy, or production availability switch. The Phase 5 support matrix is closed for signing-class hidden/test consumers: cleartext signing, text/file/password sign-plus-encrypt, detached file signing, modify-expiry, selective subkey/User ID revocation export, and User ID contact certification all dispatch through router-owned helpers. Workflow services must not grow local custody switches or direct external P-256 signer runtime calls, and Secure Enclave signer routes must continue to fail without software fallback. Standalone refreshBinding, decrypt/ECDH, direct-key certification, key-level revocation-artifact generation, private export/backup, and product exposure remain outside Phase 5. Production policy still blocks Secure Enclave custody.

ProtectedData Device-Binding Note

ProtectedData uses a separate app-data root-secret model and must not be conflated with private-key bundle wrapping. The current ProtectedData v2 model keeps the Keychain / SecAccessControl / authenticated LAContext gate, but stores the root-secret Keychain payload as a Secure Enclave device-bound envelope instead of raw root-secret bytes.

The device-binding key is a ProtectedData-only P-256 Secure Enclave key with kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly and .privateKeyUsage. It must not include .userPresence, .biometryAny, or .devicePasscode, because the user authentication prompt remains the existing app-session Keychain gate. The SE layer is a silent second factor that makes copied Keychain payloads and ProtectedData files unusable away from the original device. If the SE device-binding key is missing or unusable, ProtectedData must fail closed and require framework recovery/reset; there is no production fallback that opens v2 ProtectedData without the SE factor.

The v2 root-secret envelope is a binary-plist CAPDSEV2 payload with algorithmID = p256-ecdh-hkdf-sha256-aes-gcm-v1. It uses a normal software-ephemeral P-256 ECDH exchange with the persistent ProtectedData SE public key; it must not reuse the existing private-key self-ECDH wrapping scheme as its security design. Its HKDF sharedInfo and AES-GCM AAD bind the AAD version plus hashes of both persistent SE and ephemeral public keys. After v2 migration succeeds, registry state plus a ThisDeviceOnly Keychain format-floor marker must make later v1 raw root-secret payloads fail closed as downgrade/corruption.

ProtectedData domain payloads must open only after the app privacy gate has produced an authenticated LAContext or an already-authorized ProtectedData session. The post-unlock domain coordinator may reuse that context for registered committed domains, but it must skip pending-mutation, missing context, and no-domain states without fetching the root secret or starting a second interactive prompt.

Wrapping (on key generation or import)

  1. Generate SecureEnclave.P256.KeyAgreement.PrivateKey() with access control flags matching the current auth mode.
  2. Self-ECDH: compute shared secret between SE private key and its own public key. This computation happens inside the SE hardware.
  3. Derive AES-256 key: HKDF<SHA256>.deriveKey(inputKeyMaterial: sharedSecret, salt: randomSalt, info: infoString, outputByteCount: 32) where infoString = "CypherAir-SE-Wrap-v1:" + hexFingerprint.
  4. Seal: AES.GCM.seal(privateKeyBytes, using: symmetricKey).
  5. Store three Keychain items: SE key dataRepresentation, random salt, AES-GCM sealed box. Confirm all three writes succeed.
  6. Only after successful storage: zeroize the raw private key bytes and symmetric key from memory.

HKDF info string: The info parameter includes a version prefix (v1) and the key's hex fingerprint to provide domain separation across different keys and future wrapping scheme versions. This exact string must be constructed identically in SecureEnclaveManager.swift. Any mismatch will produce a different derived key and make existing wrapped keys permanently inaccessible.

Ordering rationale (steps 5–6): Storage is performed before zeroization. If storage fails or the process crashes before step 5 completes, the raw key bytes are still in memory and the operation can be retried. If zeroization happened first and storage then failed, the key would be permanently lost.

Unwrapping (on decrypt or sign)

  1. Retrieve SE key blob, salt, and sealed box from Keychain.
  2. Reconstruct SE key from dataRepresentation — this triggers device authentication (Face ID / Touch ID, with or without passcode fallback depending on auth mode).
  3. Re-derive symmetric key: self-ECDH (inside SE) + HKDF with stored salt and same info string ("CypherAir-SE-Wrap-v1:" + hexFingerprint).
  4. Open sealed box → raw private key bytes in application memory.
  5. Perform the PGP operation.
  6. Zeroize the private key bytes and symmetric key immediately.

Security Properties

  • Keychain data extraction without the SE hardware yields an encrypted blob that cannot be decrypted.
  • The SE key's dataRepresentation is bound to the SoC UID (fused at manufacturing, never exposed to software).
  • The raw private key exists in application memory briefly during use. This is an inherent tradeoff of the P-256-only SE constraint.
  • SE ECDH latency: ~2–5ms. Imperceptible to users.

4. Authentication Modes

Standard Mode (default)

let accessControl = SecAccessControlCreateWithFlags(
    kCFAllocatorDefault,
    kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
    [.privateKeyUsage, .biometryAny, .or, .devicePasscode],
    &error
)

Face ID / Touch ID with device passcode fallback. Equivalent to LAPolicy.deviceOwnerAuthentication. Suitable for most users.

High Security Mode

let accessControl = SecAccessControlCreateWithFlags(
    kCFAllocatorDefault,
    kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
    [.privateKeyUsage, .biometryAny],
    &error
)

Face ID / Touch ID only. No passcode fallback. If biometrics are unavailable (sensor damaged, face obscured, biometry locked out after 5 failures), all private-key operations (decrypt, sign, export) are blocked until biometric auth is restored.

High Security protects private-key operations by requiring biometric authorization and denying device-passcode fallback. The current policy uses .biometryAny, so it does not invalidate keys merely because biometric enrollment changes.

Mode Switching Procedure

When the user changes mode in Settings:

  1. Display warning. If switching to High Security and no backup exists, show a stronger warning requiring explicit acknowledgment.
  2. Record the rewrap target and phase in the post-unlock private-key-control.recoveryJournal.
  3. Authenticate under the current mode (proves the user has authority to change).
  4. For each private key: a. Unwrap using the current SE key. b. Generate a new SE key with the new access control flags. c. Re-wrap the private key with the new SE key. d. Store the new Keychain items under temporary key names (e.g., com.cypherair.v1.pending-se-key.<fingerprint>). e. Zeroize the raw key bytes from memory.
  5. Verify all new items are successfully stored.
  6. Delete the old Keychain items (original com.cypherair.v1.se-key.<fingerprint> etc.).
  7. Rename the temporary items to their permanent key names.
  8. Persist the new mode to private-key-control.settings.authMode.
  9. Clear the private-key-control.recoveryJournal rewrap entry.

Atomicity: Old Keychain items are kept intact until ALL new items are confirmed stored (step 5). If any step fails before step 6, the original keys are unaffected — delete the temporary items and report the error.

Crash recovery: After app-session authentication opens private-key-control, check the rewrap recovery journal. If an entry is present:

  • If the permanent bundle is complete and temporary items exist, the permanent bundle is treated as authoritative. Delete the temporary items and keep the original mode.
  • If the permanent bundle is partial but the temporary bundle is complete, the temporary bundle is treated as authoritative. Delete the residual permanent items, then promote the temporary bundle to permanent names.
  • If the permanent bundle is missing and the temporary bundle is complete, promote the temporary bundle to permanent names.
  • If neither namespace contains a complete three-item bundle, recovery is unrecoverable. Clear the journal entry, surface a generic post-unlock warning, and require the user to restore from backup if private-key operations fail.
  • If deletion or promotion fails for a retryable reason (for example, transient Keychain write/delete failure), preserve the recovery journal so the app retries recovery after the next successful unlock.
  • Recovery diagnostics are surfaced through the app's existing post-unlock warning path and must remain generic — never include fingerprints or other key identifiers.
  • Persist the new auth mode only after a full successful promotion of complete pending bundles. Cleaning stale pending items alone must not change auth mode.
  • This ensures the app prefers a complete bundle over a partial one and avoids silently finalizing an inconsistent state.

Legacy UserDefaults keys such as com.cypherair.internal.rewrapInProgress and com.cypherair.preference.authMode are migration sources only. Verified migration moves them into private-key-control and removes the legacy keys.

LAPolicy Selection

Mode LAPolicy Fallback Button
Standard .deviceOwnerAuthentication Passcode shown
High Security .deviceOwnerAuthenticationWithBiometrics context.localizedFallbackTitle = "" (hidden)

5. Protected App Data

Protected app data is a separate security domain for CypherAir-owned local state outside private-key material. It must not be conflated with the Secure Enclave wrapping path that protects OpenPGP secret key bytes.

Protected app-data scope and per-surface classification live in PERSISTED_STATE_INVENTORY. This security model records the rules and invariants; the inventory records the row-level domains, paths, current support cutoffs, and export/temp exceptions.

Security invariants for protected app data:

  • Protected domains open only after app privacy authentication and the shared ProtectedData authorization path.
  • ProtectedData is separate from the private-key material domain; permanent and pending SE-wrapped private-key bundle rows remain under the Keychain / Secure Enclave private-key-material boundary.
  • appSessionAuthenticationPolicy remains the documented early-readable boot-authentication exception.
  • Legacy flat Contacts files under Documents/contacts are outside the supported app-state model. CypherAir no longer reads, migrates, quarantines, or reset-cleans them.
  • Contacts production state stays inside the protected contacts domain. Certification-signature export/share is an explicit artifact export boundary, not a Contacts backup, package, or social-graph export.
  • Manual Contacts verification is a local fingerprint-check assertion and is not OpenPGP certification. Saved certification artifacts stay under app custody until the user explicitly exports or shares a certification signature.
  • Contacts does not provide multi-contact package exchange or social-graph export. Any future complete Contacts backup or device migration must be mandatory encrypted.
  • Self-test, decrypted, streaming, export handoff, and tutorial artifacts keep the inventory's ephemeral-with-cleanup behavior; files exported to user-selected destinations are outside app custody after handoff.

UserDefaults is allowed only for documented boot, test, tutorial, and legacy cleanup exceptions. Personal or sensitive app data must not be newly introduced there; post-auth settings use protected-settings unless they are explicit boot-authentication exceptions.

Protected app-data authorization uses AppSessionAuthenticationPolicy, not private-key AuthenticationMode. AppSessionOrchestrator owns launch/resume privacy authentication and the grace window. When app authentication succeeds, it can hand the authenticated LAContext to ProtectedDataSessionCoordinator, which reads the shared app-data root secret through Keychain with kSecUseAuthenticationContext. That same authenticated handoff is reused by post-unlock domain openers so committed registered domains can open without a second Face ID / Touch ID prompt.

ProtectedOrdinarySettingsCoordinator owns ordinary-settings availability. It loads grace period, onboarding completion, color theme, encrypt-to-self, and guided tutorial completion from protected-settings schema v2 only after app privacy authentication and an unlocked protected-settings handoff. Existing schema v1 payloads are upgraded through an explicit compatibility path using legacy ordinary settings as a migration source; schema v2 payloads are strict, so missing or corrupt ordinary settings enter protected-settings recovery instead of resetting to defaults. If the setting snapshot is unavailable, the resume grace window fails closed to immediate authentication, startup/onboarding routing waits for a loaded snapshot, and encryption does not silently use the app-default encrypt-to-self value for real work.

KeyMetadataDomainStore owns key metadata availability. It stores key-metadata schema v2 payloads with PGPKeyIdentity records that explicitly include app-owned OpenPGP configuration identity and private-key custody kind; existing schema v1 payloads are upgraded after app privacy authentication by deriving current Profile A/B records as software-custody keys. The domain remains metadata-only: it must not store Apple Secure Enclave handle locators, access-control policy, salts, sealed boxes, secret certificate bytes, or any other private material. Corrupt, missing, or bootstrap-mismatched current committed metadata enters recovery instead of rebuilding from legacy Keychain rows.

Key operation failure categories are app-owned sanitized classifications for resolver, future router, Security, Rust/UniFFI, workflow-service, and UI mapping boundaries. Local-authentication categories are explicitly separate from payload-authentication failure. They must not contain plaintext, private-key material, shared secrets, session keys, KEKs, Keychain locators, stable fingerprints, temporary capability paths, or other secret-bearing values. They do not persist Secure Enclave handle state and do not replace payload-authentication hard-fail behavior.

The shared root secret is not stored as raw bytes in the current format. Keychain stores a v2 CAPDSEV2 envelope that must also unwrap through the ProtectedData-only Secure Enclave device-binding key described in Section 3. The raw root secret is used only to derive the wrapping root key and is immediately zeroized. Each protected domain has its own random domain master key, persisted only as a wrapped-DMK record under the derived wrapping root key. Unwrapped domain keys and decrypted payloads are session-local and must be cleared on relock.

ProtectedDataRegistry is the only authority for committed protected-domain membership and pending create/delete work. Cold start may read the registry and per-domain bootstrap metadata before app authentication, but it must not retrieve the root secret, unwrap any DMK, open domain payloads, or infer committed membership by directory enumeration. Invalid registry state enters framework recovery. Domain corruption enters the domain's recovery state; no protected domain may silently reset unreadable state to empty data.

Relock is fail-closed. ProtectedDataSessionCoordinator blocks new protected-domain access, fans out to all registered ProtectedDataRelockParticipants, zeroizes the wrapping root key, clears unwrapped DMKs, and returns to sessionLocked only if teardown succeeds. The ordinary-settings coordinator also clears its loaded snapshot on relock/content clear. Any relock participant failure latches runtime-only restartRequired, blocking further protected-domain access until process restart.

ProtectedData files live under the protected app-data storage root documented in PERSISTED_STATE_INVENTORY. Registry files, bootstrap metadata, scratch writes, wrapped-DMK files, and committed domain files use explicit file-protection verification where the platform supports it. Storage outside the app-owned container is not an allowed fallback for protected-domain files.

6. Guided Tutorial Sandbox Isolation

The guided tutorial is allowed to run real app services and real OpenPGP operations only inside an isolated tutorial dependency graph. It must not read or mutate the user's real keys, contacts, settings, files, exports, or private-key security assets.

Tutorial isolation boundaries:

  • TutorialSandboxContainer uses the fixed com.cypherair.tutorial.sandbox UserDefaults suite and a temporary contacts directory with verified complete file protection instead of the app's real preferences and real Contacts storage. The product flow owns a single active tutorial sandbox at a time; container creation and current tutorial cleanup clear the fixed suite and directory. Startup and Reset All Local Data also remove legacy orphaned com.cypherair.tutorial.<UUID> suites.
  • Tutorial key management, encryption, decryption, signing, certificate, QR, and self-test services are constructed against tutorial-local storage and the same Rust engine API shape used by the real app.
  • Tutorial private-key protection currently uses mock Secure Enclave and mock Keychain primitives behind a real AuthenticationManager instance, so auth-mode behavior is exercised without touching real Secure Enclave-wrapped private keys or real Keychain rows. This is temporary SR-FIX-18 debt: tutorial/UI-test mocks must remain visibly named Mock*, stay under Sources/Security/Mocks, and keep mock-owned errors instead of impersonating production KeychainError.
  • OutputInterceptionPolicy and page-level configuration must block or intercept real file import/export, clipboard writes, share-sheet export, URL handoff, app icon changes, onboarding management actions, and other real-workspace side effects.
  • Tutorial completion state is the only tutorial fact that persists across app restarts. Tutorial keys, contacts, messages, settings, and unfinished module progress are ephemeral and are cleaned up when the tutorial is reset or finished.

Changes to tutorial isolation, output interception, or tutorial security simulation must be reviewed with the same care as other auth and local-data boundaries. A tutorial regression must never weaken the app's zero-network, minimal-permission, no-secret-logging, or real-workspace isolation guarantees. The long-term direction is to replace tutorial mocks with tutorial-specific isolated Protected Data domains and real hardware-backed processing that never reads or mutates user security assets.

7. Argon2id Parameters (Profile B Only)

Used only for private key export (backup) and for importing/unlocking passphrase-protected private key files. Not used for routine message decryption or signing — those operations use the SE-unwrapped private key directly.

Not used by Profile A. Profile A uses Iterated+Salted S2K (mode 3).

Parameter Value RFC 9580 Encoding
Memory 512 MB (524,288 KiB) encoded_m = 19 (2^19 KiB)
Parallelism 4 lanes p = 4
Time Fixed at 3 passes (~3s target on contemporary hardware) t = 3

iOS Memory Safety Guard

Before Argon2id derivation when importing or unlocking a passphrase-protected private key file (this guard does NOT apply to routine message decryption):

  1. Parse the S2K specifier from the key file.
  2. Calculate required memory: 2^encoded_m KiB.
  3. Query os_proc_available_memory().
  4. If required > 75% of available memory: refuse with error message: "This key uses memory-intensive protection that exceeds this device's capacity."
  5. Return a user-facing refusal error before Argon2id derivation begins.

This prevents iOS Jetsam from killing the app. The 75% threshold provides a safety margin.

8. Memory Integrity Enforcement (MIE)

What It Protects

MIE is built into supported Apple hardware and software, including current A19/A19 Pro devices such as iPhone 17 and iPhone Air. It provides hardware-level defense against buffer overflows and use-after-free in all C/C++ code, including vendored OpenSSL. The system allocator assigns 4-bit tags to heap allocations. Every memory access is checked by hardware in real time. Tag mismatch = immediate process termination.

Enablement

Enhanced Security is enabled via Signing & Capabilities → Add Capability → Enhanced Security → enable Hardware Memory Tagging. When this capability is added, Xcode writes the required entitlement keys into CypherAir.entitlements:

  • com.apple.security.hardened-processtrue
  • com.apple.security.hardened-process.enhanced-security-version-string1
  • com.apple.security.hardened-process.hardened-heaptrue
  • com.apple.security.hardened-process.platform-restrictions-string2
  • com.apple.security.hardened-process.dyld-rotrue
  • com.apple.security.hardened-process.checked-allocationstrue (Hardware Memory Tagging)
  • com.apple.security.hardened-process.checked-allocations.enable-pure-datatrue
  • com.apple.security.hardened-process.checked-allocations.no-tagged-receivetrue

These entitlement keys must be committed to source control. Xcode reads the .entitlements file to determine which protections are enabled. Removing the keys disables the corresponding protections.

Additionally, verify ENABLE_ENHANCED_SECURITY = YES in both Debug and Release build settings in project.pbxproj.

Testing Workflow

  1. Xcode diagnostics: Enable Hardware Memory Tagging in Scheme → Run → Diagnostics. Run full test suite on supported A19/A19 Pro-or-newer hardware. Any tag mismatch surfaces as a crash with exact location.
  2. Production: Tag mismatches terminate the process immediately. This is the desired behavior — it converts silent corruption into a detectable, non-exploitable crash.

Impact on Vendored OpenSSL

The openssl-src crate compiles OpenSSL from C source. Any undiscovered buffer overflow or use-after-free in OpenSSL will cause an immediate crash under MIE. This is the desired behavior — it converts silent corruption into a detectable, non-exploitable crash. Test all Sequoia + OpenSSL code paths (AES-256, SHA-512, Ed25519, X25519, Ed448, X448, Argon2id) under Hardware Memory Tagging diagnostics.

Compatibility

Device MIE Behavior
Supported A19/A19 Pro-or-newer devices, including current iPhone 17 and iPhone Air models Full hardware memory tagging active
Older unsupported devices Software-only typed allocator. No hardware tagging.

The Enhanced Security capability is additive. It never breaks compatibility with older devices.

9. Known Limitations

9.1 Passphrase String Cannot Be Reliably Zeroized

Scope: Affects passphrase-based flows that originate in Swift String, specifically private key import/export and password-message encrypt/decrypt operations. It does not affect routine recipient-key decryption or signing, which use SE-unwrapped key bytes (Data) that are properly zeroized.

Issue: Swift String is a value type with copy-on-write semantics. There is no supported API to overwrite a String's internal buffer in place. When the user enters a passphrase for key export (S2K protection), key import (S2K unlock), or password-message encrypt/decrypt (SKESK), the passphrase exists as a String in memory until ARC deallocates it. The exact lifetime depends on the Swift runtime and is not deterministic.

Why this is not fixed:

  1. SwiftUI constraint: SecureField — the only system-provided secure text input — binds to String. There is no Data-backed alternative.
  2. FFI boundary: UniFFI transfers String by copying through RustBuffer. Even if the Swift side could zeroize its copy, the Rust side receives an independent copy (which Sequoia consumes and the Rust zeroize crate handles on its side).
  3. Platform-wide pattern: No shipping iOS app (including Apple's own Keychain prompts) can zeroize String passphrases. This is an accepted platform limitation.

Mitigations:

  • Short lifetime: The passphrase String is only alive for the duration of the active import/export or password-message call. It is not stored in any persistent state, UserDefaults, or Keychain.
  • Rust-side zeroize: The zeroize crate ensures the Rust copy of the passphrase is overwritten after use.
  • iOS memory protections: ASLR, sandboxing, and MIE (on supported A19/A19 Pro-or-newer devices) make memory scanning attacks significantly harder.
  • Immediate Rust conversion: Password-message APIs convert the Swift String into Sequoia Password at the FFI boundary so the Rust-side representation is encrypted in memory and only decrypted on demand.

Rejected alternatives:

  • UnsafeMutableBufferPointer<UInt8> with manual zeroing: Would require forking SecureField or building a custom UIKit text field, bypassing system-provided secure input. The security loss from a custom input field (no system-level screen recording protection, no secure text entry mode) would outweigh the benefit of zeroizable memory.
  • Data-based passphrase: UniFFI does not support DataString conversion at the FFI boundary without an intermediate String allocation, negating the benefit.

10. AI Coding Red Lines

The following files and functions are security-critical. Claude Code must stop and describe proposed changes before editing them. Do not make autonomous modifications.

Files Requiring Human Review

File Reason
Sources/Security/SecureEnclaveManager.swift SE wrapping/unwrapping logic. Error = keys lost or insecure.
Sources/Security/SecureEnclaveCustody* Future Secure Enclave custody handle lifecycle, access-control policy, role/public-key binding, and sanitized failure mapping.
Sources/Security/KeychainManager.swift Access control flags. Wrong flags = wrong auth behavior.
Sources/Security/AuthenticationManager.swift Mode switching re-wrap. Error = keys permanently lost.
Sources/Security/ProtectedData/ App-data root-secret authorization, SE device-binding envelope, domain master-key wrapping, reset semantics. Error = protected app data lost or opened under the wrong gate.
Sources/Security/MemoryZeroingUtility.swift Removing a zeroize call = key material leaks.
Sources/Extensions/Data+Zeroing.swift Contains @_optimize(none) zeroing barrier. Weakening = compiler may eliminate all memory zeroing app-wide.
Sources/Services/DecryptionService.swift Phase 1/Phase 2 auth boundary. Skipping Phase 2 auth check = biometric bypass.
Sources/Services/QRService.swift Parses untrusted external input (cypherair:// URLs). Bugs here may trigger Sequoia parser on malicious data.
pgp-mobile/src/decrypt.rs AEAD hard-fail enforcement. Weakening = plaintext leaks.
pgp-mobile/src/streaming.rs Streaming file encrypt/decrypt with buffer zeroing. Error in temp file handling = plaintext leaks to disk.
pgp-mobile/src/error.rs PgpError enum. Must stay 1:1 with Swift.
Sources/Services/DiskSpaceChecker.swift Disk space validation threshold. Wrong threshold = Jetsam termination during file operations.
CypherAir.entitlements MIE, Enhanced Security entitlements.
CypherAir-Info.plist Only NSFaceIDUsageDescription permitted. No other usage descriptions.

Functions Requiring Human Review

  • Any function that calls SecAccessControlCreateWithFlags
  • Any function that calls SecKeyCreateRandomKey, SecItemCopyMatching, or SecItemDelete for kSecClassKey
  • Any function that calls SecureEnclave.P256.KeyAgreement.PrivateKey()
  • Any function that calls AES.GCM.seal() or AES.GCM.open() on key material
  • Any function that calls HKDF<SHA256>.deriveKey()
  • Any function that writes to or deletes from Keychain
  • The os_proc_available_memory() guard in Argon2id handling
  • Any Rust function marked pub in pgp-mobile/src/lib.rs
  • URL parsing logic in QRService that handles cypherair:// scheme input
  • Profile/CipherSuite selection in key generation

Testing Requirements for Security Changes

Every change to a file listed above must include:

  1. Positive test: The operation succeeds with correct inputs and proper authentication.
  2. Negative test: The operation fails gracefully with wrong inputs (wrong key, wrong passphrase, tampered data, unavailable biometrics).
  3. Round-trip test: For crypto operations — encrypt then decrypt, sign then verify, wrap then unwrap.
  4. No-leak test: For memory-sensitive changes — verify that sensitive data is zeroized after use (inspect with Xcode Memory Graph Debugger or Instruments).

There aren't any published security advisories