PassQ Security Analysis
1. Server Zero-Knowledge Analysis
If a passq server handed to an attacker — full disk, full database, ability to run the binary in their lab — what do they actually have, and what can they do with it?
1.1 What the server stores
| Table / object | Contents | Secret? |
|---|---|---|
users.master_kem_pk |
ML-KEM-768 public key (1184 B) | no |
users.master_sig_pk |
ML-DSA-65 public key (1952 B) | no |
users.recovery_sig_pk |
ML-DSA-65 public key | no |
devices.device_sig_pk |
ML-DSA-65 public key per device | no |
wrapped_keys.data |
PQPW-framed WrappedMasterKey blob |
encrypted under Argon2id(passphrase ∥ device_secret) |
blobs.data |
PQPW-framed Entry blob |
encrypted under per-entry DEK |
blob_recipients |
(blob_id, user_id) ACL pairs |
metadata (cleartext) |
auth_challenges.nonce |
32 random bytes per challenge | random, short-lived |
sessions.token |
32 random bytes per session | bearer credential, ≤15 min TTL |
1.2 What the server provably never holds
- No passphrase. Entered only on the client, fed straight to Argon2id, never serialized.
- No
device_secret. Generated on the first device, displayed once as the recovery code, stored only in the iOS Keychain or local file. The wire never carries it. - No KEM or signature secret keys. These exist only in
WrappedMasterKeyciphertext or in unlocked client memory. - No DEKs. Generated per entry, used once at AEAD time, dropped.
- No
EntryPlaintextcontent. Passwords, usernames, URLs, notes never reach the server.
This is structural rather than promised: the protocol has no code path that uploads any of these.
1.3 What a fully compromised server can actually do
The attacker walks off with the SQLite file and runs the binary in their lab. They get:
-
Every wrapped-key blob and every entry blob. Both are AEAD-sealed. Without the keys to unwrap
them they are noise. Decrypting the wrapped-key blob requires both the passphrase and the 256-bit
device_secret— the user's recovery code. The hardness of that brute force is quantified in §3. -
The complete sharing graph. They learn which
user_idshared whichblob_idwith whom, blob sizes, and timestamps. This is a real privacy loss in a multi-user setting — organizational structure, reporting lines, password-rotation cadence — but it is not key loss. -
Current session tokens. Tokens with
expires_at > nowgrant authenticated API access for up to 15 minutes per token. The attacker still cannot read plaintext, and everyEntryPUT must carry a valid ML-DSA-65 signature from the user's master signing key — which the attacker doesn't have — so forged entries are rejected. -
The ability to impersonate the server going forward. They can deny service, serve modified
blobs (clients reject them via AEAD or signature failure), and attempt pubkey substitution against clients
that have not yet pinned. Any client that has previously synced is protected by TOFU pinning in
PinnedPubkeyStore.
The server is zero-knowledge with respect to keys and plaintext. All cleartext on the server is either public-key material that has no value when stolen, opaque ciphertext, or routing metadata. The two residual attack surfaces against a stolen server are offline brute force of the wrapped-key blob — bounded in §3 — and pubkey substitution against not-yet-pinned clients, which is the standard TOFU caveat.
2. Client Compromise Analysis
The outcome depends on the device state at the moment of seizure.
2.1 Device locked since boot, no passcode entered
Everything PassQ touches sits behind iOS's class-key encryption. device_secret uses
kSecAttrAccessibleWhenUnlockedThisDeviceOnly, the wrapped-key blob and entry cache live in app
sandbox files protected by NSFileProtectionComplete, the cached master key uses
.biometryCurrentSet, and the device signing secret uses .userPresence. On a
powered-off or freshly rebooted device, none of these keys are accessible without the user passcode.
Attacker yield: nothing usable without breaking iOS itself. A modern iPhone with a strong passcode is the limiting factor. This is the same threat model that protects the iOS Keychain across the OS.
2.2 iOS unlocked, PassQ locked
kSecAttrAccessibleWhenUnlockedThisDeviceOnly items become accessible to any process running as the
app. Two sub-cases:
- Physical access only, no code execution in PassQ's sandbox. Sandbox isolation prevents sibling apps from reading PassQ's Keychain. A sandbox escape is an OS-level exploit.
-
Physical access plus code execution in PassQ's process (jailbreak, debugger, malicious dylib).
The attacker can read
device_secretfrom the Keychain — which is currently not biometry-bound — plus the cached wrapped-key blob from disk. They cannot use the cached master key (.biometryCurrentSet) or the device signing key (.userPresence) without the user's biometric.
Combined yield in the second sub-case: wrapped-key ciphertext + device_secret. From this they
mount an offline passphrase-only attack. The second factor has fallen; passphrase quality is
the only remaining barrier (quantified in §3).
Mitigation available: binding the device_secret Keychain item to
.userPresence blocks this attack — the attacker cannot read it without a biometric or passcode
prompt. Cost: a Face ID / passcode prompt on every passphrase unlock.
2.3 PassQ unlocked
In-memory: the master KEM secret key, the master signing secret key, the recovery signing secret key, and the full decrypted entries array. Any attacker with code execution in this process state has full read access to the vault on this device.
iOS sandbox protections make this difficult but not impossible. Process memory can be dumped via debugger on a development-signed build or via OS-level exploit. The 30-second background auto-lock shortens the window but does not eliminate it. Swift-managed buffers are not actively zeroized on lock; ARC will deallocate but bytes can linger in physical pages until reuse.
2.4 What a stolen device cannot do to other devices
The keys on this device do not yield access to other devices' device signing keys or independent device
sessions. However, device_secret doubles as the recovery code for the whole vault — if an attacker
exfiltrates it from one device and also obtains the passphrase, they can recover-enroll a new device they
control. The user's only detection path is the device list, which they must check.
2.5 Summary
| State | Attacker yield | Practical risk |
|---|---|---|
| Locked since boot | Nothing without an iOS zero-day | low |
| Unlocked, PassQ locked, sandboxed | device_secret (today) — one of two factors, offline attack possible |
medium until device_secret is biometry-bound |
| Unlocked, PassQ locked, after biometric binding | Wrapped-key ciphertext only — zero of two factors | low |
| Unlocked, PassQ unlocked | Full vault content | high — depends on 30 s auto-lock and OS sandbox |
Client compromise is bounded but not zero. The single largest improvement available is binding
device_secret to biometric presence. That change collapses the unlocked-phone, locked-app
attack from "passphrase brute force" to "no actionable material."
3. Post-Quantum and Nation-State Threat Model
The threat is an actor with state-level compute resources and possibly a cryptographically relevant quantum computer (CRQC), attempting to decrypt one user's vault.
3.1 Algorithm-by-algorithm work factor
| Primitive | Role | Best classical attack | Best quantum attack |
|---|---|---|---|
| ML-KEM-768 | Recipient encryption | ≈2200 (lattice sieving) | ≈2180 (Grover-accelerated sieving). Shor's algorithm does not apply. |
| ML-DSA-65 | Signatures | ≈2192 (SIS / MLWE forging) | ≈2180. Same MLWE foundation as ML-KEM. |
| ChaCha20-Poly1305 | AEAD for wrapped key and entries | 2256 key search | ≈2128 (Grover). Still beyond plausible CRQC. |
| Argon2id (256 MiB / 3 / 4) | Passphrase hardening | Memory-bandwidth bound; ~0.5 s/guess commodity CPU | Grover gives sqrt speedup on guess space; per-guess cost is bandwidth-bound and not improved. |
| HKDF-SHA-256 | Per-recipient DEK wrap key derivation | 2256 output | ≈2128 (Grover). Not the weakest link. |
| SHA-256 | Fingerprints, AAD construction | 2128 collision | ≈285 with memory tradeoffs. Fingerprints (16 bytes) inherit 264 quantum collision — adequate for human comparison, not for cryptographic commitment. |
3.2 Concrete attacker walkthroughs
A. Server compromise, nothing else
To decrypt one entry the attacker needs the ML-KEM-768 decapsulation key, which lives inside the wrapped-key
blob. To open the wrapped-key blob they need the passphrase and the 256-bit device_secret.
Brute force of device_secret alone is 2256 guesses classical, 2128 quantum
with Grover — both infeasible.
Pure-PQ with a 256-bit second factor against a server-only attacker is effectively unbreakable.
B. Server compromise plus a stolen device_secret
The attacker also seized a phone or extracted device_secret via the unlocked-phone path in §2.2.
Only the passphrase stands between them and the master keypair. Argon2id at the current parameters takes
roughly 500 ms per guess on a commodity CPU; specialized hardware can drive that to ~10 ms per guess on a
high-bandwidth rig.
For a passphrase with N bits of entropy, work factor = 2N−1 × 0.01 s per guess:
| Passphrase entropy | Single rig | 100,000-way parallel (nation-state) | Verdict |
|---|---|---|---|
| 40 bits (4 dictionary words) | ~170 years | ~15 hours | breakable |
| 50 bits (5 diceware words) | ~175,000 years | ~64 days | hard |
| 60 bits (6 diceware words) | ~180 million years | ~1,800 years | infeasible |
| 70 bits (7+ words) | — | — | unbreakable |
| 80 bits | — | quantum (Grover) ~350 years | unbreakable |
Argon2id is the load-bearing element. Without it, even a 60-bit passphrase falls in hours on a GPU farm. Anchor
any future passphrase-strength UI at ≥60 bits assuming device_secret is also compromised, and
consider raising iterations to 4 or memory_kib to 512 MiB to widen the wall.
C. Adversary with a working CRQC
Such a CRQC does not directly attack ML-KEM-768 or ML-DSA-65. Shor's algorithm is irrelevant to lattice schemes. The adversary would need novel lattice cryptanalysis — possible but not currently known. Best public progress on BKZ blocksize and sieving has been steady but not breakthrough.
If lattice cryptanalysis advances to break ML-KEM-768 in practice, the response is algorithm agility (move to ML-KEM-1024 or another family) rather than parameter tweaks. The wire format supports this: every blob carries explicit algorithm identifiers and the AEAD AAD binds them, so a v2 wire format can ship new algorithms alongside the old.
D. Harvest now, decrypt later
Pure-PQ resists this completely under any known quantum algorithm. The hybrid X25519+ML-KEM alternative would resist a single-algorithm break of either component but not both. The current design chose pure-PQ, trading algorithm-flaw risk for cleaner harvest-now-decrypt-later resistance.
3.3 Where nation-state attackers actually win
Crypto is rarely the breaking point. In rough order of practical likelihood:
- Endpoint compromise. An implant on the user's iOS device captures the master key from memory after unlock. The crypto is fine; the OS is the perimeter.
- Passphrase capture. Keylogger, shoulder surf, coerced disclosure.
- Implementation flaws. A memory-safety bug, a timing leak in Argon2id, a state-machine bug in OpenSSL. OpenSSL 3.5's PQ paths are newer code and will be heavily scrutinized.
- Side channels. Power, EM, cache timing — relevant when the attacker has physical or process co-location.
- Supply chain. Malicious dependency update adding a backdoor before signing.
- Lattice cryptanalysis advance. Possible but the field has not had a "Shor moment" for lattices in 25 years.
Direct algorithmic break of ML-KEM-768 or ML-DSA-65 by a nation state is, today, less likely than any of the above. The crypto choices are not where a state-level adversary should be expected to attack.
4. OpenSSL 3.5 Component Map
Where each cryptographic primitive in PassQ gets serviced by OpenSSL 3.5.
| Operation | OpenSSL algorithm name | API path |
|---|---|---|
| ML-KEM-768 keypair generation | "ML-KEM-768" |
EVP_PKEY_CTX_new_from_name + EVP_PKEY_keygen |
| ML-KEM-768 encapsulation (entry sharing) | "ML-KEM-768" |
EVP_PKEY_encapsulate_init + EVP_PKEY_encapsulate |
| ML-KEM-768 decapsulation (entry decrypt) | "ML-KEM-768" |
EVP_PKEY_decapsulate_init + EVP_PKEY_decapsulate |
| ML-DSA-65 keypair generation | "ML-DSA-65" |
EVP_PKEY_CTX_new_from_name + EVP_PKEY_keygen |
| ML-DSA-65 signing | "ML-DSA-65" |
EVP_DigestSignInit_ex (NULL md) + EVP_DigestSign |
| ML-DSA-65 verification | "ML-DSA-65" |
EVP_DigestVerifyInit_ex (NULL md) + EVP_DigestVerify |
| ChaCha20-Poly1305 AEAD | "ChaCha20-Poly1305" |
EVP_CIPHER_fetch + EVP_EncryptInit_ex2 + AAD via EVP_EncryptUpdate (NULL out) + plaintext via EVP_EncryptUpdate + EVP_EncryptFinal_ex + EVP_CIPHER_CTX_ctrl(GET_TAG) |
| Argon2id (passphrase → wrapping key) | "ARGON2ID" |
EVP_KDF_fetch + EVP_KDF_CTX_new + OSSL_PARAM (memory_cost, iter, threads, password, salt) + EVP_KDF_derive |
| HKDF-SHA-256 (per-recipient DEK wrap key) | "HKDF" with "digest"="SHA2-256" |
EVP_KDF_fetch + EVP_KDF_CTX_new + OSSL_PARAM (mode, digest, key, salt, info) + EVP_KDF_derive |
| SHA-256 (fingerprints, AAD) | "SHA2-256" |
EVP_MD_fetch + EVP_DigestInit_ex2 + EVP_DigestUpdate + EVP_DigestFinal_ex, or one-shot EVP_Q_digest |
| Random bytes | DRBG (AES-256-CTR-DRBG, NIST SP 800-90A) | RAND_bytes |
Notes
- ML-DSA in OpenSSL 3.5 uses the
EVP_DigestSignfamily with aNULLmessage-digest because the algorithm performs its own internalmu = H(BytesToBits(tr) ∥ M′)hashing. The DigestSign naming is a historical artefact; messages are processed directly. - Argon2id support landed in OpenSSL 3.2 and is stable in 3.5. Parameters:
OSSL_KDF_PARAM_ARGON2_MEMCOST,OSSL_KDF_PARAM_ITER,OSSL_KDF_PARAM_THREADS,OSSL_KDF_PARAM_PASSWORD,OSSL_KDF_PARAM_SALT. - HKDF mode for a single-shot derive is
EVP_KDF_HKDF_MODE_EXTRACT_AND_EXPAND. EVP_PKEY_encapsulatereturns both the wire ciphertext and the 32-byte shared secret in a single call.- All
*_fetchcalls should be cached per-process — fetching on every operation is the most common OpenSSL 3.x performance pitfall.
Stack mapping
| Layer | Integration |
|---|---|
| Rust server | The openssl crate (≥ 0.10.66) provides idiomatic PKey<Private>, Cipher, Kdf, and MessageDigest wrappers around the EVP API. ML-KEM and ML-DSA are exposed via pkey::Id::ML_KEM_768 and pkey::Id::ML_DSA_65. openssl-sys is available for direct FFI if needed. |
| iOS (Swift) | Build OpenSSL 3.5 as a static libcrypto.a xcframework. Replace OQSKit with a thin Swift wrapper around the EVP API, matching the existing MLKEM768.swift / MLDSA65.swift signatures so call sites in CryptoCore are unchanged. |
After the migration the only PQ-aware code in the tree is the thin Swift wrapper and the matching Rust wrappers — both pointing at the same audited C codebase. Add a cross-stack roundtrip test that signs a known message on iOS and verifies on the server, and encaps to a known pubkey on iOS and decaps the same ciphertext on the server. That single test would have caught the earlier Dilithium-3 vs ML-DSA-65 mismatch instantly.
5. Summary
- Server zero-knowledge: yes, with respect to keys and plaintext. The sharing graph is cleartext metadata; hiding it would require a separate architecture.
- Client compromise: state-dependent. Locked devices with strong passcodes are solid. Unlocked-phone-locked-app currently leaks
device_secret; binding it to biometric presence closes that gap. Unlocked-app has no defense beyond OS sandbox and the 30-second auto-lock. - Post-quantum posture: strong. Against any plausible nation-state, the wrapped-key blob is the only meaningful target, and it requires both the passphrase and a 256-bit secret. Passphrase quality is the practical limit; Argon2id widens the wall but does not rescue weak passphrases.
- OpenSSL 3.5 fit: clean. One EVP-family call per primitive, the same algorithm names on both sides of the wire, native FIPS 203 / 204 support means no implementation-name mismatch trap.