peach-android-audit-report.md

41.5 KB · raw

rendered · source

Peach Android — Audit Report

Date: 2026-06-09 Base version (per brief): v2.1.40 — note: app/build.gradle actually declares versionName "2.2.0" / versionCode 202000. The codebase audited is ahead of the brief's stated base. Findings below reference the code as it currently stands. Method: Read-only static comparison of peach-android against peach-browser (the reference implementation). No code was modified; no koh/git/gradle was run. Every claim cites file:line. Where a browser counterpart could not be found, that is stated rather than assumed.


Executive Summary

The cryptographic primitives are in good shape. Argon2id parameters, AES-256-GCM usage, nonce/tag handling, the per-entry sync HKDF envelope, sync-identity derivation, computeVaultId, the Noise NK/XX handshakes, SAS derivation, and the Codex RS/Aztec/deflate stack are all byte-for-byte (or parameter-for-parameter) equivalent to the browser. Key material is zeroed on lock and in transient paths, the biometric escrow is hardware-backed and correctly configured, and there is no secret-bearing logging. That is the good news, and it is substantial.

The serious problems are not in the math — they are in lifecycle and parity:

Counts: 2 Critical, 8 Major, ~10 Minor. A notable amount of the sync/pairing surface is stubbed and throws at runtime, so several sync-related risks are latent rather than live today (see MN-2). I would not ship this as a password manager until CF-1/CF-2 are fixed; the cross-platform parity breaks (MF-1…MF-4) should be resolved or explicitly documented as platform-local before any cross-device sync/recovery is advertised.


Critical Findings

CF-1: Vault never auto-locks and does not lock on backgrounding

Severity: Critical Location: app/src/main/java/com/peachpasswords/ui/lock/LockViewModel.kt (no timer); app/src/main/java/com/peachpasswords/vault/VaultRepository.kt:232 (lock()); app/src/main/java/com/peachpasswords/vault/VaultSettings.kt:10 (autoLockMinutes); app/src/main/java/com/peachpasswords/ui/settings/SettingsViewModel.kt:164 (setAutoLockMinutes); app/src/main/java/com/peachpasswords/PeachApplication.kt (no lifecycle observer). Description: autoLockMinutes is written to settings and exposed in the UI (SettingsCardBodies.kt:241-249) but is read by nothing — there is no timer, no postDelayed, no elapsedRealtime tracking. There is also no lifecycle observer: grep for ON_STOP/onStop/onPause/ProcessLifecycleOwner finds no app-level hit. The vault key lives in the @Singleton VaultRepository and is cleared only by an explicit lock() call (which lock() does correctly — it zeroes derivedKey and rootKey, VaultRepository.kt:232-244). Impact: Once unlocked, the vault stays unlocked — key in memory, all entries decryptable, autofill/QS-tile/widget live — until the process is killed. Backgrounding the app, locking the device, and the configured auto-lock interval do nothing. The user-facing auto-lock setting is a false assurance. This is the single most damaging gap for a password manager. Recommended fix: Register a DefaultLifecycleObserver on ProcessLifecycleOwner that calls vault.lock() on ON_STOP, plus an idle timer honoring autoLockMinutes, reset on user interaction.

CF-2: No way to manually lock the vault from the UI (lock() is dead code)

Severity: Critical Location: app/src/main/java/com/peachpasswords/ui/vault/VaultListViewModel.kt:72 (fun lock() = vault.lock(), never invoked); app/src/main/java/com/peachpasswords/ui/vault/VaultListScreen.kt:203 (TopBar exposes only Settings + Add). Description: VaultListViewModel.lock() is defined but never called anywhere in app/src/main. The vault-list top bar wires only onSettings/onAdd. There is no lock button anywhere. Impact: Combined with CF-1, the only events that ever lock the vault are destroyVault() (duress/panic) and process death. A user cannot deliberately lock their vault — e.g. before handing the phone to someone. Recommended fix: Wire a lock control into the top bar or settings calling VaultListViewModel.lock().


Major Findings

MF-1: Recovery words derive a different key on Android than the browser — escrow is not cross-platform recoverable

Severity: Major Location: vault/RecoveryWords.kt:76-91 (wordKeyMaterial = BLAKE3(words.joinToString(" ")) → deriveRecoveryKey); browser src/shared/recovery-words.ts:61-142 + src/background/vault.ts:286,330 (wordsToRootKeyMaterial = reconstructed BIP39 entropy). Also: Android wraps the entire slot-A payload (VaultRepository.kt:639-654), the browser wraps only the 32-byte root key (vault.ts:275-316). Description: The HKDF salt and info label match ("peach-recovery-v1"), but the input keying material differs (BLAKE3 of the joined word strings vs. reconstructed BIP39 entropy) and the wrapped object differs (whole payload vs. root key). Two independent divergences, either of which breaks interop. On Android the root key is independently random (VaultRepository.kt:117) and unrelated to the displayed words, whereas in the browser the root key is the BIP39 entropy. Impact: Recovery words generated on Android cannot recover the vault in the browser and vice-versa. If a user is told "write these words down to recover on any device," that promise is false across platforms — a recovery-time data-loss risk. Within a single platform it works correctly. Recommended fix: If cross-platform recovery is a requirement, converge on the browser model (root key = BIP39 entropy; wrap only the root key; IKM = wordsToRootKeyMaterial). If Android recovery is intentionally platform-local, document it and never imply cross-device recovery.

MF-2: "PeachScript" is two completely different, incompatible formats

Severity: Major Location: Browser src/crypto/peachScript.ts + src/crypto/peachPayload.ts; Android ui/vault/PeachScriptExport.kt / PeachScriptImport.kt. Description: Browser PeachScript = a binary envelope (magic PCHS, Argon2id, AES-256-GCM, zstd sections, BLAKE3 checksum, size-bucket padding, string-table entry packing) re-encoded into Private-Use-Area codepoints U+E000–U+E0FF. Android PeachScript = a JSON object {"peachscript":1,"encrypted":true,"kdf":"argon2id","salt":…,"blob":…} where blob = nonce‖AES-256-GCM(plaintext-JSON entries) under AAD "peachscript_export_v1". They share no container, compression, checksum, entry packing, AAD, or encoding. The AAD peachscript_export_v1 and the marker "peachscript":1 do not exist in the browser (grep-confirmed). Android export also carries only 6 fields (title/username/password/url/notes/totp, PeachScriptExport.kt:100-109) — email folds into username; tags/card/passkey/breach/history/timestamps are dropped even within its own format. Impact: A browser-produced .peach backup cannot be imported on Android and vice-versa — a silent portability break for the format both products call "PeachScript." In a disaster-recovery scenario this is effective data loss. Each platform does round-trip with itself (ExportRoundTripTest.kt:35-61). Recommended fix: Pick one canonical PeachScript container. To match the browser, Android must implement the peachPayload binary envelope (PCHS, zstd, BLAKE3, string-table packing) plus PUA encode/decode. Until then, document the two formats as non-interoperable and rename one.

MF-3: Card network field renamed to cardNetwork; last4 absent — silent card-data loss on sync

Severity: Major Location: Browser network (src/types/… entry model; encoded at peachPayload.ts:539 via networkToByte(entry.network)); Android cardNetwork (vault/VaultEntry.kt:39). Browser last4 has no Android counterpart. Description: kotlinx.serialization keys on the Kotlin property name, so Android's cardNetwork will not map to the browser's network over any name-keyed sync/serialization channel. last4 is simply missing on Android. Impact: Card network (and last4) are silently lost when a card entry round-trips browser↔Android. Recommended fix: Rename to network or add @SerialName("network"), and add last4.

MF-4: Non-BMP master passwords can derive different keys across platforms

Severity: Major Location: Android vault/VaultCrypto.kt:46 (generator.generateBytes(password.toCharArray(), hash)); browser src/crypto/argon2.ts:12-21 (hash-wasm UTF-8 encodes the string). Description: All Argon2id parameters match exactly (m=65536, t=3, p=4, 32-byte out, 16-byte salt). The only divergence is input encoding: BouncyCastle's char[] path operates per-UTF-16 code unit, while hash-wasm UTF-8-encodes the full string. For BMP characters these agree; a password containing a non-BMP character (e.g. an emoji, a surrogate pair) can diverge. Impact: A vault created in the browser with a non-BMP master password may be unlockable only there (and vice-versa) — cross-platform unlock/restore failure for affected users. Same-platform use is unaffected. Rated Major (not Critical) because it was reasoned from BouncyCastle's documented behavior, not a runtime KAT. Recommended fix: Derive on explicit UTF-8 bytes on both sides (password.toByteArray(Charsets.UTF_8)), or document a master-password charset restriction. Add a shared cross-platform known-answer test vector.

MF-5: Duress vs. real unlock is timing-distinguishable on Android

Severity: Major Location: ui/lock/LockViewModel.kt:57-71; vault/DuressConfig.kt:105-119 (verify); vault/VaultRepository.kt:132-160 (unlock). Description: On every unlock, duress.verify(password) runs first (one full Argon2id derivation against the duress salt); only on no-match does it fall through to vault.unlock (a second full Argon2id derivation against slot A). A real-password unlock therefore costs ~2× Argon2id; a duress-password unlock costs ~1× and returns immediately. The browser explicitly derives/decrypts both slots in parallel to keep wall-clock constant (vault.ts:1067-1122, comment "DUR-002"). Impact: An observer who can time unlocks can distinguish "duress entered" from "real entered," partially defeating the deniability goal. Bounded by the noise of absolute Argon2id timing, but a real regression from the browser's stated threat model. Recommended fix: Derive both keys (and ideally attempt both decrypts) unconditionally and in parallel, then branch on results, so wall-clock time is password-independent.

MF-6: Duress/decoy architecture diverges from the browser and weakens deniability

Severity: Major Location: vault/VaultRepository.kt:21 (DECOY_SENTINEL = "peach_decoy_v1"), :168-206 (unlockDecoy, no password, keyed by the sentinel), persisted to a distinctly-named peach_vault_duress pref (VaultStore.kt:126-130,156); browser stores the decoy as a real second slot (slot B) keyed by a user-chosen duress passphrase, tried in parallel at the same prompt (vault.ts:993-1029, 1067-1169). Description: The browser decoy is a genuine second vault keyed by a duress password, reached by typing that password at the normal prompt (constant-time, indistinguishable). Android's decoy is reached by an explicit no-secret unlockDecoy() path keyed by a hardcoded in-app constant, stored under a distinctly-named pref. There is no duress passphrase. The decoy key is independent of slot A (verified — it cannot derive or brute-force the real key), so the real vault is not endangered; the weakness is purely in deniability. Impact: (a) The peach_vault_duress pref's existence signals a decoy was configured. (b) The sentinel is a constant in the APK, so anyone can open the decoy without a secret. (c) No constant-time dual-slot unlock (compounds MF-5). A coerced user relying on plausible deniability gets materially less than the browser provides. Recommended fix: Port the browser's slot-B-under-duress-passphrase model (single prompt, parallel dual-slot decrypt, user-chosen decoy passphrase, length-matched slots). If a no-secret panic decoy is intentional product scope, document it and remove "plausible deniability"/"exact port" framing.

MF-7: Autofill performs no origin/app-trust validation and uses a loose substring domain matcher

Severity: Major Location: autofill/PeachAutofillService.kt:44-55; autofill/AutofillResponseBuilder.kt:27-47 (matchEntries); autofill/AssistStructureParser.kt:47-49 (webDomain taken verbatim); browser reference src/background/vault.ts:919-930,1927 (strict eTLD+1 bucket). Description: When unlocked, the service offers entries for any form: matchEntries partitions into hits/rest but returns hits + rest (then .take(6)), so non-matching entries are still surfaced; with a null/blank origin it returns all entries. The matcher itself is a substring test on a single host label (coreLabel → hay.contains(core)), looser and less correct than the browser's registrable-domain match — core="github" matches "GitHub Desktop" or any host containing "github". webDomain is trusted verbatim with no check that the providing package is a real browser and no Digital Asset Links validation for native apps. Impact: Wrong-site credentials surface in the picker; a malicious or spoofing app can elicit the picker for credentials it does not own (the classic Android autofill phishing vector). Mitigated by the fact that fill requires an explicit user tap (no silent fill), which is why this is Major rather than Critical. Recommended fix: Filter strictly to extractDomain(entry.url) == extractDomain(origin) (PSL already available via DomainUtils); when nothing matches, show only the picker. Validate webDomain-providing packages against a trusted-browser allowlist; verify native apps via Digital Asset Links.

MF-8: Clipboard is never cleared and is not flagged sensitive on in-app copy paths

Severity: Major Location: Copy sites with no clear and no sensitive flag — EntryDetailViewModel.kt:74-75, EntryEditViewModel.kt:144-145,154-155, VaultListViewModel.kt:129-130,144-145, RecoveryWordsScreen.kt:166-167, ExportScreen.kt:310-311. Only qs/PeachTileService.kt:67-74 sets android.content.extra.IS_SENSITIVE. The CopyButton.kt:32-77 "drain" is a cosmetic animation only. Description: Passwords, TOTP codes, recovery words, and full vault-backup blobs are written via ClipData.newPlainText(...) with no auto-clear timer and (except the QS tile) without the Android-12+ IS_SENSITIVE flag. Impact: Secrets persist on the system clipboard indefinitely, readable by any clipboard-capable app and shown in the OS clipboard preview/history. A copied recovery phrase or backup blob is high value. Recommended fix: Set IS_SENSITIVE on every sensitive ClipData; schedule a clear/overwrite after a short timeout (mirror the browser's clipboard-clear behavior).


Minor Findings

MN-1: Vault plaintext is not padded — slot-A ciphertext length leaks vault size

Severity: Minor (Major in the duress threat model) Location: VaultRepository.kt:729-766 (sealSlotA/persistSlotA encrypt with no padding); padding helpers exist but are unused — VaultCrypto.kt:80-89 (padToBucket/padToBlock), :78 (generateNoise); browser vault.ts:51 (PAD_BLOCK=1024), :647-654 (padPlaintext), :67,1012 (DURESS_SLOT_SIZE ceiling). Description: The browser pads every slot-A plaintext to the next 1 KiB block, and to a fixed 256 KiB ceiling for both slots when duress is configured (so the slots are length-identical). Android applies no padding; slot A grows/shrinks with exact plaintext length. Slot B is roughly size-matched (writeMatchingNoiseSlotB, :736) but slot A is not bucketed. Impact: An attacker with at-rest access can estimate stored-secret count/size from ciphertext length; combined with MF-6 it further erodes duress indistinguishability. Not data loss, not a crypto break. Recommended fix: Apply padToBlock (and the duress ceiling when configured) in sealSlotA/persistSlotA, matching the browser.

MN-2: Acoustic pairing codec and pairing-UI entry points are stubs that throw at runtime

Severity: Minor (but functionally significant) Location: sync/Ggwave.kt:37-41 (createChirpCodec throws UnsupportedOperationException); ui/pairing/ChirpPairViewModel.kt:115-118,128,130,132 (completePairing no-op, getLocalSubnet returns null, openPairingTransport/openPairingServer throw). Description: The Noise NK/XX handshakes, ChirpSyncTransport, MdnsPairingServer, and the per-entry sync envelope are fully implemented and unit-tested (see Clean Findings), but the acoustic codec and the pairing view-model entry points are stubbed. The user-facing pairing/sync flow therefore cannot complete at runtime today. Impact: Sync/pairing is non-functional in production. This is not a security hole (no half-built crypto path is reachable), and it means the sync-related data-loss and validation risks below are latent, not live. Worth surfacing prominently because the crypto audit otherwise reads as if sync ships. Recommended fix: Gate the pairing UI behind a feature flag until the native codec + transport land, or remove the entry points.

MN-3: Incoming sync entries are not validated/sanitized before persisting (latent — sync is stubbed)

Severity: Minor (would be Major once sync is live) Location: sync/VaultSyncAdapter.kt:64-77; vault/VaultRepository.kt:465-499 (applyMergedRemoteEntries); browser vault.ts:1534,1855-1856 (sanitizeRestoredEntry), chirp-sync-vault-adapter.ts:298-321. Description: The browser runs every incoming remote entry through sanitizeRestoredEntry (rejects empty title, login lacking password, passkey missing credentialId; migrates legacy shapes) and validates typeof id === "string". Android trusts whatever kotlinx.serialization decodes; the id-binding check (entry.id == id, VaultSyncAdapter.kt:71) protects routing but not content. A malicious paired device (already sharing the root key) could push malformed entries or an unbounded passwordHistory (no re-cap on merge). Impact: Divergent/garbage vault state from a trusted-but-compromised peer. Requires an already-paired device, and sync is currently stubbed, so latent. Recommended fix: Port sanitizeRestoredEntry checks and re-cap passwordHistory to PASSWORD_HISTORY_MAX on merge.

MN-4: No cross-platform byte-vector test pins the Noise handshake or key derivations

Severity: Minor Location: app/src/test/java/com/peachpasswords/sync/C7ChirpTransportTest.kt:93-177 (initiator/responder agree only with each other); browser src/shared/noise-js.test.ts:42-51 (pins only the HMAC auth proof, which Android does not implement). Description: Neither side pins the actual Noise NK/XX transcript (message bytes, transport keys, handshakeHash), nor the derived X25519/Ed25519 public keys, against a fixed vector shared across platforms. A subtle divergence (HKDF info/salt, nonce endianness, BLAKE2s personalization, key-split order, stablelib-vs-BouncyCastle public-key derivation) would not be caught and would wedge all cross-platform pairing silently. Recommended fix: Add fixed-ephemeral-key and fixed-seed vector tests on both platforms; make generateKeyPair() injectable for the test (NoiseNK.kt:64, NoiseXX.kt:66).

MN-5: Hand-rolled JSON parser in pairing-frame decode can diverge from JSON.parse

Severity: Minor Location: sync/ChirpPairing.kt:530-628 (decodePairingFrame/simpleJsonObject); browser src/shared/chirp-handshake.ts:67-85 (JSON.parse). Description: Android's custom parser does not unescape string escapes in values and assumes flat fields. Key-bearing fields are hex-regex-validated (:544-551), so no key confusion — but a browser peer whose device_name contains a quote/backslash/Unicode escape would parse incorrectly or throw on Android. Recommended fix: Use kotlinx.serialization's JSON parser (already a dependency).

MN-6: FLAG_SECURE not applied per-screen on several secret-bearing screens; absent entirely in debug builds

Severity: Minor Location: Activity-wide release-only flag at MainActivity.kt:52-57 (if (!BuildConfig.DEBUG)); per-screen flags only on EntryDetailScreen.kt:100-101 and NotesModeScreen.kt:81-82. No per-screen flag on RecoveryWordsScreen.kt, RecoveryWordEntryScreen.kt, ExportScreen.kt, CodexGenerateScreen.kt, CodexRestoreScreen.kt, LockScreen.kt, VaultListScreen.kt. Description: Release builds get window-wide FLAG_SECURE (covers all screens); debug builds skip it, leaving recovery words / export / codex backup screenshottable in QA builds and reliant on a single activity-wide flag. Recommended fix: Keep FLAG_SECURE on in debug too, or add per-screen DisposableEffect flags to the secret-bearing screens as defense-in-depth.

MN-7: Autofill picker activity is not FLAG_SECURE

Severity: Minor Location: MainActivity.kt:54-55 (main window secure); autofill/AutofillEntryPickerActivity.kt (referenced at PeachAutofillService.kt:84) sets no FLAG_SECURE. Description: The picker that lists/decrypts vault entries during autofill can be screenshotted/recorded. Recommended fix: Set FLAG_SECURE on the picker activity window.

MN-8: Settings stored in cleartext SharedPreferences (metadata only)

Severity: Minor Location: vault/VaultStore.kt:114,140-144 (peach_vault_store / peach_settings). Description: Vault slots are AES-GCM ciphertext (correct), but VaultSettings (theme, accent, autoLockMinutes, trashRetentionDays, duress-configured flag) is plaintext JSON. No secrets, but configuration metadata is readable on a rooted/backup-extracted device. allowBackup="false" limits exposure. Recommended fix: Optional — move settings into the encrypted payload or EncryptedSharedPreferences (the dependency is already present).

MN-9: Decoy session can surface recovery words / sync master key

Severity: Minor Location: VaultRepository.kt:178,196-197 (decoy mints/loads a root key); ui/vault/RecoveryWordsScreen.kt:65-66 derives words from syncMasterKey(). Browser keeps rootKey null in decoy unlock and throws on recovery-word access (vault.ts:533-536,1146-1157). Description: In decoy mode the UI can still present recovery words for the decoy's throwaway root key. No key leak, but it could confuse the deniability story (a coerced user shown "your recovery words" for the decoy). Recommended fix: Gate recovery-word display and sync-master-key access on !decoyMode.

MN-10: Minor robustness / dead-code items

Severity: Minor


Browser Parity Matrix

Feature Browser Android Status Notes
Entry core fields (id/title/username/email/password/url/additionalUrls/totp/notes/tags) present present, same names ✅ VaultEntry.kt:9-26
modifiedAt / deletedAt / breach / passwordChangedAt present present ✅ VaultEntry.kt:20-27
passwordHistory shape (password+changedAt) present present ✅ VaultEntry.kt:23, PasswordHistoryItem.kt
Card network network cardNetwork ❌ Name mismatch → lost on sync (MF-3)
Card last4 present absent ⚠ Missing on Android (MF-3)
Card month/year/cvc/holder/nickname present present ✅ VaultEntry.kt:32-37
Passkey storage nested passkey object flat fields ⚠ Flat→nested mapping on export unverified (MN)
type enum values login/note/passkey/card login/note/card (String) ✅ Values align
isWeak/isReused/favicon computed, not stored computed, not stored ✅ Parity
TOTP algorithm / hash / period / digits / time source RFC 6238, SHA1/256/512, 30s, 6 identical ✅ TOTPGenerator.kt; RFC vectors pass (TOTPGeneratorTest.kt:13-15)
Autofill domain matching strict eTLD+1 loose substring on one label ❌ MF-7
Autofill TOTP freshness n/a code generated at fill time ✅ AutofillResponseBuilder.kt:94
Autofill app/origin trust registrable-domain bucket none ❌ MF-7
PeachScript container PCHS binary + zstd + BLAKE3 + PUA JSON + AES-GCM ❌ Incompatible (MF-2)
PeachScript AAD peachscript_export_v1 not present present ❌ Browser uses no such AAD (MF-2)
Codex GF(2⁸) poly / RS shard counts / deflate / Aztec ECL / 6×6 grid reed-solomon WASM, pako L9, ZXing 25% poly 0x11d, Deflater BEST, ZXing 25% ✅ ReedSolomon.kt, CodexCodec.kt, CodexTypes.kt
Codex cross-platform paper restore — — ⚠ Structurally compatible; no cross-platform parity-byte fixture (MN-4 class)
Noise NK/XX patterns, suite, hash, nonce, AAD, key-split as spec byte-equivalent ✅ NoiseNK.kt, NoiseXX.kt
SAS derivation first 4 bytes BE mod 1e6 pad6 identical ✅ SASDerivation.kt (Android also user-gates it)
computeVaultId / sync-identity / signing-key derivation BLAKE3 + HKDF-SHA256 identical inputs ✅ SyncIdentity.kt:19-49
Chirp on-air payload (magic/offsets/CRC32) defined byte-identical ✅ ChirpPayload.kt
Recovery escrow IKM + wrap target entropy / root key BLAKE3(words) / whole payload ❌ Not cross-recoverable (MF-1)
Per-entry sync HKDF envelope HKDF-SHA256, info id+"sync-entry-v1", AAD peach-sync-entry-v1 identical ✅ VaultSyncAdapter.kt:89-123
Conflict resolution LWW by modifiedAt, tombstone-guarded identical ✅ VaultRepository.kt:474-491
Acoustic pairing / sync end-to-end implemented codec + UI stubbed (throws) ❌ Non-functional at runtime (MN-2)

Crypto Correctness Matrix

Operation Parameter Browser Value Android Value Match?
Vault KDF Algorithm Argon2id (argon2.ts:12) Argon2id (VaultCrypto.kt:38) ✅
Vault KDF Memory 65536 KiB 65536 KiB (VaultCrypto.kt:11) ✅
Vault KDF Iterations 3 3 (VaultCrypto.kt:12) ✅
Vault KDF Parallelism 4 4 (VaultCrypto.kt:13) ✅
Vault KDF Output / salt length 32 / 16 32 / 16 (VaultCrypto.kt:14-15) ✅
Vault KDF Password encoding UTF-8 (hash-wasm) char[]/UTF-16 (VaultCrypto.kt:46) ⚠ BMP-only parity; non-BMP diverges (MF-4)
Vault cipher Algorithm / nonce / tag AES-256-GCM / 12B CSPRNG / 128b AES-256-GCM / 12B SecureRandom / 128b (VaultCrypto.kt:51-59) ✅
Vault cipher At-rest layout nonce/ct/tag fields nonce‖ct‖tag base64 ⚠ equivalent bytes, different serialization (not a defect)
Vault AAD slot A slot_a_v1 slot_a_v1 (VaultCrypto.kt:24) ✅
Vault AAD slot B slot_b_v1 (vault.ts:1013) decoy uses slot_a_v1 (VaultRepository.kt:182) ❌ architectural divergence (MF-6)
Vault AAD recovery escrow [1] byte, wraps root key recovery_escrow_v1, wraps payload ❌ different AAD + wrapped object (MF-1)
Plaintext padding block / duress ceiling 1024 / 262144 none / none ❌ (MN-1)
Key zeroing derived + root key on lock zeroed zeroed (VaultRepository.kt:232-244) ✅
Recovery KDF algorithm / info / salt HKDF-SHA256 / peach-recovery-v1 / 16B identical (RecoveryWords.kt:86-87) ✅ (IKM differs — MF-1)
Sync envelope KDF / salt / info / len / AAD / scope HKDF-SHA256 / empty / id+sync-entry-v1 / 32 / peach-sync-entry-v1 / per-entry identical (VaultSyncAdapter.kt:89-123) ✅
Sync identity X25519 / signing HKDF salts peach-sync-identity-v1 / peach-sync-signing-v1 identical (SyncIdentity.kt:19-20) ✅
vault_id BLAKE3(master‖"peach-vault-id") 64 hex — identical (SyncIdentity.kt:32-40) ✅
Noise suite / hash / nonce LE@offset4 / AAD=h ChaCha20-Poly1305 / BLAKE2s / yes / yes identical (NoiseNK.kt, NoiseXX.kt) ✅
Pairing SAS first 4 bytes BE mod 1e6 pad6 — identical (SASDerivation.kt) ✅
Biometric escrow HW key, auth-required, invalidate-on-enroll n/a (platform) yes (BiometricKeyStore.kt:107-115) ✅
Duress hash independent Argon2id salt, hash-only, constant-time compare n/a yes (DuressConfig.kt:85-129) ✅

Test Coverage Summary

91 @Test methods across 14 files in app/src/test; no instrumented tests under app/src/androidTest. Tests use the real VaultCrypto (not mocked) plus a hand-rolled FakeStorage; sync tests use all-zero Noise keys (ByteArray(32)) — acceptable for protocol-shape tests, not real KATs.

Module Tests Coverage gaps
vault/VaultRepository 23 Solid: CRUD, LWW merge, duress decoy, rollback, retention.
vault/RecoveryEscrow 6 Seal/recover round-trip incl. all-"abandon" phrase (genuine regression test, RecoveryEscrowTest.kt:117-128).
vault/TOTPGenerator 4 Canonical RFC 6238 vectors.
vault/PasswordGenerator 4 OK.
vault/VaultCrypto 0 No direct test — Argon2id params, AES-GCM, AAD-mismatch rejection, nonce uniqueness, zero() exercised only indirectly. No KATs.
vault/VaultHealth 0 Breach/reuse/aged/weak scoring untested.
ui/lock/BiometricKeyStore 0 Wrap/unwrap + invalidation path untested (needs instrumented test).
vault/DuressConfig 0 Duress verify/timing untested at unit level.
codex/CodexCodec 4 Round-trip; RS/Aztec error-correction edges not separately tested; no cross-platform parity-byte fixture.
autofill/AutofillMatch 3 Ranking only; no origin-trust/anti-phishing test.
sync/* (5 files) 24 Protocol shape with fakes; real ggwave codec stubbed; no cross-platform byte vectors.
ui/security/PasswordStrength 11 Good.
ui/vault/ExportRoundTrip, ImportParsers 13 Same-platform round-trip + parsers.

Largest gaps: the core crypto primitive (VaultCrypto), VaultHealth, BiometricKeyStore, and duress verification have no direct tests; no cross-platform interop vectors exist anywhere.


Dependency Inventory

From app/build.gradle (Compose via BOM 2024.06.00; no libs.versions.toml). All versions pinned (no floating ranges).

Library Version Purpose Sensitive? Pinned?
org.bouncycastle:bcprov-jdk18on 1.78.1 Argon2id KDF, X25519/Ed25519, ChaCha20-Poly1305 Yes Yes
com.google.crypto.tink:tink-android 1.13.0 Crypto primitives (present; VaultCrypto uses JCE directly — possibly unused) Yes Yes
androidx.security:security-crypto 1.1.0-alpha06 EncryptedSharedPreferences Yes Yes (alpha — churn risk)
androidx.biometric:biometric 1.1.0 BiometricPrompt Yes Yes
androidx.credentials:credentials (+ play-services-auth) 1.5.0 Passkey/credential provider Yes Yes
com.squareup.okhttp3:okhttp 4.12.0 Feedback HTTP (endpoint empty) Indirect Yes
com.google.zxing:core 3.5.3 Codex/Aztec encode-decode Indirect Yes
androidx.camera:camera-* 1.3.4 Codex restore camera Indirect Yes
org.jetbrains.kotlinx:kotlinx-serialization-json 1.7.1 Vault payload / settings codec Indirect Yes
org.jetbrains.kotlinx:kotlinx-coroutines-android 1.8.1 Concurrency No Yes
com.google.dagger:hilt-android (+compiler) 2.51.1 DI No Yes
androidx.glance:glance-appwidget / -material3 1.1.0 Widgets Indirect Yes
androidx.autofill:autofill 1.1.0 Inline autofill UI Indirect Yes
androidx.navigation:navigation-compose 2.8.0 Navigation No Yes
androidx.lifecycle:lifecycle-* 2.8.4 Lifecycle/VM No Yes
androidx.activity:activity-compose 1.9.2 Compose host No Yes
androidx.appcompat:appcompat / core-ktx / core-splashscreen 1.7.0 / 1.13.1 / 1.0.1 Compat/core No Yes
com.google.android.material:material 1.12.0 Material views No Yes
Compose (material3/ui/foundation/icons) BOM 2024.06.00 UI No Yes

Notes: security-crypto is an alpha in a security-critical role (deliberately pinned; alpha API can churn). tink-android appears unused (VaultCrypto uses JCE + BouncyCastle) — confirm and drop if dead weight. No SBOM/lockfile. The browser uses different libraries for equivalent functions (hash-wasm Argon2, stablelib Noise/X25519, pako deflate, reed-solomon-erasure WASM, otpauth) — equivalence is verified at the parameter level above, but the implementation mismatch is exactly why the cross-platform byte-vector tests in MN-4 are needed.


Clean Findings (explicitly verified correct)

Cryptographic primitives

Key-material lifecycle

Data integrity

Concurrency / network / surface


Residual Uncertainty (flagged honestly)