peach-android-audit-report.md

41.5 KB · raw

rendered · source

1# Peach Android — Audit Report23**Date:** 2026-06-094**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.5**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.67---89## Executive Summary1011The 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.1213The serious problems are not in the math — they are in **lifecycle and parity**:1415- **The vault never locks.** There is no auto-lock timer, no lock-on-background, and the manual lock function is dead code. The advertised auto-lock setting does nothing. Once unlocked, the vault key lives in memory and every entry is decryptable until the OS kills the process. For a password manager this is the most serious finding in the report. **(CF-1, CF-2)**16- **Several cross-platform formats are silently incompatible**, any of which can cause recovery failure or data loss in a cross-device scenario: recovery words derive different keys on each platform, PeachScript is an entirely different container, the card `network` field is renamed (lost on sync), and non-BMP master passwords can fail to unlock across platforms. **(MF-1…MF-4)**17- **Duress/plausible-deniability is materially weaker than the browser** (timing-distinguishable unlock, decoy keyed by a hardcoded sentinel, decoy existence detectable, slots not length-matched). For a feature whose entire purpose is deniability under coercion, these matter. **(MF-5, MF-6)**18- **Autofill has no origin/app-trust validation and a loose substring domain matcher**, and the clipboard is never cleared and is not flagged sensitive on most copy paths. **(MF-7, MF-8)**1920Counts: **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.2122---2324## Critical Findings2526### CF-1: Vault never auto-locks and does not lock on backgrounding27**Severity:** Critical28**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).29**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`).30**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.31**Recommended fix:** Register a `DefaultLifecycleObserver` on `ProcessLifecycleOwner` that calls `vault.lock()` on `ON_STOP`, plus an idle timer honoring `autoLockMinutes`, reset on user interaction.3233### CF-2: No way to manually lock the vault from the UI (`lock()` is dead code)34**Severity:** Critical35**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).36**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.37**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.38**Recommended fix:** Wire a lock control into the top bar or settings calling `VaultListViewModel.lock()`.3940---4142## Major Findings4344### MF-1: Recovery words derive a different key on Android than the browser — escrow is not cross-platform recoverable45**Severity:** Major46**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`).47**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.48**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.49**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.5051### MF-2: "PeachScript" is two completely different, incompatible formats52**Severity:** Major53**Location:** Browser `src/crypto/peachScript.ts` + `src/crypto/peachPayload.ts`; Android `ui/vault/PeachScriptExport.kt` / `PeachScriptImport.kt`.54**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.55**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`).56**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.5758### MF-3: Card `network` field renamed to `cardNetwork`; `last4` absent — silent card-data loss on sync59**Severity:** Major60**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.61**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.62**Impact:** Card network (and last4) are silently lost when a card entry round-trips browser↔Android.63**Recommended fix:** Rename to `network` or add `@SerialName("network")`, and add `last4`.6465### MF-4: Non-BMP master passwords can derive different keys across platforms66**Severity:** Major67**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).68**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.69**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.70**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.7172### MF-5: Duress vs. real unlock is timing-distinguishable on Android73**Severity:** Major74**Location:** `ui/lock/LockViewModel.kt:57-71`; `vault/DuressConfig.kt:105-119` (`verify`); `vault/VaultRepository.kt:132-160` (`unlock`).75**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").76**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.77**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.7879### MF-6: Duress/decoy architecture diverges from the browser and weakens deniability80**Severity:** Major81**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`).82**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.83**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.84**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.8586### MF-7: Autofill performs no origin/app-trust validation and uses a loose substring domain matcher87**Severity:** Major88**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).89**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.90**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.91**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.9293### MF-8: Clipboard is never cleared and is not flagged sensitive on in-app copy paths94**Severity:** Major95**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.96**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.97**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.98**Recommended fix:** Set `IS_SENSITIVE` on every sensitive `ClipData`; schedule a clear/overwrite after a short timeout (mirror the browser's clipboard-clear behavior).99100---101102## Minor Findings103104### MN-1: Vault plaintext is not padded — slot-A ciphertext length leaks vault size105**Severity:** Minor (Major in the duress threat model)106**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).107**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.108**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.109**Recommended fix:** Apply `padToBlock` (and the duress ceiling when configured) in `sealSlotA`/`persistSlotA`, matching the browser.110111### MN-2: Acoustic pairing codec and pairing-UI entry points are stubs that throw at runtime112**Severity:** Minor (but functionally significant)113**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).114**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.115**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.116**Recommended fix:** Gate the pairing UI behind a feature flag until the native codec + transport land, or remove the entry points.117118### MN-3: Incoming sync entries are not validated/sanitized before persisting (latent — sync is stubbed)119**Severity:** Minor (would be Major once sync is live)120**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`.121**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).122**Impact:** Divergent/garbage vault state from a trusted-but-compromised peer. Requires an already-paired device, and sync is currently stubbed, so latent.123**Recommended fix:** Port `sanitizeRestoredEntry` checks and re-cap `passwordHistory` to `PASSWORD_HISTORY_MAX` on merge.124125### MN-4: No cross-platform byte-vector test pins the Noise handshake or key derivations126**Severity:** Minor127**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).128**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.129**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`).130131### MN-5: Hand-rolled JSON parser in pairing-frame decode can diverge from `JSON.parse`132**Severity:** Minor133**Location:** `sync/ChirpPairing.kt:530-628` (`decodePairingFrame`/`simpleJsonObject`); browser `src/shared/chirp-handshake.ts:67-85` (`JSON.parse`).134**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.135**Recommended fix:** Use `kotlinx.serialization`'s JSON parser (already a dependency).136137### MN-6: FLAG_SECURE not applied per-screen on several secret-bearing screens; absent entirely in debug builds138**Severity:** Minor139**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`.140**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.141**Recommended fix:** Keep FLAG_SECURE on in debug too, or add per-screen `DisposableEffect` flags to the secret-bearing screens as defense-in-depth.142143### MN-7: Autofill picker activity is not FLAG_SECURE144**Severity:** Minor145**Location:** `MainActivity.kt:54-55` (main window secure); `autofill/AutofillEntryPickerActivity.kt` (referenced at `PeachAutofillService.kt:84`) sets no FLAG_SECURE.146**Description:** The picker that lists/decrypts vault entries during autofill can be screenshotted/recorded.147**Recommended fix:** Set FLAG_SECURE on the picker activity window.148149### MN-8: Settings stored in cleartext SharedPreferences (metadata only)150**Severity:** Minor151**Location:** `vault/VaultStore.kt:114,140-144` (`peach_vault_store` / `peach_settings`).152**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.153**Recommended fix:** Optional — move settings into the encrypted payload or `EncryptedSharedPreferences` (the dependency is already present).154155### MN-9: Decoy session can surface recovery words / sync master key156**Severity:** Minor157**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`).158**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).159**Recommended fix:** Gate recovery-word display and sync-master-key access on `!decoyMode`.160161### MN-10: Minor robustness / dead-code items162**Severity:** Minor163- `ui/lock/BiometricUnlockActivity.kt:6-11` — dead stub that `finish()`es in `onCreate`; remove or document.164- `sync/MdnsPairingServer.kt:175,177` and `sync/PairingDiscoveryClient.kt:59,72` — mDNS registration/resolve failures swallowed as empty no-ops (silent failure on multicast-blocked networks; moot while transport stubbed).165- `PeachAppNavHost.kt:113-117` — `SettingsProtection`/`SettingsVault`/`SettingsSync`/`SettingsAppearance`/`SettingsPro` route to `SimpleStub` placeholders (`PlaceholderScreens.kt:118-120`).166- `feedback/FeedbackRepository.kt:36,58-61` — feedback endpoint is empty-string; `submit()` returns success without sending (intentional no-op, matches browser contract; blank message throws `message_empty`).167- REPLACE import has no empty-set guard the browser has (`ImportScreen.kt:204` vs `vault.ts:1480-1482`); currently UI-unreachable. Import is also per-entry writes (N commits + N version bumps) rather than one batch.168- Import MERGE does **no** deduplication (every row → new UUID); re-importing duplicates entries but **can never silently overwrite** an existing password — the audit's specific concern is a clean NO (`ImportScreen.kt:209-225`).169170---171172## Browser Parity Matrix173174| Feature | Browser | Android | Status | Notes |175|---|---|---|---|---|176| Entry core fields (id/title/username/email/password/url/additionalUrls/totp/notes/tags) | present | present, same names | ✅ | `VaultEntry.kt:9-26` |177| `modifiedAt` / `deletedAt` / `breach` / `passwordChangedAt` | present | present | ✅ | `VaultEntry.kt:20-27` |178| `passwordHistory` shape (`password`+`changedAt`) | present | present | ✅ | `VaultEntry.kt:23`, `PasswordHistoryItem.kt` |179| Card `network` | `network` | `cardNetwork` | ❌ | Name mismatch → lost on sync (MF-3) |180| Card `last4` | present | absent | ⚠ | Missing on Android (MF-3) |181| Card month/year/cvc/holder/nickname | present | present | ✅ | `VaultEntry.kt:32-37` |182| Passkey storage | nested `passkey` object | flat fields | ⚠ | Flat→nested mapping on export unverified (MN) |183| `type` enum values | login/note/passkey/card | login/note/card (String) | ✅ | Values align |184| `isWeak`/`isReused`/favicon | computed, not stored | computed, not stored | ✅ | Parity |185| TOTP algorithm / hash / period / digits / time source | RFC 6238, SHA1/256/512, 30s, 6 | identical | ✅ | `TOTPGenerator.kt`; RFC vectors pass (`TOTPGeneratorTest.kt:13-15`) |186| Autofill domain matching | strict eTLD+1 | loose substring on one label | ❌ | MF-7 |187| Autofill TOTP freshness | n/a | code generated at fill time | ✅ | `AutofillResponseBuilder.kt:94` |188| Autofill app/origin trust | registrable-domain bucket | none | ❌ | MF-7 |189| PeachScript container | PCHS binary + zstd + BLAKE3 + PUA | JSON + AES-GCM | ❌ | Incompatible (MF-2) |190| PeachScript AAD `peachscript_export_v1` | not present | present | ❌ | Browser uses no such AAD (MF-2) |191| 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` |192| Codex cross-platform paper restore | — | — | ⚠ | Structurally compatible; no cross-platform parity-byte fixture (MN-4 class) |193| Noise NK/XX patterns, suite, hash, nonce, AAD, key-split | as spec | byte-equivalent | ✅ | `NoiseNK.kt`, `NoiseXX.kt` |194| SAS derivation | first 4 bytes BE mod 1e6 pad6 | identical | ✅ | `SASDerivation.kt` (Android also user-gates it) |195| `computeVaultId` / sync-identity / signing-key derivation | BLAKE3 + HKDF-SHA256 | identical inputs | ✅ | `SyncIdentity.kt:19-49` |196| Chirp on-air payload (magic/offsets/CRC32) | defined | byte-identical | ✅ | `ChirpPayload.kt` |197| Recovery escrow IKM + wrap target | entropy / root key | BLAKE3(words) / whole payload | ❌ | Not cross-recoverable (MF-1) |198| Per-entry sync HKDF envelope | HKDF-SHA256, info `id+"sync-entry-v1"`, AAD `peach-sync-entry-v1` | identical | ✅ | `VaultSyncAdapter.kt:89-123` |199| Conflict resolution | LWW by `modifiedAt`, tombstone-guarded | identical | ✅ | `VaultRepository.kt:474-491` |200| Acoustic pairing / sync end-to-end | implemented | codec + UI stubbed (throws) | ❌ | Non-functional at runtime (MN-2) |201202---203204## Crypto Correctness Matrix205206| Operation | Parameter | Browser Value | Android Value | Match? |207|---|---|---|---|---|208| Vault KDF | Algorithm | Argon2id (`argon2.ts:12`) | Argon2id (`VaultCrypto.kt:38`) | ✅ |209| Vault KDF | Memory | 65536 KiB | 65536 KiB (`VaultCrypto.kt:11`) | ✅ |210| Vault KDF | Iterations | 3 | 3 (`VaultCrypto.kt:12`) | ✅ |211| Vault KDF | Parallelism | 4 | 4 (`VaultCrypto.kt:13`) | ✅ |212| Vault KDF | Output / salt length | 32 / 16 | 32 / 16 (`VaultCrypto.kt:14-15`) | ✅ |213| Vault KDF | Password encoding | UTF-8 (hash-wasm) | `char[]`/UTF-16 (`VaultCrypto.kt:46`) | ⚠ BMP-only parity; non-BMP diverges (MF-4) |214| Vault cipher | Algorithm / nonce / tag | AES-256-GCM / 12B CSPRNG / 128b | AES-256-GCM / 12B `SecureRandom` / 128b (`VaultCrypto.kt:51-59`) | ✅ |215| Vault cipher | At-rest layout | nonce/ct/tag fields | `nonce‖ct‖tag` base64 | ⚠ equivalent bytes, different serialization (not a defect) |216| Vault AAD | slot A | `slot_a_v1` | `slot_a_v1` (`VaultCrypto.kt:24`) | ✅ |217| Vault AAD | slot B | `slot_b_v1` (`vault.ts:1013`) | decoy uses `slot_a_v1` (`VaultRepository.kt:182`) | ❌ architectural divergence (MF-6) |218| Vault AAD | recovery escrow | `[1]` byte, wraps root key | `recovery_escrow_v1`, wraps payload | ❌ different AAD + wrapped object (MF-1) |219| Plaintext padding | block / duress ceiling | 1024 / 262144 | none / none | ❌ (MN-1) |220| Key zeroing | derived + root key on lock | zeroed | zeroed (`VaultRepository.kt:232-244`) | ✅ |221| Recovery KDF | algorithm / info / salt | HKDF-SHA256 / `peach-recovery-v1` / 16B | identical (`RecoveryWords.kt:86-87`) | ✅ (IKM differs — MF-1) |222| 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`) | ✅ |223| Sync identity | X25519 / signing HKDF salts | `peach-sync-identity-v1` / `peach-sync-signing-v1` | identical (`SyncIdentity.kt:19-20`) | ✅ |224| vault_id | BLAKE3(master‖"peach-vault-id") 64 hex | — | identical (`SyncIdentity.kt:32-40`) | ✅ |225| Noise | suite / hash / nonce LE@offset4 / AAD=`h` | ChaCha20-Poly1305 / BLAKE2s / yes / yes | identical (`NoiseNK.kt`, `NoiseXX.kt`) | ✅ |226| Pairing SAS | first 4 bytes BE mod 1e6 pad6 | — | identical (`SASDerivation.kt`) | ✅ |227| Biometric escrow | HW key, auth-required, invalidate-on-enroll | n/a (platform) | yes (`BiometricKeyStore.kt:107-115`) | ✅ |228| Duress hash | independent Argon2id salt, hash-only, constant-time compare | n/a | yes (`DuressConfig.kt:85-129`) | ✅ |229230---231232## Test Coverage Summary23323491 `@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.235236| Module | Tests | Coverage gaps |237|---|---|---|238| vault/VaultRepository | 23 | Solid: CRUD, LWW merge, duress decoy, rollback, retention. |239| vault/RecoveryEscrow | 6 | Seal/recover round-trip incl. all-"abandon" phrase (genuine regression test, `RecoveryEscrowTest.kt:117-128`). |240| vault/TOTPGenerator | 4 | Canonical RFC 6238 vectors. |241| vault/PasswordGenerator | 4 | OK. |242| **vault/VaultCrypto** | **0** | **No direct test** — Argon2id params, AES-GCM, AAD-mismatch rejection, nonce uniqueness, `zero()` exercised only indirectly. No KATs. |243| **vault/VaultHealth** | **0** | Breach/reuse/aged/weak scoring untested. |244| **ui/lock/BiometricKeyStore** | **0** | Wrap/unwrap + invalidation path untested (needs instrumented test). |245| **vault/DuressConfig** | **0** | Duress verify/timing untested at unit level. |246| codex/CodexCodec | 4 | Round-trip; RS/Aztec error-correction edges not separately tested; no cross-platform parity-byte fixture. |247| autofill/AutofillMatch | 3 | Ranking only; no origin-trust/anti-phishing test. |248| sync/* (5 files) | 24 | Protocol shape with fakes; real ggwave codec stubbed; no cross-platform byte vectors. |249| ui/security/PasswordStrength | 11 | Good. |250| ui/vault/ExportRoundTrip, ImportParsers | 13 | Same-platform round-trip + parsers. |251252Largest gaps: the core crypto primitive (`VaultCrypto`), `VaultHealth`, `BiometricKeyStore`, and duress verification have **no** direct tests; no cross-platform interop vectors exist anywhere.253254---255256## Dependency Inventory257258From `app/build.gradle` (Compose via BOM `2024.06.00`; no `libs.versions.toml`). All versions **pinned** (no floating ranges).259260| Library | Version | Purpose | Sensitive? | Pinned? |261|---|---|---|---|---|262| org.bouncycastle:bcprov-jdk18on | 1.78.1 | Argon2id KDF, X25519/Ed25519, ChaCha20-Poly1305 | **Yes** | Yes |263| com.google.crypto.tink:tink-android | 1.13.0 | Crypto primitives (present; `VaultCrypto` uses JCE directly — possibly unused) | **Yes** | Yes |264| androidx.security:security-crypto | 1.1.0-**alpha06** | EncryptedSharedPreferences | **Yes** | Yes (alpha — churn risk) |265| androidx.biometric:biometric | 1.1.0 | BiometricPrompt | **Yes** | Yes |266| androidx.credentials:credentials (+ play-services-auth) | 1.5.0 | Passkey/credential provider | **Yes** | Yes |267| com.squareup.okhttp3:okhttp | 4.12.0 | Feedback HTTP (endpoint empty) | Indirect | Yes |268| com.google.zxing:core | 3.5.3 | Codex/Aztec encode-decode | Indirect | Yes |269| androidx.camera:camera-* | 1.3.4 | Codex restore camera | Indirect | Yes |270| org.jetbrains.kotlinx:kotlinx-serialization-json | 1.7.1 | Vault payload / settings codec | Indirect | Yes |271| org.jetbrains.kotlinx:kotlinx-coroutines-android | 1.8.1 | Concurrency | No | Yes |272| com.google.dagger:hilt-android (+compiler) | 2.51.1 | DI | No | Yes |273| androidx.glance:glance-appwidget / -material3 | 1.1.0 | Widgets | Indirect | Yes |274| androidx.autofill:autofill | 1.1.0 | Inline autofill UI | Indirect | Yes |275| androidx.navigation:navigation-compose | 2.8.0 | Navigation | No | Yes |276| androidx.lifecycle:lifecycle-* | 2.8.4 | Lifecycle/VM | No | Yes |277| androidx.activity:activity-compose | 1.9.2 | Compose host | No | Yes |278| androidx.appcompat:appcompat / core-ktx / core-splashscreen | 1.7.0 / 1.13.1 / 1.0.1 | Compat/core | No | Yes |279| com.google.android.material:material | 1.12.0 | Material views | No | Yes |280| Compose (material3/ui/foundation/icons) | BOM 2024.06.00 | UI | No | Yes |281282Notes: `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.283284---285286## Clean Findings (explicitly verified correct)287288**Cryptographic primitives**289- Argon2id parameters identical across platforms: m=65536 KiB, t=3, p=4, 32-byte output, 16-byte CSPRNG salt (`VaultCrypto.kt:11-15,37-48`).290- AES-256-GCM with fresh 96-bit `SecureRandom` nonce per encrypt and 128-bit tag, on both vault and sync envelopes (`VaultCrypto.kt:51`, `VaultSyncAdapter.kt:98`).291- Distinct AAD constants per blob type (`AAD_SLOT_A`/`AAD_SLOT_B`/`AAD_ROOT_KEY`/`AAD_RECOVERY`, `VaultCrypto.kt:24-32`); a recovery-escrow blob cannot be presented as slot A (GCM-enforced).292- Slot-A AAD (`slot_a_v1`) byte-identical to the browser.293- Per-entry sync HKDF envelope is byte-for-byte parity (KDF, empty salt, info, 32-byte key, AAD, per-entry scope) **and** id-bound — a swapped envelope is rejected (`VaultSyncAdapter.kt:69-71`).294- Sync-identity HKDF salts/info, `computeVaultId` BLAKE3 derivation, and SAS derivation all match exactly.295- Noise NK and XX message patterns, cipher suite (ChaCha20-Poly1305), hash (BLAKE2s), HKDF (SHA-256), nonce encoding, AAD usage, and transport-key/role split are byte-equivalent to the browser; ephemeral keys are fresh per handshake (no static-key reuse). The C7 transport uses the real `noiseNKResponder`/`noiseXXInitiator`, not stubs. Android additionally wires the SAS into a user-confirmation gate (stronger than the browser, where SAS is documented as not-yet-surfaced).296- Chirp on-air payload (magic `PEAC`, version, device-type range, 32B pubkey, 16B nonce, CRC32-IEEE) is byte-identical — cross-platform decodable.297- Codex: GF(2⁸) poly 0x11d with Vandermonde-made-systematic matrix, RS shard counts, zlib deflate (mutually inflatable), ZXing Aztec at 25% ECC, 6×6=36 codes/page, and the envelope JSON schema all match.298299**Key-material lifecycle**300- `lock()` zeroes both `derivedKey` and `rootKey` and nulls them (`VaultRepository.kt:232-244`) — the in-memory key *is* cleared when `lock()` is called (the problem is that it is rarely called — see CF-1/CF-2).301- Sync/codex/recovery/change-password paths zero master/material/key copies in `finally` blocks; wrong-password paths zero the trial key (`VaultRepository.kt:142,566-573,611,650-651`).302- `currentVaultKey()` returns a copy (`copyOf()`, `:263`), zeroed at its call sites.303- Biometric escrow: hardware-backed AndroidKeyStore AES-256-GCM key, `setUserAuthenticationRequired(true)` with `AUTH_BIOMETRIC_STRONG`, `setInvalidatedByBiometricEnrollment(true)`; on `KeyPermanentlyInvalidatedException` the wrapped blob is cleared before password fallback (`BiometricKeyStore.kt:62-115`). Wrapped blob + IV in plain prefs is safe (ciphertext under a key that never leaves secure hardware).304- No secret-bearing logging anywhere: the only `Log` call in the tree is a clock-skew timestamp (`ChirpSyncCoordinator.kt:275`); no `println`/`printStackTrace`. No empty catch blocks in `app/src/main`.305- Vault slots in plain `SharedPreferences` is acceptable and matches the browser model — the blob is already AES-GCM-sealed; only the salt is cleartext. License and paired-device stores correctly use `EncryptedSharedPreferences`.306307**Data integrity**308- Soft-delete is the only normal delete path; hard delete (`purgeEntry`/`purgeDeletedEntries`) is reachable only from Trash behind a confirm overlay (`TrashScreen.kt:148-163`).309- Trash retention defaults to "Never" (`null`), **not** 30 days, matching the deliberate CONTINUITY decision; tested both ways (`VaultSettings.kt:14`, `VaultRepositoryTest.kt:239-249`).310- Panic/`destroyVault` is guarded by 3 confirmations + a typed "DESTROY", wipes both slots, and preserves the Pro license (separate prefs file). Duress DESTROY and Panic converge on the same `destroyVault()` (`PanicScreen.kt`, `VaultRepository.kt:302-306`).311- Password history captured **before** the new password is applied, newest-first, age-stamped, deduped on identical resave, capped at 50 (`VaultRepository.kt:340-355`); survives sync as a serialized field.312- Vault write keeps the **real** vault readable on crash: slot A is committed first and fully and decrypts independently of slot B; `persistOrRollback` restores in-memory state on disk-write failure (`VaultRepository.kt:751-780`). (The two-slot write is non-atomic, but this matches the browser and only affects the decoy size-match — see MN-1.)313- Schema migration is forward-compatible: `ignoreUnknownKeys = true`, decrypt does not gate on `version`, `recoveryEscrow` and `rootKeyB64` are nullable/back-filled, so a v2.0.x vault opens on v2.1.x+ and lazily gains a root key (`VaultStore.kt:73`, `VaultRepository.kt:716-723`).314- Import never silently overwrites an existing entry's password — every imported row gets a fresh UUID (`ImportScreen.kt:212-224`).315316**Concurrency / network / surface**317- `VaultRepository` serializes all mutating + unlock ops under a `Mutex` (`VaultRepository.kt:51`).318- QS tile checks `isUnlocked` before copying; locked → launches app, never copies (`PeachTileService.kt:36-49`).319- All exported manifest components are permission-gated: autofill (`BIND_AUTOFILL_SERVICE`), credential provider (`BIND_CREDENTIAL_PROVIDER_SERVICE`), QS tile (`BIND_QUICK_SETTINGS_TILE`); `BiometricUnlockActivity` and `AutofillEntryPickerActivity` are `exported="false"`; widget receivers use the standard system-only `APPWIDGET_UPDATE` pattern. `allowBackup="false"` + `fullBackupContent="false"`.320- **No HIBP/online breach check exists** — `VaultHealth.isBreach` reads a pre-existing `entry.breach` field; SHA-256 is used only for *local* reuse detection; the only network call is the (empty) feedback endpoint. So the brief's k-anonymity concern is moot: **no password hash or plaintext is ever sent anywhere.** This is good for privacy, but note it also means the "breach" indicator is only as fresh as whatever populated the field (likely set during browser sync) — flagged as a feature-parity observation, not a defect.321322---323324## Residual Uncertainty (flagged honestly)325326- **MF-4 (non-BMP passwords)** was reasoned from BouncyCastle's documented `char[]`→UTF-8 behavior, not a runtime KAT. A shared test vector would settle it definitively; until then treat as Major-probable, not confirmed.327- **MF-5 timing magnitudes** were derived from control flow (sequential two-derivation path), not measured. The structural finding is high-confidence; the exploitability depends on observable timing resolution.328- **X25519/Ed25519 public-key determinism** across stablelib (browser) vs BouncyCastle (Android) is byte-identical by spec but is not pinned by any test (subsumed by MN-4).329- **Passkey flat→nested mapping** on cross-platform export was not exercised; Android stores flat passkey fields while the browser's canonical shape is a nested `passkey` object — confirm before relying on cross-platform passkey portability.330- The biometric key copy is **not** zeroed if the prompt is dismissed without firing any callback (e.g. process death mid-prompt) — minor, GC-reachable only, unverified whether the platform guarantees a callback.