peach-code-review.md
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 nosrc/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 nosrc/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 ofsrc/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(theerasure.tsreferenced in the brief assrc/shared/erasure.tsis atsrc/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
[email protected], 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.
attemptUnlockWithDeriveloads 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.
attestedSenderOriginreadssender.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_FILLre-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_EDITmirrors 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_CODEXrequire an extension-originsender.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/deleteEntryclone-and-restore in-memory state if the persist throws (vault.ts:1783-1790,1883-1888,1927-1933), andpersistSlotArestores the prior slot A if a later write fails under quota pressure (vault.ts:2193-2198).changePasswordwrites 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-suppliedmodifiedAt/deletedAtis clamped tonow + 5 minbefore any comparison (sync-limits.ts:13-25, applied invault.ts:2035-2040,2078and 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
onmessagebefore buffering (chirp-sync-transport.ts:70-79), again beforeJSON.parsein 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 simulation359-410, full reconstruction with the maximum tolerated chunk loss412-468) and aborts with a typedCodexErroron 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 totalt, chunk indexi, 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.getDocumentis called withisEvalSupported: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.tspins 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 baredecrypt(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→normalizeEntryfield whitelist with field-by-fieldpasskey/canaryrebuilds and bounded tags (vault.ts:1603-1741,MV-6), so parser leniency inimport.ts/backup.tscannot smuggle unexpected fields into the vault. (The one path that skips this isUPDATE_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 theremovePairedDeviceById"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 broadtabspermission and the ggwave web-accessible-resource were both removed (MV-10),audioCaptureisoptional_permissions, and the CSP isscript-src 'self' 'wasm-unsafe-eval'; object-src 'none'with nounsafe-inlineorunsafe-eval(manifest.json:55-75). Content-script UI is mounted in closed shadow roots throughout, page-synthesised keydowns are ignored viae.isTrusted(PCH-01-007), and no production content code usesinnerHTML.
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.