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.
All cryptographic operations use Sequoia PGP 2.3.0. Two profiles with different algorithm suites:
| 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 |
| 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).
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.
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 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.
- Generate
SecureEnclave.P256.KeyAgreement.PrivateKey()with access control flags matching the current auth mode. - Self-ECDH: compute shared secret between SE private key and its own public key. This computation happens inside the SE hardware.
- Derive AES-256 key:
HKDF<SHA256>.deriveKey(inputKeyMaterial: sharedSecret, salt: randomSalt, info: infoString, outputByteCount: 32)whereinfoString = "CypherAir-SE-Wrap-v1:" + hexFingerprint. - Seal:
AES.GCM.seal(privateKeyBytes, using: symmetricKey). - Store three Keychain items: SE key
dataRepresentation, random salt, AES-GCM sealed box. Confirm all three writes succeed. - 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.
- Retrieve SE key blob, salt, and sealed box from Keychain.
- Reconstruct SE key from
dataRepresentation— this triggers device authentication (Face ID / Touch ID, with or without passcode fallback depending on auth mode). - Re-derive symmetric key: self-ECDH (inside SE) + HKDF with stored salt and same info string (
"CypherAir-SE-Wrap-v1:" + hexFingerprint). - Open sealed box → raw private key bytes in application memory.
- Perform the PGP operation.
- Zeroize the private key bytes and symmetric key immediately.
- Keychain data extraction without the SE hardware yields an encrypted blob that cannot be decrypted.
- The SE key's
dataRepresentationis 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.
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.
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.
When the user changes mode in Settings:
- Display warning. If switching to High Security and no backup exists, show a stronger warning requiring explicit acknowledgment.
- Record the rewrap target and phase in the post-unlock
private-key-control.recoveryJournal. - Authenticate under the current mode (proves the user has authority to change).
- 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. - Verify all new items are successfully stored.
- Delete the old Keychain items (original
com.cypherair.v1.se-key.<fingerprint>etc.). - Rename the temporary items to their permanent key names.
- Persist the new mode to
private-key-control.settings.authMode. - Clear the
private-key-control.recoveryJournalrewrap 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.
| Mode | LAPolicy | Fallback Button |
|---|---|---|
| Standard | .deviceOwnerAuthentication |
Passcode shown |
| High Security | .deviceOwnerAuthenticationWithBiometrics |
context.localizedFallbackTitle = "" (hidden) |
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.
appSessionAuthenticationPolicyremains the documented early-readable boot-authentication exception.- Legacy flat Contacts files under
Documents/contactsare outside the supported app-state model. CypherAir no longer reads, migrates, quarantines, or reset-cleans them. - Contacts production state stays inside the protected
contactsdomain. 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.
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:
TutorialSandboxContaineruses the fixedcom.cypherair.tutorial.sandboxUserDefaultssuite 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 orphanedcom.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
AuthenticationManagerinstance, 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 namedMock*, stay underSources/Security/Mocks, and keep mock-owned errors instead of impersonating productionKeychainError. OutputInterceptionPolicyand 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.
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 |
Before Argon2id derivation when importing or unlocking a passphrase-protected private key file (this guard does NOT apply to routine message decryption):
- Parse the S2K specifier from the key file.
- Calculate required memory:
2^encoded_mKiB. - Query
os_proc_available_memory(). - If required > 75% of available memory: refuse with error message: "This key uses memory-intensive protection that exceeds this device's capacity."
- 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.
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.
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-process→truecom.apple.security.hardened-process.enhanced-security-version-string→1com.apple.security.hardened-process.hardened-heap→truecom.apple.security.hardened-process.platform-restrictions-string→2com.apple.security.hardened-process.dyld-ro→truecom.apple.security.hardened-process.checked-allocations→true(Hardware Memory Tagging)com.apple.security.hardened-process.checked-allocations.enable-pure-data→truecom.apple.security.hardened-process.checked-allocations.no-tagged-receive→true
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.
- 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.
- Production: Tag mismatches terminate the process immediately. This is the desired behavior — it converts silent corruption into a detectable, non-exploitable crash.
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.
| 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.
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:
- SwiftUI constraint:
SecureField— the only system-provided secure text input — binds toString. There is noData-backed alternative. - FFI boundary: UniFFI transfers
Stringby copying throughRustBuffer. Even if the Swift side could zeroize its copy, the Rust side receives an independent copy (which Sequoia consumes and the Rustzeroizecrate handles on its side). - Platform-wide pattern: No shipping iOS app (including Apple's own Keychain prompts) can zeroize
Stringpassphrases. This is an accepted platform limitation.
Mitigations:
- Short lifetime: The passphrase
Stringis 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
zeroizecrate 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
Stringinto SequoiaPasswordat 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 forkingSecureFieldor 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 supportData↔Stringconversion at the FFI boundary without an intermediateStringallocation, negating the benefit.
The following files and functions are security-critical. Claude Code must stop and describe proposed changes before editing them. Do not make autonomous modifications.
| 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. |
- Any function that calls
SecAccessControlCreateWithFlags - Any function that calls
SecKeyCreateRandomKey,SecItemCopyMatching, orSecItemDeleteforkSecClassKey - Any function that calls
SecureEnclave.P256.KeyAgreement.PrivateKey() - Any function that calls
AES.GCM.seal()orAES.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
pubinpgp-mobile/src/lib.rs - URL parsing logic in
QRServicethat handlescypherair://scheme input - Profile/CipherSuite selection in key generation
Every change to a file listed above must include:
- Positive test: The operation succeeds with correct inputs and proper authentication.
- Negative test: The operation fails gracefully with wrong inputs (wrong key, wrong passphrase, tampered data, unavailable biometrics).
- Round-trip test: For crypto operations — encrypt then decrypt, sign then verify, wrap then unwrap.
- No-leak test: For memory-sensitive changes — verify that sensitive data is zeroized after use (inspect with Xcode Memory Graph Debugger or Instruments).