# Peach Browser Extension — Independent Code Review **Reviewer:** Claude Fable 5 **Date:** 2026-06-12 **Codebase:** peach-browser (v4.0.0) **Method:** Complete static read of all security-relevant source files ## Overview Peach is a local-first, zero-knowledge browser password manager (Manifest V3, Chrome + Firefox, plus a native Android port that is out of scope here). The vault is encrypted on-device with AES-256-GCM under an Argon2id-derived key; no plaintext ever leaves the machine. Beyond storage it implements browser autofill, WebAuthn/passkey provisioning, a Pro-gated LAN device-sync feature (audio "chirp" pairing → Noise-encrypted WebSocket), a paper backup system ("Codex": Reed-Solomon-coded Aztec matrices rendered to PDF), and a duress/decoy two-slot vault. The dominant impression from reading the code is that it has been through serious, repeated security review and that the fixes were integrated thoughtfully rather than bolted on. Nearly every security-relevant decision carries an inline rationale tied to a specific finding ID (audit `HV-*`/`MV-*`/ `LV-*`, technical-report `PCH-01-*`). Cryptographic primitives are standard and correctly parameterised. The extension's trust boundaries — content script vs. background, page origin vs. vault — are enforced with browser-attested signals rather than payload-supplied ones. Error handling consistently fails closed for secrets and fails gracefully for availability. The findings below are, with one exception, Informational or Low. I found no Critical or High issues. This is consistent with a codebase that has already absorbed two prior review rounds; what remains is mostly defense-in-depth hardening, documented residual risks, and a small number of asymmetries worth tightening. ## Scope Read completely: - **Cryptographic layer:** `src/crypto/argon2.ts`, `src/crypto/zstd.ts`, `src/crypto/totp.ts`, `src/crypto/peachScript.ts`, `src/crypto/peachPayload.ts`, `src/background/aes.ts` (the canonical AES module — there is no `src/crypto/aes.ts`; see note in Scope deviations), `src/background/self-test.ts`, `src/shared/noise-xx.ts`, `src/shared/noise-js.ts` (the Noise NK implementation — there is no `src/shared/noise-nk.ts`), `src/shared/sync-identity.ts`, `src/shared/recovery-words.ts`. - **Vault core:** `src/background/vault.ts` (entire file). - **Extension boundary:** `src/background/messages.ts`, `src/background/entry.ts`, `src/background/clipboard.ts`, `src/content/index.ts`, `src/content/detect.ts`, `src/content/save.ts`, and the relevant interfaces of `src/content/inject.ts`. - **Sync + pairing:** `src/background/chirp-sync-coordinator.ts`, `src/background/chirp-sync-vault-adapter.ts`, `src/shared/chirp-sync-transport.ts`, `src/background/chirp-sync-runner.ts`, `src/background/paired-devices.ts`, `src/shared/chirp-payload.ts`, `src/shared/chirp-handshake.ts`, `src/shared/device-manifest.ts`, `src/shared/sync-limits.ts`. - **Backup + recovery:** `src/popup/codex/generate.ts`, `restore.ts`, `pdf-import.ts`, `erasure.ts`, `src/shared/import.ts`, `src/shared/backup.ts`, `src/shared/import-limits.ts` (the `erasure.ts` referenced in the brief as `src/shared/erasure.ts` is at `src/popup/codex/erasure.ts`). - **Configuration:** `manifest.json`, `manifest.firefox.json`, `package.json`. **Scope deviations:** three brief paths do not exist as written. AES-GCM lives in `src/background/aes.ts` and inline in `peachPayload.ts` (a header comment in `aes.ts:4` records that a duplicate `src/crypto/aes.ts` was removed); the Noise NK pattern is in `noise-js.ts`; the Codex erasure module is in `src/popup/codex/`. All three were located and read. ## Cryptographic Implementation **Argon2id (master-password KDF)** — `argon2.ts`, also inline in `peachPayload.ts` and as parameters in `codex-types`. m = 65536 KiB (64 MiB), t = 3, p = 4, 32-byte output, via `hash-wasm`. This meets the OWASP password-manager tier and exceeds it on parallelism. The password is NFC-normalised before derivation (`argon2.ts:32`) so the same passphrase typed under different Unicode normalisation forms (macOS NFD dead-keys vs. mobile NFC) derives the same key and stays cross-platform-compatible; a `deriveKeyRaw` legacy path (`argon2.ts:40-45`) exists solely to open vaults sealed before that fix. The salt is 16 bytes from `crypto.getRandomValues`, fresh at creation and on every password change (`argon2.ts:50-52`). Appropriate and correct. **AES-256-GCM (vault-at-rest and export envelope)** — `aes.ts`, `peachPayload.ts:997-1051`. 96-bit random nonce per encryption (`aes.ts:56`), 128-bit tag (the maximum, explicitly pinned and commented as non-reducible at `aes.ts:51-55`). Keys are imported non-extractable, used once, then zeroed by callers (`vault.ts` zeroes `derivedKey`/`rootKey` on lock and after key swaps). The AAD carries a per-slot domain tag (`"slot_a_v1"`, `"slot_b_v1"`, `"pw_root_v1"`) with a legacy single-byte-AAD fallback on decrypt so pre-hardening vaults still open (`aes.ts:104-129`). The construction is textbook and the nonce-reuse hazard is correctly avoided with fresh randomness rather than a counter. Note that GCM's ~2³² safe-message bound per key is irrelevant here — a vault is re-encrypted as a single message per write. **BLAKE3 (payload checksum, vault id, card fingerprints)** — via `hash-wasm`. In `peachPayload.ts` a 4-byte truncated BLAKE3 checksum sits *inside* the encrypted envelope (`blake3Truncated`, `1053-1061`) purely to fail fast on compressed-payload corruption before decompression — authenticity is GCM's job, not the checksum's, and the code says so (`peachPayload.ts:27-29`). The checksum comparison is constant-time (`1063-1068`). `computeVaultId` is `BLAKE3(masterKey || "peach-vault-id")` (`sync-identity.ts:68-75`). **zstd / pako (compression)** — `zstd.ts`, `generate.ts`. Compression is not a security primitive but is a decompression-bomb surface; it is bounded (see PCH-02-008 context and `import-limits.ts`). **X25519 + ChaCha20-Poly1305 + BLAKE2s (Noise XX / NK)** — `noise-xx.ts`, `noise-js.ts`. Both patterns (`Noise_XX_25519_ChaChaPoly_BLAKE2s` for mutual-auth WebSocket sync, `Noise_NK_…` for post-chirp pairing) derive the protocol-name init hash and the HKDF chain over BLAKE2s, matching the named pattern (a prior round had used SHA-256 here; `PCH-01-002` corrected it). Because `@stablelib`'s HKDF reuses one HMAC across output blocks and BLAKE2s's `restoreState` leaves the `finished` flag set, the code runs HKDF-Expand manually with a fresh HMAC-BLAKE2s per block (`noise-xx.ts:46-58`), documented as byte-identical to the Android BouncyCastle side. Transport keys are split correctly and direction-swapped on the responder; nonces are per-frame counters from 0. The XX initiator pins the responder's static key *before* transmitting its own static in message 3 (`noise-xx.ts:78-133`, `PCH-01-003`), so a probed-but-wrong LAN host never learns the prober's long-term identity. **Ed25519 (device-manifest signing)** — `sync-identity.ts:89-161`, `device-manifest.ts`. Deterministically derived from the root key under a distinct HKDF salt (`"peach-sync-signing-v1"`) so the signing key is single-purpose and survives reinstall. Manifests are signed over a canonical JSON encoding (sorted keys, no insignificant whitespace, non-finite numbers rejected — `device-manifest.ts:183-204`) and verified with strict per-device field validation and a freshness window. The private key is imported as extractable, which the inline comment justifies and `PCH-01-022` documents: the 32-byte seed is already in memory, so a JWK export to recover the public key adds no attack surface. **HKDF-SHA256 (sync identity, recovery KEK, per-entry sync keys)** — `sync-identity.ts`, `recovery-words.ts:130-155`, `chirp-sync-vault-adapter.ts:355-369`. Each use is domain-separated. The per-entry sync envelope key is `HKDF(rootKey, info = entryId + "\x00" + "sync-entry-v1")`; the null-byte separator makes the (id, suffix) boundary unambiguous (`PCH-01-005`), and the adapter rejects any decrypted envelope whose `entry.id` does not equal the map key it arrived under, so a ciphertext minted for one slot cannot be replayed into another (`chirp-sync-vault-adapter.ts:153-155`). **PBKDF2-SHA256 (legacy export, self-test)** — `backup.ts:146-173` derives the key for the *legacy* encrypted-JSON import format with PBKDF2-SHA256 at 250,000 iterations. `self-test.ts` uses PBKDF2 at 1,000 iterations deliberately, only as a fast Known-Answer-Test vehicle on the same AES-GCM path (it pins the expected ciphertext, `PCH-01-020`, so a self-consistent-but-broken WebCrypto fails). See PCH-02-005. **BIP39 recovery words** — `recovery-words.ts`. A 12-word phrase encodes a 128-bit prefix of the 256-bit root key plus a 4-bit SHA-256 checksum, exactly per BIP39. The code is explicit and repeated about the security model (`recovery-words.ts:20-26`, `116-120`, `vault.ts:282-296`): recovery is 128-bit, the words wrap the *full* root key via an HKDF-derived KEK from a *prefix* of that key, and a future 256-bit-recovery scheme would need independent entropy. This is a correct and unusually well-documented construction. **TOTP** — `totp.ts` delegates to the `otpauth` library; the entry's stored algorithm/digits/period are threaded through (`PCH-01-019`) with RFC defaults (SHA-1/6/30) when absent. The raw base32 seed never crosses to a content script (see Extension boundary); the background generates the current code and ships only that plus seconds-remaining. **PeachScript** — `peachScript.ts` is a trivial byte → Unicode-PUA codec (U+E000…U+E0FF) for the device-to-device export string. It is presentation, not cryptography — the payload it carries is already an AES-GCM envelope — and the file says so. Decode validates every code point is in range. ## Code Review Findings ### Finding PCH-02-001: Update-entry patch path bypasses the canary/tags/passkey sanitizers used on add and restore **Severity:** Low **Location:** `src/background/vault.ts:2317-2417` (`sanitizeEntryPatch`), `src/background/messages.ts:478-501` (`UPDATE_ENTRY` handler) **Description:** Entries entering the vault via add (`sanitizeEntryDraft`, `vault.ts:2256-2315`), restore, or sync (`sanitizeRestoredEntry`, `vault.ts:1603-1650`) are run through field whitelists that rebuild `passkey` and `canary` sub-objects field-by-field and bound `tags` (`sanitizePasskey`/`sanitizeCanary`/`sanitizeTags`). The `UPDATE_ENTRY` path does not: `sanitizeEntryPatch` copies `patch.canary` (`2375-2377`), `patch.tags` (`2386-2388`), and `patch.passkey` (`2379-2384`) through verbatim. `UPDATE_ENTRY` is also not in `DESTRUCTIVE_MESSAGE_TYPES` (`messages.ts:141-146`), so unlike `DESTROY_VAULT`/`RESTORE_*` it is not gated to extension-page senders — and it is legitimately sent from content-script context (the inline editor and the canary-ping bump at `content/index.ts:336-348` and `content/save.ts:57-61`), so it cannot simply be gated. The net effect is that a caller able to send an `UPDATE_ENTRY` for a known entry id could attach an arbitrary `passkey` sub-object, set unbounded `tags`, or replace a `canary` object on an entry that already has one without the field rebuild the other paths enforce. **Notes:** Exploitability is genuinely low: web pages cannot call `chrome.runtime.sendMessage` (no `externally_connectable`), so this requires compromising the content script itself, and Peach's injected UI mounts in *closed* shadow roots (`content/index.ts:164`, `inject.ts` `attachShadow({mode:"closed"})` throughout), which removes the obvious in-page foothold. Adding a *new* canary is separately Pro-gated (`messages.ts:491-498`) and the canary fire path re-validates the webhook with the full SSRF guard (`isAllowedCanaryWebhookUrl`, `messages.ts:1568-1600`). The cleanest fix is to route `sanitizeEntryPatch`'s `canary`/`tags`/`passkey` branches through the same sanitizers the other paths use; the change is local and does not alter the trust boundary. ### Finding PCH-02-002: Noise XX message 1 omits the canonical empty-payload hash step **Severity:** Informational **Location:** `src/shared/noise-xx.ts:93-97` (initiator), `184-191` (responder) **Description:** In a canonical Noise handshake, every message — including the first `-> e` — ends by processing its payload through `EncryptAndHash`, which for an empty payload with no symmetric key yet still performs `MixHash(empty)`. The Peach XX implementation processes the ephemeral (`mixHash(h, e.publicKey)`) and sends it, but does not perform the empty-payload `MixHash` for message 1. The NK implementation *does* hash its message payloads (`noise-js.ts:90-91`, `112-116`), because by those points a key exists and a real AEAD tag is produced. The consequence is that Peach's XX transcript hash diverges by one `MixHash(empty)` from what a stock Noise library (e.g. `snow`) would compute, so a third-party Noise stack would not interoperate with Peach's XX handshake. **Notes:** This is not a confidentiality or authenticity weakness: both Peach peers (and the Android hand-port) compute `h` identically, the handshake hash still binds the full transcript that both sides observe, and the transport keys derive from the chaining key, not `h`. The only practical effect is interop with external implementations, which Peach does not rely on (the doc comment at `noise-js.ts:11-14` notes interop is held "by porting discipline, not by a shared library"). The header comment in `noise-js.ts:12` that "a stock `snow` stack would interoperate" holds for NK but is not strictly true for XX; worth a one-line correction so the claim is not over-read. ### Finding PCH-02-003: Pairing authentication rests on the user-confirmed SAS and an unverifiable peer-side gate **Severity:** Informational **Location:** `src/shared/chirp-handshake.ts:125-216` **Description:** The post-chirp pairing handshake uses Noise NK, which authenticates only the *responder* (it proves the initiator encrypted to the responder's chirp-broadcast ephemeral). It does not cryptographically authenticate the initiator, and there is no proof-of-possession over the `long_term_public_key_hex` a peer advertises in its `PAIR_IDENTITY` frame. The compensating control is a 6-digit Short Authentication String derived from the shared Noise handshake hash (`derivePairingSAS`, `163-174`), which the extension pairing flow requires the user to confirm matches the peer's before any device is persisted (`gateOnSAS`, referenced as living in `src/popup/chirp-pair.ts`). **Notes:** This is called out thoroughly and honestly in the source itself (`chirp-handshake.ts:125-154`) — one of the better in-code threat statements in the codebase. Residual risk is the standard SAS bound: a MITM/relay survives with probability ~1e-6 per attempt *and only if the user does not actually compare the codes*. The open item the code names — that the Android/native peer must enforce the identical SAS gate before persisting — cannot be verified from this repository and is the more important dependency: if the app auto-confirms, the channel is only as strong as its weakest end. Recommend confirming the app-side gate against the Android source, and (as the comment suggests) considering a future proof-of-possession round via the already-present `performInitiatorAuth`/`performResponderAuth` so authentication becomes structural rather than dependent on user diligence. ### Finding PCH-02-004: Crypto-adjacent dependencies are caret-ranged while core crypto deps are exact-pinned **Severity:** Informational **Location:** `package.json:31-52` **Description:** The core vault/crypto/transport dependencies are exact-pinned (no caret): `@stablelib/*` 2.0.1, `hash-wasm` 4.12.0, `@bokuweb/zstd-wasm` 0.0.27, `@subspace/reed-solomon-erasure.wasm` 0.2.5, `tldts` 7.0.28, `pdfjs-dist` 5.7.284, `@zxcvbn-ts/*` (this is the `LV-9` exact-pin discipline). Several dependencies on or adjacent to a security path remain caret-ranged: `otpauth` `^9.5.0` (TOTP generation), `pako` `^2.1.0` (Codex decompression on the import side), `@zxing/library` `^0.23.0` and `bwip-js` `^4.10.1` (Aztec decode/encode), `pdf-lib` `^1.17.1`, `papaparse` `^5.5.3`. **Notes:** With a committed lockfile (`bun.lock`, `packageManager` pinned to `bun@1.3.14`, frozen-lockfile CI per `LV-6`) the practical exposure is limited to fresh resolves, but extending the exact-pin discipline to `otpauth` and `pako` in particular would make the supply-chain posture uniform with the rest of the crypto surface. ### Finding PCH-02-005: Legacy encrypted-JSON import uses PBKDF2-SHA256 rather than Argon2id **Severity:** Informational **Location:** `src/shared/backup.ts:146-173` **Description:** The legacy `.json` export/import envelope derives its key with PBKDF2-SHA256 at 250,000 iterations, whereas every current path uses Argon2id (m=64 MiB, t=3, p=4). PBKDF2 with no memory-hardness is materially weaker against GPU/ASIC attackers than Argon2id at equivalent wall-clock cost. **Notes:** Scope is bounded and the decision is reasonable: this format's UI export was removed in v2.5.0 (`backup.ts:5-12`), the helper is import-only (plus a test fixture generator), and current exports use PeachScript / the QR matrix encoder backed by Argon2id. No action is strictly required; flagged so the KDF asymmetry is visible to anyone who still relies on old `.json` backups. ### Finding PCH-02-006: vault_id is a stable, LAN-broadcast, cross-device-linkable identifier **Severity:** Informational **Location:** `src/shared/sync-identity.ts:62-75` **Description:** `computeVaultId` returns `BLAKE3(masterKey || "peach-vault-id")`, a deterministic 64-hex value that, per its own comment, is carried in the mDNS TXT record and the first sync handshake (`SYNC_HELLO`). It is therefore a stable identifier visible to any device on the same LAN, and it is identical across all of a user's paired devices, making them linkable to a passive local observer for as long as the master key (root key) is unchanged. **Notes:** This is inherent to the discovery design — peers must recognise "same vault" before exchanging entries — and it is one-way (the master key is not recoverable from it). It is a minor privacy property worth stating, not a confidentiality flaw. "Rotate sync identity" (`vault.ts:2771-2814`) changes it by re-keying the root key. ### Finding PCH-02-007: HIBP breach check omits the Add-Padding response-padding header **Severity:** Informational **Location:** `src/background/vault.ts:216-238` **Description:** `checkBreach` queries the Have I Been Pwned k-anonymity range API with the SHA-1 prefix but does not send `Add-Padding: true`. HIBP supports that header to pad the response to a fixed bucket count; without it, the number of suffixes returned for a given prefix is a (weak) side channel that a network-position observer could use to narrow which prefix was queried. **Notes:** Impact is small — only the 5-hex-char prefix is ever sent, never the password or full hash, and breach monitoring is an opt-out convenience feature. Adding the padding header is a one-line hardening that costs nothing. ### Finding PCH-02-008: peachPayload BinaryReader performs no explicit bounds checking **Severity:** Informational **Location:** `src/crypto/peachPayload.ts:940-969` (`BinaryReader`), consumed in `decodeEntry` `638-813` **Description:** The binary entry decoder reads `u8`/`u16`/`u32`/length-prefixed strings without validating that the cursor stays within the buffer. Past-end reads yield `undefined`/`NaN` (for `u8`) or throw `RangeError` (DataView), and length-prefixed reads clamp via `subarray`. The decoded bytes are normally inside an AES-GCM-authenticated envelope, but the same decoder runs on imported `.peach` payloads where an attacker who also knows the passphrase controls the plaintext. **Notes:** This is a robustness observation, not a memory-safety issue — the runtime is JavaScript, so the worst case is a thrown exception (caught by the import UI) or a malformed entry that is later rejected by `sanitizeRestoredEntry` at the vault boundary. The declared section-length and cumulative-size caps (`peachPayload.ts:301-311`, `MV-4`) already bound total work before this point. Explicit cursor bounds checks would make the decoder's failure modes uniform and self-evidently safe. ### Finding PCH-02-009: X25519 shared-secret computations do not reject low-order/zero outputs **Severity:** Informational **Location:** `src/shared/noise-xx.ts:108,119,140,196,204,231`, `src/shared/noise-js.ts:84,106,175,248,263,362` **Description:** All `sharedKey(secret, peerPublic)` calls use `@stablelib/x25519` with the default `rejectZeroSharedKey = false`, so a peer public key chosen as a low-order point (yielding an all-zero shared secret) is not rejected at the DH step. **Notes:** The practical impact is neutralised by the surrounding protocol: NK pre-mixes the known responder static key, the XX responder static is pinned against the paired-device registry before and after the handshake (`chirp-sync-transport.ts:130-154`), and pairing is additionally gated by the human SAS comparison. An attacker forcing a zero shared secret would still fail the static-key pin and/or the SAS. Passing `rejectZero = true` would make the DH contributory-by-construction and remove the need to reason about this at all. ### Finding PCH-02-010: Clipboard auto-clear is unconditional and does not compare against the parked hash **Severity:** Informational **Location:** `src/background/clipboard.ts:98-117,124-178` **Description:** On copy, a SHA-256 of the copied value is parked in `storage.session` purely as a "a clear is owed" flag (`98-103`); the auto-clear routine (`clearClipboardNow`) then writes an empty string to the clipboard unconditionally when the timer/alarm fires, without reading the current clipboard to confirm it still holds the Peach-copied value. If the user copies something else within the clear window, that later value is wiped too. **Notes:** This is a deliberate trade-off in favour of the secret-must-not-persist invariant (the alarm path is eviction-proof, `HV-2`, and clearing an already-changed clipboard is harmless to confidentiality), and a content/popup-side write with a real user gesture is preferred when available (`alreadyWritten`). It is a minor UX surprise rather than a security issue; noted for completeness because the parked hash is computed but never used for a match check, which could read as dead bookkeeping to a future maintainer. ## Positive Observations The codebase's strengths are specific and worth recording at the same resolution as the findings. - **Two-slot duress with a real plausible-deniability story.** Both vault slots always exist; the decoy/noise slot is filled with CSPRNG bytes that are indistinguishable at rest from a GCM ciphertext (`vault.ts:704-716`), and when duress is configured both slots are padded to a fixed 256 KiB ceiling so their lengths cannot reveal that a decoy exists (`vault.ts:62-72,1038,2213`). The duress bit lives only inside slot A's encrypted payload — no external flag (`vault.ts:44-45,1051-1053`). - **Constant-time unlock.** `attemptUnlockWithDerive` loads both slots, derives both keys, and attempts both decrypts in parallel and unconditionally (`vault.ts:1124-1154`), so wall-clock time does not distinguish a real-vault unlock from a decoy unlock (`DUR-002`). - **Origin enforcement uses browser-attested signals only.** `attestedSenderOrigin` reads `sender.origin`/`sender.url` (which a page cannot forge, since pages reach the background only via the content-script relay) and never payload-supplied origins (`messages.ts:217-231`). `GET_ENTRY_FOR_FILL` re-validates the entry's domain set against the requesting tab's attested origin and refuses cross-domain fills unless the user explicitly summoned them (`messages.ts:698-727`, `MV-1`); `GET_ENTRY_FOR_EDIT` mirrors it (`PCH-01-006`). - **Secrets are minimised at the boundary.** The fill response strips the passkey credential, the raw TOTP base32 seed, and the full password history; the background generates the *current* TOTP code and sends only that plus seconds-remaining (`messages.ts:728-760`). The TOTP toast deliberately does not render the live code into page-readable DOM (`content/index.ts:374-379`). - **Destructive operations are gated to extension-page senders.** `DESTROY_VAULT`/`CLEAR_VAULT_ENTRIES`/`RESTORE_VAULT_SNAPSHOT`/`RESTORE_FROM_CODEX` require an extension-origin `sender.url`, so a compromised content script cannot escalate to vault destruction (`messages.ts:267-278`, `PCH-01-018`). - **Transactional disk writes with rollback.** `addEntry`/`updateEntry`/ `deleteEntry` clone-and-restore in-memory state if the persist throws (`vault.ts:1783-1790,1883-1888,1927-1933`), and `persistSlotA` restores the prior slot A if a later write fails under quota pressure (`vault.ts:2193-2198`). `changePassword` writes the new ciphertext, salt, and re-wrapped root key in a single storage transaction and swaps the in-memory key synchronously before any further await (`vault.ts:2849-2879`). - **Monotonic timestamps and clamped peer input.** Entry timestamps are forced strictly increasing against wall-clock regressions (`vault.ts:585-594`), and every peer-supplied `modifiedAt`/`deletedAt` is clamped to `now + 5 min` before any comparison (`sync-limits.ts:13-25`, applied in `vault.ts:2035-2040,2078` and at the adapter boundary), so a far-future remote value cannot permanently pin against honest local edits (`HV-1`). Remote password replacements and deletes of live entries raise durable, user-visible alerts (`vault.ts:2052-2097`). - **Sync resource bounds are layered.** Oversized frames are refused at the WebSocket `onmessage` before buffering (`chirp-sync-transport.ts:70-79`), again before `JSON.parse` in the coordinator (`chirp-sync-coordinator.ts:283-285`), and the adapter caps entry/tombstone counts and per-entry plaintext size before and after decryption (`chirp-sync-vault-adapter.ts:133-152,167`, `MV-3`). The subnet probe is rate-limited to once per 60 s per peer pubkey (`chirp-sync-transport.ts:208-251`). - **Vouched devices require explicit confirmation.** A signed extension manifest can introduce a never-seen device, but it is queued in a pending-vouched store for user Allow/Reject rather than becoming a sync target on the signature alone (`chirp-sync-vault-adapter.ts:270-287`, `MV-7`); a key change under a stable device id raises a durable alert (`MV-8d`). - **Codex generation verifies itself end-to-end.** Generation gates each stage (compression round-trip `generate.ts:341-350`, per-stripe erasure simulation `359-410`, full reconstruction with the maximum tolerated chunk loss `412-468`) and aborts with a typed `CodexError` on any mismatch — appropriate for a disaster-recovery artifact where silent corruption is the worst outcome. - **Codex restore treats every scanned field as attacker-controlled.** The stripe count `S`, within-stripe total `t`, chunk index `i`, and declared fingerprint length are all validated/bounded before they can drive allocation or pass a check (`restore.ts:128-173,354`, `PCH-01-010/011/012/013`), and decompression is streamed with a hard output ceiling (`generate.ts:320-339`, `MV-4`). - **PDF import is hardened in depth.** `pdfjs.getDocument` is called with `isEvalSupported:false`, `disableFontFace`, `disableRange`, `disableStream` (`pdf-import.ts:303-312`, `MV-9`, defending the CVE-2024-4367 font/eval class), with file-size, page-count, and rendered-dimension caps (`pdf-import.ts:119-126,238,273,327`, `PCH-01-014`). - **A real Known-Answer self-test runs on every unlock.** `self-test.ts` pins the expected AES-GCM ciphertext for a fixed vector and compares constant-time (`83-90`), so a WebCrypto that is broken-but-self-consistent (would pass a bare `decrypt(encrypt(x))===x`) is caught and the vault re-locks (`messages.ts:304-311`). - **Import data is sanitised at the vault boundary, not just at the parser.** Every restore/sync path funnels through `sanitizeRestoredEntry` → `normalizeEntry` field whitelist with field-by-field `passkey`/`canary` rebuilds and bounded tags (`vault.ts:1603-1741`, `MV-6`), so parser leniency in `import.ts`/`backup.ts` cannot smuggle unexpected fields into the vault. (The one path that skips this is `UPDATE_ENTRY` — see PCH-02-001.) - **Honest, specific in-code threat documentation.** The pairing trust-model comment (`chirp-handshake.ts:125-154`), the per-entry-key replay rationale (`chirp-sync-vault-adapter.ts:9-21`), the recovery-words 128-bit-prefix model, and the `removePairedDeviceById` "removal is insufficient, rotate instead" note (`paired-devices.ts:72-95`, `MV-8`) all describe residual risk accurately rather than overselling the controls. - **Minimal, justified manifest.** Permissions are `storage`, `unlimitedStorage`, `activeTab`, `scripting`, `clipboardWrite`, `alarms`, `idle`, `favicon`, `webAuthenticationProxy`, `offscreen`; the broad `tabs` permission and the ggwave web-accessible-resource were both removed (`MV-10`), `audioCapture` is `optional_permissions`, and the CSP is `script-src 'self' 'wasm-unsafe-eval'; object-src 'none'` with no `unsafe-inline` or `unsafe-eval` (`manifest.json:55-75`). Content-script UI is mounted in closed shadow roots throughout, page-synthesised keydowns are ignored via `e.isTrusted` (`PCH-01-007`), and no production content code uses `innerHTML`. ## Summary Table | ID | Title | Severity | Location | |---|---|---|---| | PCH-02-001 | Update-entry patch path bypasses canary/tags/passkey sanitizers | Low | `vault.ts:2317-2417`, `messages.ts:478-501` | | PCH-02-002 | Noise XX message 1 omits canonical empty-payload hash step | Informational | `noise-xx.ts:93-97,184-191` | | PCH-02-003 | Pairing auth rests on user SAS + unverifiable peer-side gate | Informational | `chirp-handshake.ts:125-216` | | PCH-02-004 | Crypto-adjacent deps caret-ranged vs. exact-pinned core | Informational | `package.json:31-52` | | PCH-02-005 | Legacy JSON import uses PBKDF2 rather than Argon2id | Informational | `backup.ts:146-173` | | PCH-02-006 | vault_id is stable, LAN-broadcast, cross-device-linkable | Informational | `sync-identity.ts:62-75` | | PCH-02-007 | HIBP check omits Add-Padding header | Informational | `vault.ts:216-238` | | PCH-02-008 | peachPayload BinaryReader lacks explicit bounds checks | Informational | `peachPayload.ts:940-969` | | PCH-02-009 | X25519 DH does not reject low-order/zero outputs | Informational | `noise-xx.ts`, `noise-js.ts` (DH call sites) | | PCH-02-010 | Clipboard auto-clear unconditional; parked hash unused for match | Informational | `clipboard.ts:98-178` | ## Conclusions Peach is a carefully engineered, security-conscious password manager whose cryptographic core is standard and correctly parameterised, and whose extension trust boundaries are enforced with browser-attested origins, closed shadow-DOM UI, sender-gated destructive operations, and consistent secret minimisation across the content-script boundary. The vault layer handles the hard cases — atomic key-rotation writes, in-memory rollback on failed persists, monotonic timestamps, constant-time duress unlock — with evident care, and the higher-risk feature surfaces (LAN sync, Codex PDF import) are bounded against malformed and malicious input at multiple layers. The review found no Critical or High issues. The single Low finding is a sanitizer asymmetry on the update path that is meaningfully mitigated by the extension's messaging model and is a local fix. The remaining items are Informational: documented residual risks (the pairing SAS dependency on an unverifiable peer-side gate is the most important to track, because its weakest link lives in a repository this review cannot see), small defense-in-depth hardening opportunities (reject zero DH outputs, add HIBP padding, bound the binary reader explicitly), and supply-chain/KDF consistency notes. A technically literate reader should come away with high confidence in the correctness of the encryption-at-rest and autofill-boundary implementations, and with a short, concrete list of hardening steps rather than any structural concern. The pervasive, accurate in-code documentation of *why* each control exists is itself a quality signal: it makes the security model auditable from the source alone, which is exactly what a transparency-focused publication of this review should want to demonstrate. The two cross-repository dependencies — the Android Noise hand-port and the Android SAS gate — are the parts of the trust model that this review could not close from the browser source, and they are the right place to direct any follow-up verification.