cc2414dc0ac2
docs: save pending docs relocation before vault adoption drafts
- time
- 2026-06-19 19:07 UTC · 4 months ago
- machine
- koh2
- agent
- gpt-5
- session
- vault adoption drafts
- parent
- 6c2063ea1afd
↓ peach-browser-cc2414dc.tar.gzatlas at this save
files changed5 files
CONTRIBUTING.md+0 −47WASM-PROVENANCE.md+0 −54peach-code-review.md+0 −513test-coverage-analysis.md+0 −606vault-adoption-design.md+0 −250CONTRIBUTING.md+0 −47
@@ -1,47 +0,0 @@1-# Contributing to Peach2-3-Thank you for your interest in contributing. Peach is built and maintained by Asha Software.4-5-## Before Contributing6-7-- Open an issue before starting significant work — this avoids duplicated effort8-- Read the security documentation in `security/` to understand the threat model9-- Peach takes cryptographic correctness seriously — changes to `src/crypto/` require careful review10-11-## Development Setup12-13-```bash14-bun install --frozen-lockfile15-bun run dev # Chrome dev build with hot reload16-bun run test # run the test suite17-bun run typecheck # tsc --noEmit18-```19-20-## What We Welcome21-22-- Bug fixes with tests23-- Security findings (see security reporting in `security/README.md`)24-- Compatibility improvements for specific browsers or sites25-- Documentation improvements26-- Translations (contact us first)27-28-## What We're Cautious About29-30-- Changes to cryptographic code — these require extra scrutiny and discussion31-- New dependencies — we keep dependencies minimal and exact-pinned for security reasons32-- Breaking changes to the vault format — these require migration paths33-- UI changes — Peach has a specific design language, large visual changes need discussion first34-35-## Code Style36-37-- TypeScript strict mode throughout38-- Tests required for new functionality39-- `tsc --noEmit` and the full test suite must pass40-41-## Security Issues42-43-Please do not open public issues for security vulnerabilities. Email [email protected] instead.44-45-## License46-47-By contributing you agree that your contributions will be licensed under GPL v3.WASM-PROVENANCE.md+0 −54
@@ -1,54 +0,0 @@1-# WASM & Crypto-Adjacent Dependency Provenance2-3-Peach's vault security rests on a small set of third-party packages that4-either ship WebAssembly blobs or sit directly on a security path. This file5-records what each one is for and the supply-chain rules that apply to them.6-7-## Exact-pin discipline (LV-9, extended by PCH-02-004)8-9-Every dependency listed below is exact-pinned in `package.json` (no caret or10-tilde ranges) and locked in `bun.lock` with `packageManager` pinned to a11-specific bun release. CI installs with a frozen lockfile (LV-6). Upgrading any12-of them is a deliberate act: bump the pin, regenerate the lockfile, and re-run13-the full suite. It should never happen as a side effect of a fresh resolve.14-15-### WASM-shipping packages16-17-| Package | Pinned | Role |18-|---|---|---|19-| `hash-wasm` | 4.12.0 | Argon2id (master-password KDF) and BLAKE3 (payload checksums, vault id) |20-| `@bokuweb/zstd-wasm` | 0.0.27 | zstd compression inside the encrypted export envelope |21-| `@subspace/reed-solomon-erasure.wasm` | 0.2.5 | Codex paper-backup erasure coding |22-23-### Pure-JS crypto core24-25-| Package | Pinned | Role |26-|---|---|---|27-| `@stablelib/*` (blake2s, chacha20poly1305, hkdf, hmac, sha256, x25519) | 2.0.1 | Noise XX/NK handshakes and transport encryption for LAN sync |28-| `@zxcvbn-ts/*` | 3.0.4 / 3.0.2 | Password strength estimation |29-| `tldts` | 7.0.28 | eTLD+1 derivation for the autofill origin boundary |30-| `pdfjs-dist` | 5.7.284 | Codex PDF import (hardened per MV-9) |31-32-### Crypto-adjacent (exact-pinned by PCH-02-004)33-34-These do not hold key material but feed or transform security-relevant data,35-so the same pin discipline applies:36-37-| Package | Pinned | Role |38-|---|---|---|39-| `otpauth` | 9.5.0 | TOTP code generation (RFC 6238) from stored seeds |40-| `pako` | 2.1.0 | Codex restore-side decompression (bomb-capped per MV-4) |41-| `@zxing/library` | 0.23.0 | Aztec matrix decode on Codex restore |42-| `bwip-js` | 4.11.1 | Aztec matrix encode on Codex generation |43-| `pdf-lib` | 1.17.1 | Codex PDF assembly (note: bundles its own internal `[email protected]`, distinct from the import-side `[email protected]`) |44-| `papaparse` | 5.5.3 | CSV import parsing (formula-injection surface) |45-46-## Verifying a WASM blob47-48-The WASM packages ship compiled binaries; their provenance chain is49-npm-registry integrity hash → `bun.lock` → frozen-lockfile CI. When bumping one,50-diff the package's published source repository tag against the shipped artifact51-where the upstream provides reproducible builds, and re-run the unlock-time52-Known-Answer self-test (`src/background/self-test.ts`) plus the Argon2id KAT53-(`src/crypto/argon2id.kat.test.ts`). A silently-swapped KDF or hash54-implementation fails those vectors.peach-code-review.md+0 −513
@@ -1,513 +0,0 @@1-# Peach Browser Extension — Independent Code Review2-**Reviewer:** Claude Fable 53-**Date:** 2026-06-124-**Codebase:** peach-browser (v4.0.0)5-**Method:** Complete static read of all security-relevant source files6-7-## Overview8-9-Peach is a local-first, zero-knowledge browser password manager (Manifest V3,10-Chrome + Firefox, plus a native Android port that is out of scope here). The11-vault is encrypted on-device with AES-256-GCM under an Argon2id-derived key; no12-plaintext ever leaves the machine. Beyond storage it implements browser13-autofill, WebAuthn/passkey provisioning, a Pro-gated LAN device-sync feature14-(audio "chirp" pairing → Noise-encrypted WebSocket), a paper backup system15-("Codex": Reed-Solomon-coded Aztec matrices rendered to PDF), and a duress/decoy16-two-slot vault.17-18-The dominant impression from reading the code is that it has been through19-serious, repeated security review and that the fixes were integrated20-thoughtfully rather than bolted on. Nearly every security-relevant decision21-carries an inline rationale tied to a specific finding ID (audit `HV-*`/`MV-*`/22-`LV-*`, technical-report `PCH-01-*`). Cryptographic primitives are standard and23-correctly parameterised. The extension's trust boundaries — content script vs.24-background, page origin vs. vault — are enforced with browser-attested signals25-rather than payload-supplied ones. Error handling consistently fails closed for26-secrets and fails gracefully for availability.27-28-The findings below are, with one exception, Informational or Low. I found no29-Critical or High issues. This is consistent with a codebase that has already30-absorbed two prior review rounds; what remains is mostly defense-in-depth31-hardening, documented residual risks, and a small number of asymmetries worth32-tightening.33-34-## Scope35-36-Read completely:37-38-- **Cryptographic layer:** `src/crypto/argon2.ts`, `src/crypto/zstd.ts`,39- `src/crypto/totp.ts`, `src/crypto/peachScript.ts`, `src/crypto/peachPayload.ts`,40- `src/background/aes.ts` (the canonical AES module — there is no41- `src/crypto/aes.ts`; see note in Scope deviations), `src/background/self-test.ts`,42- `src/shared/noise-xx.ts`, `src/shared/noise-js.ts` (the Noise NK43- implementation — there is no `src/shared/noise-nk.ts`), `src/shared/sync-identity.ts`,44- `src/shared/recovery-words.ts`.45-- **Vault core:** `src/background/vault.ts` (entire file).46-- **Extension boundary:** `src/background/messages.ts`, `src/background/entry.ts`,47- `src/background/clipboard.ts`, `src/content/index.ts`, `src/content/detect.ts`,48- `src/content/save.ts`, and the relevant interfaces of `src/content/inject.ts`.49-- **Sync + pairing:** `src/background/chirp-sync-coordinator.ts`,50- `src/background/chirp-sync-vault-adapter.ts`, `src/shared/chirp-sync-transport.ts`,51- `src/background/chirp-sync-runner.ts`, `src/background/paired-devices.ts`,52- `src/shared/chirp-payload.ts`, `src/shared/chirp-handshake.ts`,53- `src/shared/device-manifest.ts`, `src/shared/sync-limits.ts`.54-- **Backup + recovery:** `src/popup/codex/generate.ts`, `restore.ts`,55- `pdf-import.ts`, `erasure.ts`, `src/shared/import.ts`, `src/shared/backup.ts`,56- `src/shared/import-limits.ts` (the `erasure.ts` referenced in the brief as57- `src/shared/erasure.ts` is at `src/popup/codex/erasure.ts`).58-- **Configuration:** `manifest.json`, `manifest.firefox.json`, `package.json`.59-60-**Scope deviations:** three brief paths do not exist as written. AES-GCM lives in61-`src/background/aes.ts` and inline in `peachPayload.ts` (a header comment in62-`aes.ts:4` records that a duplicate `src/crypto/aes.ts` was removed); the Noise NK63-pattern is in `noise-js.ts`; the Codex erasure module is in `src/popup/codex/`.64-All three were located and read.65-66-## Cryptographic Implementation67-68-**Argon2id (master-password KDF)** — `argon2.ts`, also inline in `peachPayload.ts`69-and as parameters in `codex-types`. m = 65536 KiB (64 MiB), t = 3, p = 4, 32-byte70-output, via `hash-wasm`. This meets the OWASP password-manager tier and exceeds71-it on parallelism. The password is NFC-normalised before derivation72-(`argon2.ts:32`) so the same passphrase typed under different Unicode73-normalisation forms (macOS NFD dead-keys vs. mobile NFC) derives the same key and74-stays cross-platform-compatible; a `deriveKeyRaw` legacy path75-(`argon2.ts:40-45`) exists solely to open vaults sealed before that fix. The salt76-is 16 bytes from `crypto.getRandomValues`, fresh at creation and on every password77-change (`argon2.ts:50-52`). Appropriate and correct.78-79-**AES-256-GCM (vault-at-rest and export envelope)** — `aes.ts`,80-`peachPayload.ts:997-1051`. 96-bit random nonce per encryption (`aes.ts:56`),81-128-bit tag (the maximum, explicitly pinned and commented as non-reducible at82-`aes.ts:51-55`). Keys are imported non-extractable, used once, then zeroed by83-callers (`vault.ts` zeroes `derivedKey`/`rootKey` on lock and after key swaps).84-The AAD carries a per-slot domain tag (`"slot_a_v1"`, `"slot_b_v1"`, `"pw_root_v1"`)85-with a legacy single-byte-AAD fallback on decrypt so pre-hardening vaults still86-open (`aes.ts:104-129`). The construction is textbook and the nonce-reuse hazard87-is correctly avoided with fresh randomness rather than a counter. Note that GCM's88-~2³² safe-message bound per key is irrelevant here — a vault is re-encrypted as a89-single message per write.90-91-**BLAKE3 (payload checksum, vault id, card fingerprints)** — via `hash-wasm`. In92-`peachPayload.ts` a 4-byte truncated BLAKE3 checksum sits *inside* the encrypted93-envelope (`blake3Truncated`, `1053-1061`) purely to fail fast on94-compressed-payload corruption before decompression — authenticity is GCM's job,95-not the checksum's, and the code says so (`peachPayload.ts:27-29`). The checksum96-comparison is constant-time (`1063-1068`). `computeVaultId` is97-`BLAKE3(masterKey || "peach-vault-id")` (`sync-identity.ts:68-75`).98-99-**zstd / pako (compression)** — `zstd.ts`, `generate.ts`. Compression is not a100-security primitive but is a decompression-bomb surface; it is bounded101-(see PCH-02-008 context and `import-limits.ts`).102-103-**X25519 + ChaCha20-Poly1305 + BLAKE2s (Noise XX / NK)** — `noise-xx.ts`,104-`noise-js.ts`. Both patterns (`Noise_XX_25519_ChaChaPoly_BLAKE2s` for mutual-auth105-WebSocket sync, `Noise_NK_…` for post-chirp pairing) derive the protocol-name106-init hash and the HKDF chain over BLAKE2s, matching the named pattern (a prior107-round had used SHA-256 here; `PCH-01-002` corrected it). Because `@stablelib`'s108-HKDF reuses one HMAC across output blocks and BLAKE2s's `restoreState` leaves the109-`finished` flag set, the code runs HKDF-Expand manually with a fresh HMAC-BLAKE2s110-per block (`noise-xx.ts:46-58`), documented as byte-identical to the Android111-BouncyCastle side. Transport keys are split correctly and direction-swapped on112-the responder; nonces are per-frame counters from 0. The XX initiator pins the113-responder's static key *before* transmitting its own static in message 3114-(`noise-xx.ts:78-133`, `PCH-01-003`), so a probed-but-wrong LAN host never learns115-the prober's long-term identity.116-117-**Ed25519 (device-manifest signing)** — `sync-identity.ts:89-161`,118-`device-manifest.ts`. Deterministically derived from the root key under a119-distinct HKDF salt (`"peach-sync-signing-v1"`) so the signing key is120-single-purpose and survives reinstall. Manifests are signed over a canonical121-JSON encoding (sorted keys, no insignificant whitespace, non-finite numbers122-rejected — `device-manifest.ts:183-204`) and verified with strict per-device123-field validation and a freshness window. The private key is imported as124-extractable, which the inline comment justifies and `PCH-01-022` documents: the125-32-byte seed is already in memory, so a JWK export to recover the public key adds126-no attack surface.127-128-**HKDF-SHA256 (sync identity, recovery KEK, per-entry sync keys)** —129-`sync-identity.ts`, `recovery-words.ts:130-155`, `chirp-sync-vault-adapter.ts:355-369`.130-Each use is domain-separated. The per-entry sync envelope key is131-`HKDF(rootKey, info = entryId + "\x00" + "sync-entry-v1")`; the null-byte132-separator makes the (id, suffix) boundary unambiguous (`PCH-01-005`), and the133-adapter rejects any decrypted envelope whose `entry.id` does not equal the map134-key it arrived under, so a ciphertext minted for one slot cannot be replayed into135-another (`chirp-sync-vault-adapter.ts:153-155`).136-137-**PBKDF2-SHA256 (legacy export, self-test)** — `backup.ts:146-173` derives the138-key for the *legacy* encrypted-JSON import format with PBKDF2-SHA256 at 250,000139-iterations. `self-test.ts` uses PBKDF2 at 1,000 iterations deliberately, only as140-a fast Known-Answer-Test vehicle on the same AES-GCM path (it pins the expected141-ciphertext, `PCH-01-020`, so a self-consistent-but-broken WebCrypto fails). See142-PCH-02-005.143-144-**BIP39 recovery words** — `recovery-words.ts`. A 12-word phrase encodes a145-128-bit prefix of the 256-bit root key plus a 4-bit SHA-256 checksum, exactly per146-BIP39. The code is explicit and repeated about the security model147-(`recovery-words.ts:20-26`, `116-120`, `vault.ts:282-296`): recovery is 128-bit,148-the words wrap the *full* root key via an HKDF-derived KEK from a *prefix* of that149-key, and a future 256-bit-recovery scheme would need independent entropy. This is150-a correct and unusually well-documented construction.151-152-**TOTP** — `totp.ts` delegates to the `otpauth` library; the entry's stored153-algorithm/digits/period are threaded through (`PCH-01-019`) with RFC defaults154-(SHA-1/6/30) when absent. The raw base32 seed never crosses to a content script155-(see Extension boundary); the background generates the current code and ships only156-that plus seconds-remaining.157-158-**PeachScript** — `peachScript.ts` is a trivial byte → Unicode-PUA codec159-(U+E000…U+E0FF) for the device-to-device export string. It is presentation, not160-cryptography — the payload it carries is already an AES-GCM envelope — and the161-file says so. Decode validates every code point is in range.162-163-## Code Review Findings164-165-### Finding PCH-02-001: Update-entry patch path bypasses the canary/tags/passkey sanitizers used on add and restore166-**Severity:** Low167-**Location:** `src/background/vault.ts:2317-2417` (`sanitizeEntryPatch`),168-`src/background/messages.ts:478-501` (`UPDATE_ENTRY` handler)169-**Description:** Entries entering the vault via add (`sanitizeEntryDraft`,170-`vault.ts:2256-2315`), restore, or sync (`sanitizeRestoredEntry`,171-`vault.ts:1603-1650`) are run through field whitelists that rebuild `passkey`172-and `canary` sub-objects field-by-field and bound `tags`173-(`sanitizePasskey`/`sanitizeCanary`/`sanitizeTags`). The `UPDATE_ENTRY` path does174-not: `sanitizeEntryPatch` copies `patch.canary` (`2375-2377`), `patch.tags`175-(`2386-2388`), and `patch.passkey` (`2379-2384`) through verbatim. `UPDATE_ENTRY`176-is also not in `DESTRUCTIVE_MESSAGE_TYPES` (`messages.ts:141-146`), so unlike177-`DESTROY_VAULT`/`RESTORE_*` it is not gated to extension-page senders — and it is178-legitimately sent from content-script context (the inline editor and the179-canary-ping bump at `content/index.ts:336-348` and `content/save.ts:57-61`), so180-it cannot simply be gated. The net effect is that a caller able to send an181-`UPDATE_ENTRY` for a known entry id could attach an arbitrary `passkey`182-sub-object, set unbounded `tags`, or replace a `canary` object on an entry that183-already has one without the field rebuild the other paths enforce.184-**Notes:** Exploitability is genuinely low: web pages cannot call185-`chrome.runtime.sendMessage` (no `externally_connectable`), so this requires186-compromising the content script itself, and Peach's injected UI mounts in *closed*187-shadow roots (`content/index.ts:164`, `inject.ts` `attachShadow({mode:"closed"})`188-throughout), which removes the obvious in-page foothold. Adding a *new* canary is189-separately Pro-gated (`messages.ts:491-498`) and the canary fire path re-validates190-the webhook with the full SSRF guard (`isAllowedCanaryWebhookUrl`,191-`messages.ts:1568-1600`). The cleanest fix is to route `sanitizeEntryPatch`'s192-`canary`/`tags`/`passkey` branches through the same sanitizers the other paths193-use; the change is local and does not alter the trust boundary.194-195-### Finding PCH-02-002: Noise XX message 1 omits the canonical empty-payload hash step196-**Severity:** Informational197-**Location:** `src/shared/noise-xx.ts:93-97` (initiator), `184-191` (responder)198-**Description:** In a canonical Noise handshake, every message — including the199-first `-> e` — ends by processing its payload through `EncryptAndHash`, which for200-an empty payload with no symmetric key yet still performs `MixHash(empty)`. The201-Peach XX implementation processes the ephemeral (`mixHash(h, e.publicKey)`) and202-sends it, but does not perform the empty-payload `MixHash` for message 1. The NK203-implementation *does* hash its message payloads (`noise-js.ts:90-91`, `112-116`),204-because by those points a key exists and a real AEAD tag is produced. The205-consequence is that Peach's XX transcript hash diverges by one `MixHash(empty)`206-from what a stock Noise library (e.g. `snow`) would compute, so a third-party207-Noise stack would not interoperate with Peach's XX handshake.208-**Notes:** This is not a confidentiality or authenticity weakness: both Peach209-peers (and the Android hand-port) compute `h` identically, the handshake hash210-still binds the full transcript that both sides observe, and the transport keys211-derive from the chaining key, not `h`. The only practical effect is interop with212-external implementations, which Peach does not rely on (the doc comment at213-`noise-js.ts:11-14` notes interop is held "by porting discipline, not by a shared214-library"). The header comment in `noise-js.ts:12` that "a stock `snow` stack215-would interoperate" holds for NK but is not strictly true for XX; worth a216-one-line correction so the claim is not over-read.217-218-### Finding PCH-02-003: Pairing authentication rests on the user-confirmed SAS and an unverifiable peer-side gate219-**Severity:** Informational220-**Location:** `src/shared/chirp-handshake.ts:125-216`221-**Description:** The post-chirp pairing handshake uses Noise NK, which222-authenticates only the *responder* (it proves the initiator encrypted to the223-responder's chirp-broadcast ephemeral). It does not cryptographically224-authenticate the initiator, and there is no proof-of-possession over the225-`long_term_public_key_hex` a peer advertises in its `PAIR_IDENTITY` frame. The226-compensating control is a 6-digit Short Authentication String derived from the227-shared Noise handshake hash (`derivePairingSAS`, `163-174`), which the extension228-pairing flow requires the user to confirm matches the peer's before any device is229-persisted (`gateOnSAS`, referenced as living in `src/popup/chirp-pair.ts`).230-**Notes:** This is called out thoroughly and honestly in the source itself231-(`chirp-handshake.ts:125-154`) — one of the better in-code threat statements in232-the codebase. Residual risk is the standard SAS bound: a MITM/relay survives with233-probability ~1e-6 per attempt *and only if the user does not actually compare the234-codes*. The open item the code names — that the Android/native peer must enforce235-the identical SAS gate before persisting — cannot be verified from this236-repository and is the more important dependency: if the app auto-confirms, the237-channel is only as strong as its weakest end. Recommend confirming the app-side238-gate against the Android source, and (as the comment suggests) considering a239-future proof-of-possession round via the already-present240-`performInitiatorAuth`/`performResponderAuth` so authentication becomes structural241-rather than dependent on user diligence.242-243-### Finding PCH-02-004: Crypto-adjacent dependencies are caret-ranged while core crypto deps are exact-pinned244-**Severity:** Informational245-**Location:** `package.json:31-52`246-**Description:** The core vault/crypto/transport dependencies are exact-pinned247-(no caret): `@stablelib/*` 2.0.1, `hash-wasm` 4.12.0, `@bokuweb/zstd-wasm` 0.0.27,248-`@subspace/reed-solomon-erasure.wasm` 0.2.5, `tldts` 7.0.28, `pdfjs-dist`249-5.7.284, `@zxcvbn-ts/*` (this is the `LV-9` exact-pin discipline). Several250-dependencies on or adjacent to a security path remain caret-ranged: `otpauth`251-`^9.5.0` (TOTP generation), `pako` `^2.1.0` (Codex decompression on the import252-side), `@zxing/library` `^0.23.0` and `bwip-js` `^4.10.1` (Aztec decode/encode),253-`pdf-lib` `^1.17.1`, `papaparse` `^5.5.3`.254-**Notes:** With a committed lockfile (`bun.lock`, `packageManager` pinned to255-`[email protected]`, frozen-lockfile CI per `LV-6`) the practical exposure is limited to256-fresh resolves, but extending the exact-pin discipline to `otpauth` and `pako` in257-particular would make the supply-chain posture uniform with the rest of the258-crypto surface.259-260-### Finding PCH-02-005: Legacy encrypted-JSON import uses PBKDF2-SHA256 rather than Argon2id261-**Severity:** Informational262-**Location:** `src/shared/backup.ts:146-173`263-**Description:** The legacy `.json` export/import envelope derives its key with264-PBKDF2-SHA256 at 250,000 iterations, whereas every current path uses Argon2id265-(m=64 MiB, t=3, p=4). PBKDF2 with no memory-hardness is materially weaker against266-GPU/ASIC attackers than Argon2id at equivalent wall-clock cost.267-**Notes:** Scope is bounded and the decision is reasonable: this format's UI268-export was removed in v2.5.0 (`backup.ts:5-12`), the helper is import-only269-(plus a test fixture generator), and current exports use PeachScript / the QR270-matrix encoder backed by Argon2id. No action is strictly required; flagged so the271-KDF asymmetry is visible to anyone who still relies on old `.json` backups.272-273-### Finding PCH-02-006: vault_id is a stable, LAN-broadcast, cross-device-linkable identifier274-**Severity:** Informational275-**Location:** `src/shared/sync-identity.ts:62-75`276-**Description:** `computeVaultId` returns `BLAKE3(masterKey || "peach-vault-id")`,277-a deterministic 64-hex value that, per its own comment, is carried in the mDNS TXT278-record and the first sync handshake (`SYNC_HELLO`). It is therefore a stable279-identifier visible to any device on the same LAN, and it is identical across all280-of a user's paired devices, making them linkable to a passive local observer for281-as long as the master key (root key) is unchanged.282-**Notes:** This is inherent to the discovery design — peers must recognise "same283-vault" before exchanging entries — and it is one-way (the master key is not284-recoverable from it). It is a minor privacy property worth stating, not a285-confidentiality flaw. "Rotate sync identity" (`vault.ts:2771-2814`) changes it by286-re-keying the root key.287-288-### Finding PCH-02-007: HIBP breach check omits the Add-Padding response-padding header289-**Severity:** Informational290-**Location:** `src/background/vault.ts:216-238`291-**Description:** `checkBreach` queries the Have I Been Pwned k-anonymity range292-API with the SHA-1 prefix but does not send `Add-Padding: true`. HIBP supports293-that header to pad the response to a fixed bucket count; without it, the number294-of suffixes returned for a given prefix is a (weak) side channel that a295-network-position observer could use to narrow which prefix was queried.296-**Notes:** Impact is small — only the 5-hex-char prefix is ever sent, never the297-password or full hash, and breach monitoring is an opt-out convenience feature.298-Adding the padding header is a one-line hardening that costs nothing.299-300-### Finding PCH-02-008: peachPayload BinaryReader performs no explicit bounds checking301-**Severity:** Informational302-**Location:** `src/crypto/peachPayload.ts:940-969` (`BinaryReader`),303-consumed in `decodeEntry` `638-813`304-**Description:** The binary entry decoder reads `u8`/`u16`/`u32`/length-prefixed305-strings without validating that the cursor stays within the buffer. Past-end306-reads yield `undefined`/`NaN` (for `u8`) or throw `RangeError` (DataView), and307-length-prefixed reads clamp via `subarray`. The decoded bytes are normally inside308-an AES-GCM-authenticated envelope, but the same decoder runs on imported `.peach`309-payloads where an attacker who also knows the passphrase controls the plaintext.310-**Notes:** This is a robustness observation, not a memory-safety issue — the311-runtime is JavaScript, so the worst case is a thrown exception (caught by the312-import UI) or a malformed entry that is later rejected by `sanitizeRestoredEntry`313-at the vault boundary. The declared section-length and cumulative-size caps314-(`peachPayload.ts:301-311`, `MV-4`) already bound total work before this point.315-Explicit cursor bounds checks would make the decoder's failure modes uniform and316-self-evidently safe.317-318-### Finding PCH-02-009: X25519 shared-secret computations do not reject low-order/zero outputs319-**Severity:** Informational320-**Location:** `src/shared/noise-xx.ts:108,119,140,196,204,231`,321-`src/shared/noise-js.ts:84,106,175,248,263,362`322-**Description:** All `sharedKey(secret, peerPublic)` calls use `@stablelib/x25519`323-with the default `rejectZeroSharedKey = false`, so a peer public key chosen as a324-low-order point (yielding an all-zero shared secret) is not rejected at the DH325-step.326-**Notes:** The practical impact is neutralised by the surrounding protocol: NK327-pre-mixes the known responder static key, the XX responder static is pinned328-against the paired-device registry before and after the handshake329-(`chirp-sync-transport.ts:130-154`), and pairing is additionally gated by the330-human SAS comparison. An attacker forcing a zero shared secret would still fail331-the static-key pin and/or the SAS. Passing `rejectZero = true` would make the DH332-contributory-by-construction and remove the need to reason about this at all.333-334-### Finding PCH-02-010: Clipboard auto-clear is unconditional and does not compare against the parked hash335-**Severity:** Informational336-**Location:** `src/background/clipboard.ts:98-117,124-178`337-**Description:** On copy, a SHA-256 of the copied value is parked in338-`storage.session` purely as a "a clear is owed" flag (`98-103`); the auto-clear339-routine (`clearClipboardNow`) then writes an empty string to the clipboard340-unconditionally when the timer/alarm fires, without reading the current clipboard341-to confirm it still holds the Peach-copied value. If the user copies something342-else within the clear window, that later value is wiped too.343-**Notes:** This is a deliberate trade-off in favour of the344-secret-must-not-persist invariant (the alarm path is eviction-proof, `HV-2`, and345-clearing an already-changed clipboard is harmless to confidentiality), and a346-content/popup-side write with a real user gesture is preferred when available347-(`alreadyWritten`). It is a minor UX surprise rather than a security issue; noted348-for completeness because the parked hash is computed but never used for a match349-check, which could read as dead bookkeeping to a future maintainer.350-351-## Positive Observations352-353-The codebase's strengths are specific and worth recording at the same resolution354-as the findings.355-356-- **Two-slot duress with a real plausible-deniability story.** Both vault slots357- always exist; the decoy/noise slot is filled with CSPRNG bytes that are358- indistinguishable at rest from a GCM ciphertext (`vault.ts:704-716`), and when359- duress is configured both slots are padded to a fixed 256 KiB ceiling so their360- lengths cannot reveal that a decoy exists (`vault.ts:62-72,1038,2213`). The361- duress bit lives only inside slot A's encrypted payload — no external flag362- (`vault.ts:44-45,1051-1053`).363-364-- **Constant-time unlock.** `attemptUnlockWithDerive` loads both slots, derives365- both keys, and attempts both decrypts in parallel and unconditionally366- (`vault.ts:1124-1154`), so wall-clock time does not distinguish a real-vault367- unlock from a decoy unlock (`DUR-002`).368-369-- **Origin enforcement uses browser-attested signals only.**370- `attestedSenderOrigin` reads `sender.origin`/`sender.url` (which a page cannot371- forge, since pages reach the background only via the content-script relay) and372- never payload-supplied origins (`messages.ts:217-231`). `GET_ENTRY_FOR_FILL`373- re-validates the entry's domain set against the requesting tab's attested374- origin and refuses cross-domain fills unless the user explicitly summoned them375- (`messages.ts:698-727`, `MV-1`); `GET_ENTRY_FOR_EDIT` mirrors it (`PCH-01-006`).376-377-- **Secrets are minimised at the boundary.** The fill response strips the passkey378- credential, the raw TOTP base32 seed, and the full password history; the379- background generates the *current* TOTP code and sends only that plus380- seconds-remaining (`messages.ts:728-760`). The TOTP toast deliberately does not381- render the live code into page-readable DOM (`content/index.ts:374-379`).382-383-- **Destructive operations are gated to extension-page senders.**384- `DESTROY_VAULT`/`CLEAR_VAULT_ENTRIES`/`RESTORE_VAULT_SNAPSHOT`/`RESTORE_FROM_CODEX`385- require an extension-origin `sender.url`, so a compromised content script cannot386- escalate to vault destruction (`messages.ts:267-278`, `PCH-01-018`).387-388-- **Transactional disk writes with rollback.** `addEntry`/`updateEntry`/389- `deleteEntry` clone-and-restore in-memory state if the persist throws390- (`vault.ts:1783-1790,1883-1888,1927-1933`), and `persistSlotA` restores the391- prior slot A if a later write fails under quota pressure392- (`vault.ts:2193-2198`). `changePassword` writes the new ciphertext, salt, and393- re-wrapped root key in a single storage transaction and swaps the in-memory key394- synchronously before any further await (`vault.ts:2849-2879`).395-396-- **Monotonic timestamps and clamped peer input.** Entry timestamps are forced397- strictly increasing against wall-clock regressions (`vault.ts:585-594`), and398- every peer-supplied `modifiedAt`/`deletedAt` is clamped to `now + 5 min` before399- any comparison (`sync-limits.ts:13-25`, applied in400- `vault.ts:2035-2040,2078` and at the adapter boundary), so a far-future remote401- value cannot permanently pin against honest local edits (`HV-1`). Remote402- password replacements and deletes of live entries raise durable, user-visible403- alerts (`vault.ts:2052-2097`).404-405-- **Sync resource bounds are layered.** Oversized frames are refused at the406- WebSocket `onmessage` before buffering (`chirp-sync-transport.ts:70-79`), again407- before `JSON.parse` in the coordinator (`chirp-sync-coordinator.ts:283-285`),408- and the adapter caps entry/tombstone counts and per-entry plaintext size before409- and after decryption (`chirp-sync-vault-adapter.ts:133-152,167`, `MV-3`). The410- subnet probe is rate-limited to once per 60 s per peer pubkey411- (`chirp-sync-transport.ts:208-251`).412-413-- **Vouched devices require explicit confirmation.** A signed extension manifest414- can introduce a never-seen device, but it is queued in a pending-vouched store415- for user Allow/Reject rather than becoming a sync target on the signature alone416- (`chirp-sync-vault-adapter.ts:270-287`, `MV-7`); a key change under a stable417- device id raises a durable alert (`MV-8d`).418-419-- **Codex generation verifies itself end-to-end.** Generation gates each stage420- (compression round-trip `generate.ts:341-350`, per-stripe erasure simulation421- `359-410`, full reconstruction with the maximum tolerated chunk loss422- `412-468`) and aborts with a typed `CodexError` on any mismatch — appropriate423- for a disaster-recovery artifact where silent corruption is the worst outcome.424-425-- **Codex restore treats every scanned field as attacker-controlled.** The426- stripe count `S`, within-stripe total `t`, chunk index `i`, and declared427- fingerprint length are all validated/bounded before they can drive allocation428- or pass a check (`restore.ts:128-173,354`,429- `PCH-01-010/011/012/013`), and decompression is streamed with a hard output430- ceiling (`generate.ts:320-339`, `MV-4`).431-432-- **PDF import is hardened in depth.** `pdfjs.getDocument` is called with433- `isEvalSupported:false`, `disableFontFace`, `disableRange`, `disableStream`434- (`pdf-import.ts:303-312`, `MV-9`, defending the CVE-2024-4367 font/eval class),435- with file-size, page-count, and rendered-dimension caps436- (`pdf-import.ts:119-126,238,273,327`, `PCH-01-014`).437-438-- **A real Known-Answer self-test runs on every unlock.** `self-test.ts` pins the439- expected AES-GCM ciphertext for a fixed vector and compares constant-time440- (`83-90`), so a WebCrypto that is broken-but-self-consistent (would pass a bare441- `decrypt(encrypt(x))===x`) is caught and the vault re-locks442- (`messages.ts:304-311`).443-444-- **Import data is sanitised at the vault boundary, not just at the parser.**445- Every restore/sync path funnels through `sanitizeRestoredEntry` →446- `normalizeEntry` field whitelist with field-by-field `passkey`/`canary` rebuilds447- and bounded tags (`vault.ts:1603-1741`, `MV-6`), so parser leniency in448- `import.ts`/`backup.ts` cannot smuggle unexpected fields into the vault. (The449- one path that skips this is `UPDATE_ENTRY` — see PCH-02-001.)450-451-- **Honest, specific in-code threat documentation.** The pairing trust-model452- comment (`chirp-handshake.ts:125-154`), the per-entry-key replay rationale453- (`chirp-sync-vault-adapter.ts:9-21`), the recovery-words 128-bit-prefix model,454- and the `removePairedDeviceById` "removal is insufficient, rotate instead" note455- (`paired-devices.ts:72-95`, `MV-8`) all describe residual risk accurately rather456- than overselling the controls.457-458-- **Minimal, justified manifest.** Permissions are459- `storage`, `unlimitedStorage`, `activeTab`, `scripting`, `clipboardWrite`,460- `alarms`, `idle`, `favicon`, `webAuthenticationProxy`, `offscreen`; the broad461- `tabs` permission and the ggwave web-accessible-resource were both removed462- (`MV-10`), `audioCapture` is `optional_permissions`, and the CSP is463- `script-src 'self' 'wasm-unsafe-eval'; object-src 'none'` with no `unsafe-inline`464- or `unsafe-eval` (`manifest.json:55-75`). Content-script UI is mounted in closed465- shadow roots throughout, page-synthesised keydowns are ignored via `e.isTrusted`466- (`PCH-01-007`), and no production content code uses `innerHTML`.467-468-## Summary Table469-470-| ID | Title | Severity | Location |471-|---|---|---|---|472-| PCH-02-001 | Update-entry patch path bypasses canary/tags/passkey sanitizers | Low | `vault.ts:2317-2417`, `messages.ts:478-501` |473-| PCH-02-002 | Noise XX message 1 omits canonical empty-payload hash step | Informational | `noise-xx.ts:93-97,184-191` |474-| PCH-02-003 | Pairing auth rests on user SAS + unverifiable peer-side gate | Informational | `chirp-handshake.ts:125-216` |475-| PCH-02-004 | Crypto-adjacent deps caret-ranged vs. exact-pinned core | Informational | `package.json:31-52` |476-| PCH-02-005 | Legacy JSON import uses PBKDF2 rather than Argon2id | Informational | `backup.ts:146-173` |477-| PCH-02-006 | vault_id is stable, LAN-broadcast, cross-device-linkable | Informational | `sync-identity.ts:62-75` |478-| PCH-02-007 | HIBP check omits Add-Padding header | Informational | `vault.ts:216-238` |479-| PCH-02-008 | peachPayload BinaryReader lacks explicit bounds checks | Informational | `peachPayload.ts:940-969` |480-| PCH-02-009 | X25519 DH does not reject low-order/zero outputs | Informational | `noise-xx.ts`, `noise-js.ts` (DH call sites) |481-| PCH-02-010 | Clipboard auto-clear unconditional; parked hash unused for match | Informational | `clipboard.ts:98-178` |482-483-## Conclusions484-485-Peach is a carefully engineered, security-conscious password manager whose486-cryptographic core is standard and correctly parameterised, and whose extension487-trust boundaries are enforced with browser-attested origins, closed shadow-DOM UI,488-sender-gated destructive operations, and consistent secret minimisation across the489-content-script boundary. The vault layer handles the hard cases — atomic490-key-rotation writes, in-memory rollback on failed persists, monotonic timestamps,491-constant-time duress unlock — with evident care, and the higher-risk feature492-surfaces (LAN sync, Codex PDF import) are bounded against malformed and malicious493-input at multiple layers.494-495-The review found no Critical or High issues. The single Low finding is a496-sanitizer asymmetry on the update path that is meaningfully mitigated by the497-extension's messaging model and is a local fix. The remaining items are498-Informational: documented residual risks (the pairing SAS dependency on an499-unverifiable peer-side gate is the most important to track, because its weakest500-link lives in a repository this review cannot see), small defense-in-depth501-hardening opportunities (reject zero DH outputs, add HIBP padding, bound the502-binary reader explicitly), and supply-chain/KDF consistency notes.503-504-A technically literate reader should come away with high confidence in the505-correctness of the encryption-at-rest and autofill-boundary implementations, and506-with a short, concrete list of hardening steps rather than any structural concern.507-The pervasive, accurate in-code documentation of *why* each control exists is508-itself a quality signal: it makes the security model auditable from the source509-alone, which is exactly what a transparency-focused publication of this review510-should want to demonstrate. The two cross-repository dependencies — the Android511-Noise hand-port and the Android SAS gate — are the parts of the trust model that512-this review could not close from the browser source, and they are the right place513-to direct any follow-up verification.test-coverage-analysis.md+0 −606
@@ -1,606 +0,0 @@1-# peach-browser — Test Coverage Analysis2-3-**Date:** 2026-06-114-**Base:** dev lane, HEAD `7100d7d25214` (v3.9.8)5-**Method:** Full read of `src/` (130 source modules, 43,826 LOC) cross-referenced against the test suite (109 test files).6-**Mode:** Read-only analysis. No source modified.7-8-**Test count:** 1,632 literal `it()`/`test()` declarations (1,062 unit/component in `src/`, 570 Playwright e2e in `tests/`). Many suites are parameterized via `it.each`/loops (e.g. `content.adversarial`, `vault.adversarial`, `codex.adversarial` each declare "≥200 cases"), so the **executed** assertion count recorded in `LOG.md` for the v3.9.x line is ~3,200. Headline ratio: ~1,632 declared tests against ~44k LOC ≈ **1 test per 27 LOC** — high by volume, but unevenly distributed (see §3, §5).9-10----11-12-## Section 1 — Module Inventory13-14-### Background (`src/background/`) — service worker, vault, sync, passkeys15-16-| Module | Lines | Purpose |17-|--------|-------|---------|18-| vault.ts | 2890 | Core vault: unlock/lock, AES-GCM crypto, entry CRUD, password history, soft delete/trash, duress vault, vault-history snapshots, sync merge (`applyMergedRemoteEntries`), changePassword, recovery escrow, `normalizeEntry` whitelist |19-| messages.ts | 1610 | Message router: `GET_ENTRY_FOR_FILL`/`_EDIT`, `AUTOFILL_QUERY`, `GET_RECOVERY_WORDS`, destructive-op gating, canary, `SAVE_NOTE`, sync alerts, license/pro gate |20-| passkey.ts | 1413 | WebAuthn interception — Chrome `webAuthenticationProxy` + Firefox content-proxy create/get ceremonies, approval flow, pending-request tracking |21-| passkey-crypto.ts | 370 | WebAuthn crypto: ES256/Ed25519 keypair gen, COSE/CBOR encoding, P1363→DER, authenticator data |22-| codex-handlers.ts | 310 | Codex backup restore: verify-code validation, blob decompress, metadata validation before unlock |23-| chirp-sync-vault-adapter.ts | 438 | Sync crypto boundary: per-entry AES-GCM key derivation, entry/tombstone apply, device-manifest sign/verify |24-| chirp-sync-coordinator.ts | 295 | Manifest-diff sync protocol (HELLO→MANIFEST→REQUEST→ENTRIES→TOMBSTONES→DONE) over Noise session |25-| chirp-sync-runner.ts | 181 | Production sync runner: vault adapter + Noise XX transport + IP cache + subnet-probe fallback |26-| sync-trigger.ts | 192 | Sync dispatcher: vault-change/foreground/manual/mDNS triggers, per-device runner, `trustSource:'direct'` filter |27-| clipboard.ts | 201 | Clipboard write + auto-clear via `chrome.alarms` + offscreen (HV-2) |28-| ml.ts | 200 | Logistic-regression field classifier (hard-coded weights) |29-| alarms.ts | 69 | Auto-lock via `chrome.alarms` + idle detection |30-| paired-devices.ts | 180 | Paired-device registry + last-known-IP cache |31-| chirp-identity.ts | 143 | Local device ID (UUID, storage.local) + device name |32-| health.ts | 170 | Advanced vault-health analysis (Pro): age/reuse/exposure scoring |33-| license.ts | 169 | Ed25519 license signature verification + Pro gate |34-| self-test.ts | 110 | Crypto KAT on unlock (PCH-01-020 pinned vector) |35-| sync-alerts.ts | 77 | Pending sync-alert queue (HV-1b) |36-| pending-vouched-devices.ts | 81 | MV-7 queue for vouched devices awaiting user confirmation |37-| commands.ts | 69 | Keyboard-shortcut → summon-inline routing |38-| entry.ts | 73 | MV3 lock-on-close port (LV-5 nonce'd port) |39-| offscreen.ts | 43 | Offscreen-document clipboard clear (HV-2) |40-| recovery-pending.ts | 33 | Transient recovery-confirm flag (suppresses lock-on-close) |41-42-### Content / Autofill (`src/content/`) — the injection layer43-44-| Module | Lines | Purpose |45-|--------|-------|---------|46-| inject.ts | 4405 | Inline UI rendering: `showAutofillSelector`, `injectFieldIcon`, `showTOTPSelector`, `showCardSelector`, passkey toasts/gate/offer, remember-site toast, smart-generator drawer, anchor positioning |47-| index.ts | 1263 | Autofill orchestration: `handleFieldInteraction`, 3-layer dismissal model, fill-success lock, focusin/mutation observers, `initialScan` aggression levels, TOTP/card paths, message routing (`SUMMON_AUTOFILL`/`VAULT_UNLOCKED`/passkey), search palette, Firefox passkey bridge |48-| detect.ts | 357 | Field/form detection: login/signup/email-first/TOTP/card detection, deep active-input, visibility, CSS.escape (PCH-01-025) |49-| passkeys-firefox.ts | 325 | Firefox MAIN-world `navigator.credentials` interception proxy |50-| save.ts | 309 | Save-on-submit detection (offer to save new credentials) |51-| content-ml-detector.ts | 190 | Content-side ML field-prediction singleton |52-| fill.ts | 136 | Field filling: `fillFields` (cross-origin-safe), `setNativeValue` (React), card fill, Luhn/network |53-| policy.ts | 130 | Site password-policy extraction (min/max/forbidden chars) |54-| passkeys-firefox-relay.ts | 68 | MAIN↔content allowlisted postMessage relay |55-| identity-helper.ts | 58 | Saved-identity sort/filter for signup surface |56-| interaction.ts | 40 | `resolveAutofillInteractionTargets` (which fields/whether to open) |57-| passkey-toast/* | ~300 | Preact passkey approval toast (inject.tsx, PasskeyToast.tsx, css) |58-59-### Crypto (`src/crypto/`)60-61-| Module | Lines | Purpose |62-|--------|-------|---------|63-| peachPayload.ts | 1080 | Codex/backup payload: Argon2id `deriveKey` + NFC, AES-GCM/ChaCha, zstd, BLAKE3 checksum, section encoding, decompression bounds |64-| argon2.ts | 52 | Argon2id KDF with NFC normalize + raw fallback |65-| zstd.ts | 51 | zstd compress/decompress with output-size cap (MV-4) |66-| totp.ts | 40 | TOTP generation + `parseOtpauthURI` (algorithm/digits/period) |67-| peachScript.ts | 64 | PUA (U+E000..E0FF) byte↔char codec |68-69-### Shared (`src/shared/`)70-71-| Module | Lines | Purpose |72-|--------|-------|---------|73-| import.ts | 605 | Multi-format import (1Password/Bitwarden/ProtonPass/Credential-Exchange/CSV/Peach) |74-| types.ts | 543 | Cross-boundary type definitions (no runtime logic) |75-| backup.ts | 295 | Peach encrypted backup export/import (canonical NFC raw-retry pattern) |76-| codex-types.ts | 288 | Codex payload types + `computeParityShards`/`deriveShardCounts` |77-| recovery-word-list.ts | 275 | BIP39 2048-word English list |78-| chirp-sync-transport.ts | 292 | WebSocket client + Noise XX + subnet probe (rate-limited) |79-| chirp-pairing.ts | 291 | Audio pairing driver (chirp exchange → Noise NK) |80-| chirp-handshake.ts | 261 | Post-chirp NK + identity frame exchange + SAS derivation |81-| noise-xx.ts | 260 | Noise XX mutual-auth handshake (25519/ChaChaPoly/BLAKE2s) |82-| sync-identity.ts | 189 | Vault-tied X25519/Ed25519 derivation + vault ID |83-| ggwave.ts | 187 | WASM audio-codec loader + ChirpCodec wrapper |84-| shortcuts.ts | 185 | Keybinding schema/format/match/conflict |85-| recovery-words.ts | 181 | BIP39 recovery (12-word ↔ root key) + HKDF KEK (128-bit prefix) |86-| noise-js.ts | 422 | Noise NK handshake + post-handshake HMAC auth proof |87-| device-manifest.ts | 221 | Signed device-graph manifest + freshness window |88-| chirp-payload.ts | 139 | 64-byte audio chirp payload codec (CRC32) |89-| generate.ts | 75 | CSPRNG password generator (rejection sampling, ≤128 chars) |90-| favicon.ts | 95 | Favicon URL + fallback initials/colors |91-| domain.ts | 49 | eTLD+1 extraction (tldts) + homograph detection (LV-2) |92-| browser.ts | 34 | Feature-detected Chrome/Firefox identification |93-| passkey-utils.ts | 53 | WebAuthn rpId validation (SEV-1-A) |94-| sync-limits.ts / import-limits.ts | 68 | Resource bounds (MV-3/MV-4/HV-1 clamp) |95-| Others (ml, storage, config, bridge, native, passkey-flags, local-network) | ~250 | Thin wrappers / constants / types |96-97-### Popup UI (`src/popup/`)98-99-| Module | Lines | Purpose |100-|--------|-------|---------|101-| Settings.tsx | 2146 | Settings: password change, recovery words, license, feedback, appearance, sync |102-| Entry.tsx | 1779 | Entry view/edit: copy, TOTP, inline edit, identity |103-| App.tsx | 1707 | Root: routing, vault state, message dispatch |104-| Lock.tsx | 1058 | Unlock/create vault, master-password strength, restore options |105-| Import.tsx | 912 | Import file parse + mode selection |106-| Add.tsx | 764 | Create new entry |107-| Vault.tsx | 733 | Vault list, search, sort |108-| codex/pdf-import.ts | 703 | Codex PDF → Aztec extraction (MV-5/MV-9/PCH-01-014) |109-| CodexRestoreView.tsx | 694 | Codex restore flow (PCH-01-015) |110-| Export.tsx / Health.tsx / ChirpSync.tsx | 1576 | Export, health dashboard, sync UI |111-| codex/generate.ts | 484 | Codex generation pipeline (compress→erasure→Aztec→PDF) |112-| codex/{restore,erasure,aztec,pdf,reed-solomon,helpers}.ts | ~1480 | Codex erasure/restore internals |113-| Recovery/Notes/Trash/Panic/Onboarding views | ~1400 | Recovery words, notes editor, trash, panic, onboarding |114-| components/{GeneratorDrawer,ChirpPairingFlow,Rune*,Note*}.tsx | ~1700 | Generator drawer, pairing flow, rune canvases, note components |115-| chirp-pair.ts / chirp-pair-capacitor.ts | 557 | Pairing UI driver (gateOnSAS) |116-117----118-119-## Section 2 — Test Suite Inventory120-121-### Unit / component tests (`src/`, Vitest) — 1,062 declared122-123-| Test File | Tests | Covers |124-|-----------|-------|--------|125-| background/vault.test.ts | 76 | Unlock/lock, entry CRUD, password change, duress, biometric unlock, MV-6 whitelist, LV-1 NFC, PCH-01-008/009 |126-| background/messages.test.ts | 51 | All message types, MV-1/MV-2, LV-3/LV-4, TOTP strip, pro gate |127-| background/messages.adversarial.test.ts | 55 | Origin spoofing, PCH-01-006/007, PCH-01-018 destructive-op gating |128-| background/vault.adversarial.test.ts | 44 | Password boundary cases (unicode/emoji/RTL/control/injection), batchAdd invalid |129-| background/recovery.adversarial.test.ts | 27 | Recovery word tamper at every position, garbage rejection, password edge cases |130-| background/recovery-words.test.ts | 26 | 12-word round-trip, validation, recovery cycles, survives changePassword |131-| background/chirp-sync-vault-adapter.test.ts | 23 | MV-3 bounds, MV-7 queue, MV-8d key-rotation, PCH-01-005 separator, entry round-trip |132-| background/passkey-chrome-ceremony.test.ts | 23 | Chrome create/get, isUvpaa, locked-vault wait, origin resolution |133-| background/vault-history.test.ts | 14 | Snapshots, PCH-01-022 bucket coarsening, dedup |134-| background/paired-devices.test.ts | 12 | Upsert/find/remove, IP cache, legacy compat |135-| background/passkey-firefox-proxy.test.ts | 12 | Firefox create/get, cross-origin rejection, enumeration protection |136-| background/clipboard.test.ts | 11 | HV-2 alarms+offscreen, PCH-01-021 cold-start hash, re-arm |137-| background/chirp-sync-runner.test.ts | 11 | Locked check, IP cache, subnet probe, vault-id mismatch |138-| background/notes.test.ts | 11 | Note CRUD/sanitization |139-| background/sync-trigger.test.ts | 11 | Per-device sync, concurrency guard, MV-7 filter |140-| background/chirp-sync-static.test.ts | 9 | Static sync helpers |141-| background/health.test.ts | 7 | Reuse/age scoring, hashes-never-stored |142-| background/codex-handlers.test.ts | 7 | Pro/locked gates, PCH-01-017 validation |143-| background/chirp-sync-coordinator.test.ts | 7 | HELLO, version short-circuit, clock skew, full sync |144-| background/passkey-crypto.test.ts | 7 | ES256 keypair, authenticator data, COSE |145-| background/alarms.test.ts | 6 | Auto-lock alarm, idle lock |146-| background/vault.sync-clamp.test.ts | 6 | HV-1 clamp + REMOTE_PASSWORD_CHANGE/DELETE alerts |147-| background/aes.test.ts | 5 | AES-GCM primitives |148-| background/chirp-identity.test.ts | 3 | PCH-01-004 deviceId local + migration |149-| background/{self-test,passkey-deny-on-close,entry.lock-port,session-escrow}.test.ts | 8 | KAT (PCH-01-020), deny-on-close, LV-5 port, PCH-01-008 escrow |150-| crypto/peachPayload.test.ts | 25 | Entry round-trips, AEAD, padding, PCH-01-001 NFC (5 cases), MV-4 |151-| crypto/peachScript.test.ts | 24 | 256-byte codec, PUA validation, fuzz corpus |152-| crypto/{argon2,totp,crypto,decompression-bomb}.test.ts | 22 | LV-1 NFC parity, TOTP params, MV-4 caps |153-| content/content.adversarial.test.ts | 47 | extractDomain boundaries, field-detection adversarial, fillFields safety, Luhn |154-| content/detect.test.ts | 15 | Login/signup/TOTP/card detection, PCH-01-025 CSS.escape |155-| content/policy.test.ts | 11 | Password-policy extraction |156-| content/passkey-toast/passkey-toast.test.ts | 10 | Passkey toast rendering |157-| content/fill.test.ts | 9 | fillFields, cross-origin refusal, card fill |158-| content/passkeys-firefox-relay.test.ts | 6 | Relay allowlist, cross-frame block |159-| content/{identity-helper,interaction}.test.ts | 8 | Identity sort/filter, interaction targets |160-| content/inject.keyguard.test.ts | 2 | **PCH-01-007 isTrusted gating — the ONLY test touching inject.ts** |161-| popup/codex/codex.adversarial.test.ts | 47 | parseRawScan/ChunkBuffer/reconstruct adversarial, PCH-01-010/011/012/013/026 |162-| popup/codex/codex.test.ts | 26 | Compression, erasure, verify code, handlers |163-| popup/codex/codex.suite.test.ts | 21 | Chunk/stripe planning, Aztec import, bundle hygiene |164-| popup/codex/{generate,restore,pdf-import,pdf-import.clamp,browser-bundle}.test.ts | 59 | Erasure pipeline, restore, PDF import, PCH-01-014 clamp, MV-5 |165-| popup/views/contracts.test.tsx | 21 | Lock/Export/Health/Entry/Import/Recovery/Vault/Settings contracts |166-| popup/views/NoteEditor.toHtml.test.ts | 21 | Note HTML sanitization (THREAT-006) |167-| popup/views/{vault-search,Export.csv,zxcvbn-strength,CodexRestoreView,CodexGenerateView,Health}.test | 13 | Search/sort, CSV injection, LV-8 zxcvbn, PCH-01-015 |168-| popup/components/GeneratorDrawer.test.ts | 24 | Generator policy constraints (≤128) |169-| popup/components/ChirpPairingFlow.test.tsx | 9 | Pairing phases |170-| popup/{chirp-pair,chirp-pair-capacitor}.test.ts | 14 | PCH-01-024 gateOnSAS |171-| shared/import.test.ts | 17 | Bitwarden/ProtonPass/1Password/CredExchange/Peach, MV-5, PCH-01-019/026 |172-| shared/browser.test.ts | 17 | Chrome/Firefox detection + derivatives |173-| shared/shortcuts.test.ts | 15 | Keybinding match/format/conflict |174-| shared/sync-identity.test.ts | 13 | X25519/vault-id derivation determinism |175-| shared/{noise-xx,noise-js,noise-blake2s-kat}.test.ts | 15 | Full XX/NK handshakes, PCH-01-002/003/003b KATs |176-| shared/{chirp-handshake,chirp-pairing,chirp-payload,chirp-sync-transport}.test.ts | 35 | SAS, pairing, payload codec, subnet cooldown |177-| shared/{device-manifest,generate,backup,ggwave,passkey-utils,domain.homograph,favicon}.test.ts | 53 | Manifest sign/verify, generator, backup, ggwave, rpId (SEV-1-A), homograph (LV-2) |178-179-### End-to-end tests (`tests/`, Playwright, real Chromium + built extension) — 570 declared180-181-| Test File | Tests | Covers |182-|-----------|-------|--------|183-| vault-lifecycle.spec.ts | 55 | Create→unlock→lock→persist, SW restart |184-| keyboard-comprehensive.spec.ts | 44 | Global shortcuts, navigation |185-| settings-persistence.spec.ts | 42 | Settings survive reload (auto-lock, clipboard, theme) |186-| lock-screen.spec.ts | 37 | Master password, recovery words, lock/unlock |187-| entry-comprehensive.spec.ts | 35 | Field validation, notes, URLs, edit/delete |188-| visual-compliance.spec.ts | 36 | Animation/transition/visual-order compliance |189-| password-generator.spec.ts | 33 | Generator UI, entropy, integration |190-| autofill-deep.spec.ts | 32 | Icon injection on **synthetic** forms, matching, security |191-| vault-security.spec.ts | 32 | XSS/CSP/session (mix of behavioral + static grep) |192-| import-export-robust.spec.ts | 29 | CSV/JSON import/export, duplicates, unicode |193-| keyboard-edge.spec.ts | 29 | Focus traps, overlay behavior |194-| clipboard.spec.ts | 23 | Copy + timed clear |195-| search.spec.ts | 23 | Filtering, empty state, special chars |196-| a11y-totp-vault.spec.ts | 22 | ARIA labels, focus order |197-| totp-edge.spec.ts | 22 | Base32 parsing, invalid inputs |198-| security-visual.spec.ts | 21 | No exposed credentials, safe animations |199-| entry-flows-state.spec.ts | 18 | Add/edit/delete/copy/reveal/TOTP flows |200-| settings.spec.ts | 10 | Settings visibility, toggles |201-| {entry-crud,copy-totp}.spec.ts | 14 | CRUD, TOTP copy |202-| codex-pdf-restore.spec.ts | 4 | Codex PDF import (hard-coded local fixture path) |203-| autofill.spec.ts | 6 | Content-script injection smoke |204-| passkeys.spec.ts | 1 | One real Chrome create()+get() ceremony |205-| passkeys-firefox.spec.ts | 0 | **10 skipped** (gated on `PEACH_E2E_FIREFOX_PASSKEYS=1`) |206-| {visual-check,diagnostic}.spec.ts | 2 | Placeholders |207-208----209-210-## Section 3 — Coverage Assessment by Module211-212-### src/background/vault.ts213-**Rating:** Adequate214-**Tested:** unlock/lock, entry CRUD with storage-failure rollback, changePassword + session re-escrow (PCH-01-008), biometric unlock root-key rehydration (PCH-01-009), constant-time parallel-slot unlock, duress equal-length slots, monotonic timestamps, NFC normalize + legacy heal (LV-1), proto-pollution filter (LV-7), HV-1 clamp + alerts, snapshot bucketing (PCH-01-022), MV-6 import whitelist with passkey/canary shape + HTTPS canary check.215-**Not tested:**216-- `purgeExpiredTrash` retention cutoff — off-by-one would leak past-retention entries or erase early. No functional test.217-- `consolidatePasskeyEntries` merge heuristic — called after passkey registration; only exercised via the legacy-migration import path, never directly. Wrong winner = duplicate or wrong-credential entry.218-- `getIdentities` dedup logic for autofill identity suggestions — null-timestamp / domain-extraction-failure paths untested.219-- `checkBreach` HIBP k-anonymity (SHA-1 prefix/suffix) — network stubbed; encoding/parse never verified.220-- `restoreEntry`/`purgeEntry` (undelete / hard-delete) — no direct test; restore-after-restore monotonic case untested.221-- `consumeLastUnlockFailureReason` corruption signal (good tag / bad schema, M7) — never asserted; corruption vs wrong-password indistinguishable in test.222-- Concurrent writes (two `updateEntry` racing on the single storage key).223-**Risk:** Medium. The crown-jewel module's high-value mutating paths (changePassword, MV-6) are tested, but several destructive/lifecycle paths (trash purge, hard delete, passkey consolidation) and the corruption-vs-wrong-password discrimination are not.224-225-### src/background/messages.ts226-**Rating:** Strong227-**Tested:** all 51 message types, MV-1 (origin re-validation + explicitSummon + mismatch→not_found + unparseable→origin_unverified), MV-2 (includeAll behind userGesture), LV-3 (canary rate-limit), LV-4 (recovery-words re-auth), PCH-01-006/007 (EDIT origin), PCH-01-018 (4 destructive ops × content/extension matrix), PCH-01-019 (TOTP code generated, seed stripped), pro gate.228-**Not tested:** `GET_ENTRY_FOR_EDIT` via `sender.url` (only `sender.tab` exercised); `AUTOFILL_QUERY includeAll` against a **locked** vault; PCH-01-016 batch-add canary/tag sanitization (BATCH_ADD tested but not the sanitize path); forged `rpId`+`origin` match attempt on `GET_PASSKEY_FOR_DOMAIN`; IPv6 webhook-URL variants ([::1], fc00::/7).229-**Risk:** Low–Medium. PCH-01-016 batch sanitization is the notable security gap (see §4).230-231-### src/background/passkey.ts / passkey-crypto.ts232-**Rating:** Strong (Chrome) / Adequate (Ed25519, crypto edges)233-**Tested:** Chrome isUvpaa-when-locked, locked-vault wait, origin resolution (subdomain/exact/fallback), create/get ceremonies, cancel cleanup; Firefox cross-origin rejection (SEV-1-A), forged clientDataJSON.origin rejection, enumeration protection.234-**Not tested:** Ed25519 (-8) keypair round-trip (only -7); algorithm-preference order; `p1363ToDer` edge cases (r/s=0, high-bit boundaries); malformed `privateKeyJwk` import; rpId case-insensitivity match; `consolidatePasskeyEntries` ran-assertion after create.235-**Risk:** Medium — Ed25519 passkeys are a supported algorithm with zero round-trip coverage.236-237-### src/background/clipboard.ts / offscreen.ts / alarms.ts238-**Rating:** Strong / Adequate / Adequate239-**Tested:** HV-2 alarm arm + offscreen route + Firefox fallback, PCH-01-021 cold-start hash skip, re-arm on second copy, failed-clear re-arm; auto-lock alarm + idle lock.240-**Not tested:** offscreen `createDocument` non-"already-exists" error; `alreadyWritten=true` path; both clipboard APIs failing; `clearAutoLock`; idle `MIN_IDLE_SECONDS` clamp boundary; offscreen message-type validation; `navigator.clipboard` reject → execCommand fallback.241-**Risk:** Low.242-243-### src/background/{license,ml,sync-alerts,commands,self-test}.ts244-**Rating:** license **Weak**, ml **Missing**, sync-alerts **Weak**, commands **Weak**, self-test **Strong**245-**license.ts:** Ed25519 signature verification, base58 decode, prefix/checksum/length — **no direct tests** (only indirect pro-gate triggering). A broken verifier would not be caught.246-**ml.ts:** logistic-regression prediction — **no test file**; softmax stability, feature-vector completeness untested.247-**sync-alerts.ts:** queue filter/bound — no unit tests; tested only via vault.248-**commands.ts:** ping timeout, restricted-URL handling — no tests.249-**self-test.ts:** PCH-01-020 real-KAT divergence — Strong.250-**Risk:** Medium for license (a defeated signature check = free Pro / forged license with no test).251-252-### Sync cluster (chirp-*, noise-*, sync-identity, device-manifest, paired-devices)253-**Rating:** Strong across the board.254-**Tested:** full Noise XX **and** NK end-to-end handshakes (not just KATs), BLAKE2s cross-repo KAT byte-identical to Android (PCH-01-002), NK handshake-hash binding (PCH-01-003/003b), deviceId local+migration (PCH-01-004), per-entry HKDF separator (PCH-01-005), MV-3 bounds, MV-7 queue + sync-trigger filter, MV-8d key-rotation alert, SAS gate (PCH-01-024), PCH-01-018 pre-msg3 abort (in isolation).255-**Not tested:** subnet-probe→Noise-XX **integration** (pin check tested in isolation, not against a live wrong-peer responder — the PCH-01-018 threat surface end-to-end); WebSocket `onerror`/`onclose` handling (PCH-01-026 #9); timestamp-clamp and manifest-freshness boundary values; `rotateSyncIdentity` function itself (MV-8b — only the key-rotation *alert* is tested); chirp playback failure / mic discontinuity.256-**Risk:** Low–Medium. The one notable security gap is the absence of an end-to-end test that a probed wrong-peer never receives the long-term static (PCH-01-018).257-258-### src/crypto/peachPayload.ts259-**Rating:** Adequate260-**Tested:** entry/passkey/card/note round-trips, AEAD tamper, bucket padding, PCH-01-001 NFC (NFD-seal→NFC-open, NFC→NFC, codex-restore NFD, legacy raw fallback, CJK+combining), v3 email field.261-**Not tested:** malicious **declared-length > cap** rejection (MV-4 declared-length path is implicit, never asserted in payload context); malformed/truncated zstd frame; unknown-section skip; section exactly at 64 MiB boundary.262-**Risk:** Medium — the declared-length bomb guard in peachPayload is not directly exercised (only the zstd-output cap is, in `decompression-bomb.test.ts`).263-264-### src/crypto/totp.ts265-**Rating:** Weak266-**Tested:** param threading (SHA256/8/60), legacy defaults, `parseOtpauthURI`.267-**Not tested:** **no RFC 6238 known-answer vectors** for SHA1/SHA256/SHA512; time-step boundary roll; clock-skew window; digits/period range validation; base32 secret validation. Production correctness rests entirely on the `otpauth` library with no independent vector check.268-**Risk:** High for a TOTP feature — a library regression or wrong byte-order would ship silently. A wrong 2FA code is a hard user-facing failure.269-270-### src/crypto/{argon2,zstd,peachScript}.ts271-**Rating:** argon2 Adequate, zstd Strong (bounds) / Weak (format), peachScript Strong272-**Not tested:** Argon2id **KAT vectors** + parameter (m/t/p) verification — a silent param downgrade (e.g. memory cost) would not be caught; zstd malformed-frame handling; peachScript multi-MB payloads / Unicode-space normalization.273-**Risk:** Medium for argon2 — no test asserts the KDF actually runs at the intended cost.274-275-### src/shared/recovery-words.ts + recovery-word-list.ts276-**Rating:** Adequate / **Unaudited**277-**Tested:** 12-word round-trip, validation (case/whitespace), tamper at every position, recovery cycles, survives changePassword, 100-word BIP39 sampling.278-**Not tested:** **BIP39 checksum rejection** with a known-good-words/bad-checksum phrase (current "wrong words" test uses `Array(12).fill('abandon')` which is valid words but its checksum status is unasserted); **128-bit prefix-KEK construction** (PCH-01-005 recovery escrow) — no test that bytes 16–31 are zero-padded / that two roots differing only past byte 16 yield the same words; **word-list integrity** — no test that the 2048-word list is unique and byte-identical to the official BIP39 English list. A single transcription error would silently break recovery for affected phrases with no test catching it.279-**Risk:** High — recovery is the disaster path; the word-list integrity and checksum-rejection gaps are exactly where a silent, unrecoverable data-loss bug hides.280-281-### src/shared/import.ts + backup.ts282-**Rating:** Strong / Adequate283-**Tested:** Bitwarden/ProtonPass/1Password-8/CredentialExchange/Peach formats, format detection, LastPass grouping, MV-5 size cap, PCH-01-019 TOTP params, PCH-01-026 single-parse + trim fix; backup wrong-password/malformed/missing-field rejection.284-**Not tested:** **CSV formula injection** on the *import* side (Export side is covered by Export.csv.test.ts; import accepts `=cmd`/`@`/`+` verbatim); truncated CSV; **the LV-1 NFC raw-fallback in import.ts/backup.ts** — the `password !== password.normalize("NFC")` branch is never exercised with a genuinely non-NFC password, so the "old non-NFC backup still opens" guarantee is unverified at this layer; 1Password trashed-item skip; CredentialExchange invalid-PKCS8 path.285-**Risk:** Medium — the backup NFC raw-fallback is the canonical pattern PCH-01-001 was modeled on, yet its own decrypt path is untested (see §4).286-287-### src/shared/generate.ts288-**Rating:** Strong289-**Tested:** exact length, forbidden-char exclusion (all pools), fallback pool, smart-policy min/max clamp, rejection-sampling no-modulo-bias.290-**Not tested:** all-chars-forbidden → empty pool (potential infinite loop / crash); 128-char boundary; `minLength > maxLength` policy; NaN policy values.291-**Risk:** Low–Medium — the empty-pool hang is a real (if narrow) availability bug.292-293-### src/shared/domain.ts / passkey-utils.ts / browser.ts294-**Rating:** Strong.295-**Tested:** homograph detection (Cyrillic/punycode/bare/ASCII/malformed, LV-2); rpId exact/suffix/spoof/bare-eTLD/dotless/case (SEV-1-A); Chrome/Firefox detection + derivatives.296-**Not tested:** LV-2 UI *surfacing* of the warning (only the detector); extractDomain on IPs / multi-part TLDs; rpId with port / IPv6.297-**Risk:** Low.298-299-### Content / autofill layer300-See **Section 5** — assessed behavior-by-behavior.301-302-### Popup views (App, Settings, Entry, Lock, Import, Add, Vault, Export, Onboarding, Panic, Notes, Trash, ChirpSync)303-**Rating:** Weak (unit) — covered mainly by Playwright e2e.304-`contracts.test.tsx` (21 tests) is the only component-level coverage and verifies: Lock unlock/create + strength gate; Export surfaces; Settings change-password/recovery/license/feedback-XSS/Firefox-consent; Entry copy+canary/TOTP/inline-edit-blur/empty-password-reject; Vault search/sort; Import file-select/dup/replace/onboarding/encrypted-reparse; RecoveryWordEntry → RECOVER_WITH_WORDS. **Add, Onboarding, Panic, TrashView, ChirpSync, NotesEditor, RecoveryWordDisplay have zero component tests.** The 2,146-line Settings and 1,779-line Entry views have only the handful of contract assertions above.305-**Risk:** Medium — large stateful views rely entirely on e2e, which (see §5/§8) is happy-path-heavy.306-307-### Codex (popup/codex/*)308-**Rating:** Strong.309-**Tested:** erasure encode/decode with loss, ChunkBuffer state machine, parseRawScan/parseChunkEnvelope adversarial, reconstruct fingerprint checks, full single-stripe round-trip, all of PCH-01-010/011/012/013/014/015/026 + MV-5.310-**Not tested:** **multi-stripe restore** (generate tests multi-stripe; restore only single-stripe); **MV-9 pdf.js `isEvalSupported:false`/disableFontFace/Range/Stream flags** (no test asserts they're set); reed-solomon try/finally free on trap; full UI generate→PDF→restore round-trip.311-**Risk:** Medium — multi-stripe restore (large vaults) and the MV-9 sandbox flags are real gaps.312-313----314-315-## Section 4 — High-Risk Untested Paths316-317-### Untested: BIP39 word-list integrity + checksum rejection318-**File:** src/shared/recovery-word-list.ts, recovery-words.ts:110-113319-**Risk:** Recovery is the disaster-recovery path. No test verifies the 2048-word list is unique and byte-identical to the canonical BIP39 English list, and no test rejects a valid-words/invalid-checksum phrase. A single transcription error or a checksum-bypass would make some phrases silently unrecoverable — discovered only by a locked-out user.320-**Test:** (a) assert list length 2048, all unique, all 3–8 ASCII letters, SHA-256 of the joined list == known BIP39 digest; (b) take a valid 12-word phrase, swap the last word for another valid word, assert recovery fails on checksum.321-**Priority:** High322-323-### Untested: 128-bit prefix-KEK recovery-escrow construction (PCH-01-005)324-**File:** src/shared/recovery-words.ts:118-154325-**Risk:** Recovery words encode only the first 16 bytes of the 32-byte root key (bytes 16–31 zero-padded). If that invariant breaks, either recovery silently fails or the security claim (128-bit) is wrong. No test asserts the prefix/zero-pad behavior or the HKDF salt/info usage.326-**Test:** two root keys identical in bytes 0–15 but differing in 16–31 produce identical recovery words; HKDF output changes when salt changes.327-**Priority:** High328-329-### Untested: TOTP against RFC 6238 vectors330-**File:** src/crypto/totp.ts331-**Risk:** No independent verification of generated codes for SHA1/SHA256/SHA512. A library regression ships a wrong 2FA code with no test failing — a hard, visible breakage for every 2FA login.332-**Test:** RFC 6238 Appendix B vectors (secret `12345678901234567890`, known timestamps) for all three algorithms; verify period-boundary roll.333-**Priority:** High334-335-### Untested: peachPayload declared-length decompression bomb (MV-4)336-**File:** src/crypto/peachPayload.ts (pre-`decompressZstd` declared-length check)337-**Risk:** Only the zstd *output* cap is tested (`decompression-bomb.test.ts`). The declared-length-before-inflate guard in the payload parser — the first line of MV-4 defense on the Codex/import path — has no regression test and could be removed unnoticed.338-**Test:** payload claiming length > MAX_DECLARED_LENGTH throws before any decompression; sum-of-sections > MAX_DECOMPRESSED_BYTES rejected.339-**Priority:** High340-341-### Untested: backup/import NFC raw-fallback decrypt (LV-1 / PCH-01-001 sibling)342-**File:** src/shared/backup.ts:96-111, src/shared/import.ts:96-111343-**Risk:** This is the canonical normalize-with-raw-retry pattern PCH-01-001 cites. The `raw:true` fallback that opens pre-LV-1 non-NFC backups is never exercised with a genuinely non-NFC password. If the fallback is broken, users with non-ASCII passwords are permanently locked out of old backups.344-**Test:** seal a backup with raw (un-normalized) non-NFC password bytes → import → succeeds via raw fallback; NFC password still opens normally.345-**Priority:** High346-347-### Untested: Ed25519 passkey round-trip348-**File:** src/background/passkey-crypto.ts:164-238349-**Risk:** Ed25519 (-8) credentials are supported but only ES256 (-7) is tested. A broken PKCS8 import/export or COSE encoding for Ed25519 would let registration succeed and authentication silently fail.350-**Test:** generate -8 keypair → sign assertion → verify; export/re-import private key.351-**Priority:** Medium352-353-### Untested: license signature verification354-**File:** src/background/license.ts:94-99355-**Risk:** Ed25519 license verification has no direct test. A defeated/short-circuited check = forged Pro licenses, with nothing failing.356-**Test:** valid signature accepts; tampered payload / wrong signature / wrong prefix / bad length all reject.357-**Priority:** Medium358-359-### Untested: PCH-01-018 subnet-probe identity leak (end-to-end)360-**File:** src/shared/chirp-sync-transport.ts:132-140 (verifyRemoteStatic) + noise-xx361-**Risk:** The pin check is tested in isolation, but no integration test exercises a subnet scan finding a wrong-pubkey host and confirms the local long-term static is **not** sent before msg3. This is exactly the threat PCH-01-018 fixed.362-**Test:** simulate `openChirpSyncSession` against a responder whose static ≠ pinned → assert abort before msg3, no static transmitted.363-**Priority:** Medium364-365-### Untested: PCH-01-016 batch-add canary/tag sanitization (MV-6 on import/batch)366-**File:** src/background/messages.ts (BATCH_ADD_ENTRIES → sanitizeEntryDraft)367-**Risk:** MV-6 whitelist is tested on the restore path (vault.test.ts) but not on `BATCH_ADD_ENTRIES`/import, which is the actual attacker-reachable bulk surface. A non-HTTPS canary webhook or unbounded tags could slip in via import.368-**Test:** batch-add with a non-HTTPS canary webhook + oversized tag array → assert sanitized/rejected.369-**Priority:** Medium370-371-### Untested: MV-9 pdf.js sandbox flags372-**File:** src/popup/codex/pdf-import.ts:259373-**Risk:** No test asserts `getDocument` is called with `isEvalSupported:false`/`disableFontFace/Range/Stream`. A refactor could silently re-enable eval in the PDF parser (untrusted-PDF attack surface).374-**Test:** spy on `getDocument`, assert the hardened options object.375-**Priority:** Medium376-377----378-379-## Section 5 — The Autofill Injection Layer380-381-**Headline:** The autofill *logic helpers* (`detect`, `fill`, `policy`, `interaction`, `identity-helper`) are well unit-tested (≈90 tests). The two largest and most behaviorally complex files — **`inject.ts` (4,405 lines) and `index.ts` (1,263 lines)** — are almost entirely untested at the unit level. `inject.ts` has exactly **2 tests** (`inject.keyguard.test.ts`, PCH-01-007 isTrusted). `index.ts` has **no dedicated test file at all**. The e2e `autofill-deep.spec.ts` (32 tests) exercises icon injection but against `page.setContent()` synthetic HTML, not real pages, and does not drive the orchestration state machine in `index.ts`.382-383-Behavior-by-behavior:384-385-| Behavior (source) | Coverage | Notes |386-|---|---|---|387-| Field detection — login/signup/email-first/TOTP (detect.ts) | **Strong (unit)** | detect.test.ts + content.adversarial.test.ts (hidden/disabled/honeypot/0x0/types) |388-| Card form detection + Luhn + network (detect/fill) | **Strong (unit)** | content.adversarial + detect.test.ts (Stripe iframe, autocomplete, name/id heuristics) |389-| Domain matching (extractDomain) | **Strong (unit)** | content.adversarial — `github.com.evil.com`→`evil.com`, www/sub/case/co.uk, ~30 cases |390-| `fillFields` cross-origin-safe + React setNativeValue | **Strong (unit)** | fill.test.ts — refuses cross-origin form action, dispatches input/change |391-| CSS.escape on field ids (PCH-01-025) | **Strong (unit)** | detect.test.ts — special chars + quotes don't throw |392-| isTrusted keydown gating (PCH-01-007) | **Strong (unit)** | inject.keyguard.test.ts — untrusted Arrow/Enter/printable no-op |393-| `resolveAutofillInteractionTargets` shouldOpen logic | **Adequate (unit)** | interaction.test.ts — 4 cases (passive gate, detected fields, explicit email/password click) |394-| Password-policy extraction (policy.ts) | **Strong (unit)** | policy.test.ts — maxlength/pattern/nearby-text/forbidden-chars/no-leak |395-| Firefox passkey relay allowlist | **Strong (unit)** | passkeys-firefox-relay.test.ts — allowed/blocked types, cross-frame block, null payload |396-| **`handleFieldInteraction` orchestration (index.ts)** | **None (unit)** | The central state machine — ML-predict timeout race, field-type resolution, locked-vault prompt, signup-mode branch — untested except indirectly by e2e |397-| **3-layer dismissal model (index.ts: lastDismissedField, DISMISS_LOCK_MS, fill-success lock)** | **None** | Complex, regression-prone (the comments cite v1.4.14/§4.7/§4.12 prior regressions). Zero direct tests |398-| **`wasAlreadyFilled` / `recordFillSuccess` form+page-grace logic** | **None** | The "don't re-pop after fill" guarantee has no unit test |399-| **`initialScan` aggression levels (0/1/2) + manualSummonDomains recall** | **None** | The aggressive password-field injection on recall domains is untested |400-| **MutationObserver re-attach + 200ms debounce** | **None** | The "do not lower" debounce regression guard has no test |401-| **SUMMON_AUTOFILL routing (shortcut / passkey-unlock / search-palette)** | **None** | Message-handler branches untested |402-| **VAULT_UNLOCKED re-trigger vs passkeySummonActive** | **None** | The "no double-trigger race" claim is asserted only by comment |403-| **showAutofillSelector / showTOTPSelector / showCardSelector rendering + keyboard nav (inject.ts)** | **None** | 4,405 lines of inline-UI rendering, the lazy MV-2 search load, the smart-generator drawer — untested beyond the 2 keyguard tests |404-| **showRememberSiteToast, passkey toasts/offer/available (inject.ts)** | **None** | Untested |405-| TOTP digit-box fill + email-first flow + signup confirm-password fill | **None (unit)** | Logic lives in index.ts/inject.ts; not unit-covered |406-| **MV-1/MV-2 enforcement at the content boundary** | **Partial** | Enforced & tested in `messages.ts` (background side). The content-side caller (explicitSummon flags, lazy `loadSearchableEntries` thunk) is untested |407-408-**E2E reality (autofill-deep.spec.ts):** injects icons onto synthetic forms and checks locators/matching/security guards. It does **not**: submit forms and verify post-submit suppression; test email-first/two-step (Google/Microsoft) flows; test the inline chip dismissal model; test cross-origin iframe fill (the iframe test is synthetic and unverified); test against any real login page. As a result the content-script orchestration is effectively **uncovered** — neither unit nor realistic-integration.409-410-**Conclusion:** This is the highest-complexity, lowest-coverage area in the codebase. The pure functions are safe; the stateful glue that decides *when* to open/suppress/fill — historically the source of multiple regressions — is the single largest test gap.411-412----413-414-## Section 6 — PCH / Audit Fix Coverage415-416-Source reports (`peach-browser-security-audit.md`, `peach-browser-technical-report.md`) were draft-only and are no longer on disk; findings reconstructed from `LOG.md` and the two fix briefs in `~/Downloads`.417-418-### First-round audit (Fable, v3.7.x)419-420-| Finding | Fix | Regression test? | Quality |421-|---|---|---|---|422-| HV-1 | clampSyncTimestamp + sync alerts | **Yes** | Strong — `vault.sync-clamp.test.ts` clamp + REMOTE_PASSWORD_CHANGE + REMOTE_DELETE |423-| HV-2 | clipboard via chrome.alarms + offscreen | **Yes** | Strong — `clipboard.test.ts` arm/route/fallback |424-| MV-1 | GET_ENTRY_FOR_FILL origin re-validation | **Yes** | Strong — `messages.test.ts` 4 cases (match/mismatch/summon/unparseable) |425-| MV-2 | includeAll behind userGesture | **Yes** | Strong — `messages.test.ts` strip-without-gesture + full-with-gesture |426-| MV-3 | sync frame/count/size bounds | **Yes** | Strong — `chirp-sync-vault-adapter.test.ts` all bounds |427-| MV-4 | decompression bomb caps | **Partial** | zstd output cap tested; **declared-length cap in peachPayload NOT tested** |428-| MV-5 | import file-size limits | **Yes** | Strong — import/backup/pdf-import tests |429-| MV-6 | import whitelist (sanitizeRestoredEntry) | **Partial** | Restore path tested; **batch-add/import path (PCH-01-016) NOT directly tested** |430-| MV-7 | vouched-device confirmation | **Yes** | Strong — adapter queue + sync-trigger filter |431-| MV-8 | rotateSyncIdentity + key-rotation notify | **Partial** | DEVICE_KEY_ROTATED tested; **`rotateSyncIdentity` itself NOT directly tested** |432-| MV-9 | pdf.js isEvalSupported:false | **No** | **No test asserts the hardened getDocument options** |433-| LV-1 | NFC normalize master password | **Yes** | Strong — `argon2.test.ts` parity + `vault.test.ts` heal |434-| LV-2 | homograph warning | **Partial** | `detectHomograph` tested; **UI surfacing untested** |435-| LV-3 | canary rate-limit | **Yes** | Strong — `messages.test.ts` 3 cases |436-| LV-4 | GET_RECOVERY_WORDS re-auth | **Yes** | Strong — `messages.test.ts` 3 cases |437-| LV-5 | dedup onConnect + nonce port | **Yes** | Adequate — `entry.lock-port.test.ts` 2 cases |438-| LV-6 | lockfile standardization | n/a | Build config — no test (acceptable) |439-| LV-7 | ggwave new Function + proto-pollution | **Partial** | Proto-pollution tested (`vault.test.ts`); **ggwave new Function removal verified only by build artifact, no test** |440-| LV-8 | zxcvbn → @zxcvbn-ts/core | **Yes** | Strong — `zxcvbn-strength.test.ts` |441-| LV-9 | exact-pin crypto deps | n/a | package.json — no test (acceptable) |442-443-### Second-round technical report (Fable, v3.8.1 → PCH-01-001..026)444-445-| Finding | Regression test? | Quality |446-|---|---|---|447-| PCH-01-001 NFC export/restore + raw fallback | **Yes** | Strong — 5 cases in `peachPayload.test.ts`. (Gap: backup.ts/import.ts sibling raw-fallback untested — §4) |448-| PCH-01-002 Noise BLAKE2s | **Yes** | Strong — `noise-blake2s-kat.test.ts` cross-repo byte-identical |449-| PCH-01-003 / 003b NK handshakeHash binding | **Yes** | Strong — `noise-js.test.ts` binds + rejects-on-modify |450-| PCH-01-004 deviceId local + migration | **Yes** | Strong — `chirp-identity.test.ts` |451-| PCH-01-005 HKDF null separator | **Yes** | Strong — adapter round-trip + legacy-fails-closed |452-| PCH-01-006/007 GET_ENTRY_FOR_EDIT origin | **Yes** | Strong — `messages.adversarial.test.ts` 4 cases |453-| PCH-01-007 inject isTrusted keydown | **Yes** | Adequate — `inject.keyguard.test.ts` 2 cases |454-| PCH-01-008 re-escrow after changePassword | **Yes** | Adequate — `vault.session-escrow.test.ts` |455-| PCH-01-009 biometric rootKey rehydrate | **Yes** | Adequate — `vault.test.ts` |456-| PCH-01-010 deriveShardCounts cap | **Yes** | Strong — `codex.adversarial.test.ts` |457-| PCH-01-011 stripe-count cap | **Yes** | Strong — `codex.adversarial.test.ts` |458-| PCH-01-012 isInteger on i/t/s/S | **Yes** | Strong — `codex.adversarial` + `pdf-import.test.ts` |459-| PCH-01-013 fingerprint length | **Yes** | Strong — `codex.adversarial.test.ts` |460-| PCH-01-014 PDF page/dimension clamp | **Yes** | Strong — `pdf-import.clamp.test.ts` |461-| PCH-01-015 untrimmed passphrase | **Yes** | Strong — `CodexRestoreView.test.tsx` (untrimmed + all-whitespace) |462-| PCH-01-016 sanitizeCanary/Tags in batch | **No** | **No direct batch-sanitization test (MV-6 batch surface)** |463-| PCH-01-017 handleRestoreFromCodex validation | **Yes** | Strong — `codex-handlers.test.ts` (malformed code, NaN ts, oversize) |464-| PCH-01-018 destructive-op gating | **Yes** | Strong — `messages.adversarial.test.ts` 4 ops × matrix |465-| PCH-01-018 subnet-probe identity defer | **Partial** | Pin tested in isolation; **end-to-end probe→wrong-peer untested** |466-| PCH-01-019 TOTP otpauth params | **Partial** | Param threading tested; **no RFC vectors, -8/SHA512 path** |467-| PCH-01-020 self-test KAT | **Yes** | Strong — `self-test.test.ts` divergence |468-| PCH-01-021 clipboard cold-start hash | **Yes** | Adequate — `clipboard.test.ts` |469-| PCH-01-022/023 history entryCount bucket | **Yes** | Strong — `vault-history.test.ts` |470-| PCH-01-024 gateOnSAS | **Yes** | Adequate — `chirp-pair.test.ts` |471-| PCH-01-025 CSS.escape detect.ts | **Yes** | Strong — `detect.test.ts` |472-| PCH-01-026 (10 items) | **Partial** | ChunkBuffer never-throws ✓, out-of-range≠duplicate ✓, CSV single-parse ✓, SAVE_NOTE typed (implicit). **Untested: save.ts dead-observer removal, reed-solomon try/finally, generate.ts step renumber, pdf-import progress total, chirp-sync-transport WS-error handling** |473-474-**Fixes with NO or only weak regression test (revert-risk):** MV-9 (none), PCH-01-016 (none), LV-7 ggwave (build-only), MV-4 declared-length (none), MV-8 rotateSyncIdentity (none), LV-2 UI surfacing (none), PCH-01-018 probe-integration (isolation only), PCH-01-019 RFC vectors (none), several PCH-01-026 sub-items.475-476----477-478-## Section 7 — Error Path Coverage479-480-| Error path | Tested? | How well |481-|---|---|---|482-| Import malformed/truncated data | **Partial** | backup.ts rejects malformed JSON; **truncated CSV / mid-parse corruption untested**; import.ts returns "unknown" on bad format but graceful-failure not asserted |483-| Import wrong backup password | **Yes** | `backup.test.ts` + `import.test.ts` reject wrong password (note: masks non-tag crypto errors as "INVALID_EXPORT_PASSWORD") |484-| Vault unlock wrong password | **Yes** | `vault.test.ts` + `lock-screen.spec.ts`; **but corruption (good tag/bad schema) vs wrong-password not distinguished in test** |485-| Network failure in feedback/canary | **Partial** | Canary rate-limit tested; actual fetch failure / HIBP network error not e2e tested (stubbed) |486-| Service-worker eviction mid-operation | **Weak** | `vault-lifecycle.spec.ts` covers SW-restart→lock-screen; **eviction during create/import/clipboard-timer/autofill untested** |487-| Sync peer disconnect mid-transfer | **Weak** | WebSocket onerror/onclose code present but **not regression-tested** (PCH-01-026 #9) |488-| Codex scan with damaged/partial code | **Strong** | `codex.adversarial.test.ts` — tampered/missing/parity-only/excess-loss, fingerprint mismatch |489-| Autofill on page with no password fields | **Partial** | index.ts has the path (search-palette / email-first); **not unit-tested**; e2e covers some no-form cases |490-| Entry with all optional fields empty | **Partial** | `peachPayload.test.ts` "handles missing optional fields" round-trips; backup.ts empty-password contradiction (defaults `""` then validator rejects) untested |491-492-**Pattern:** Error *inputs* (malformed crypto/codex/sync payloads) are well covered by the adversarial suites. Error *conditions* (eviction, disconnect, network failure, partial writes) are weakly covered because they require infrastructure (SW termination, two-device sync, network mocking) the suite doesn't have.493-494----495-496-## Section 8 — Integration vs Unit Balance497-498-- **Unit:** ~1,062 tests, the bulk of the suite. Crypto, sync, message-router, codex, detect/fill — all strongly unit-tested. This is where the suite is strongest.499-- **E2E (Playwright):** ~570 tests against the real built extension in Chromium. Covers vault lifecycle, entry CRUD, settings persistence, keyboard, lock screen, clipboard, generator, search well. This is genuine integration for the **popup**.500-- **Behavioral/contract:** `contracts.test.tsx` (21) is the only component-contract layer; thin relative to the ~12k LOC of popup views.501-502-**Assessment:** For a password manager, the interesting bugs are in component *interactions*, and the suite has two systemic integration gaps:503-504-1. **The content-script ↔ page boundary** is not integration-tested against real pages. `autofill-deep.spec.ts` uses synthetic `setContent` HTML and never drives `index.ts`'s state machine or a real form submit. The single highest-value integration test — *create vault → visit a real login page → autofill → submit → verify suppression* — does not exist.505-2. **Cross-device sync** (chirp pairing, Noise handshake, manifest exchange) is exhaustively **unit**-tested but has **zero** end-to-end coverage (it needs two devices + mDNS). The Noise handshakes are tested initiator↔responder in-memory, which is the best achievable without hardware, but the full pairing→sync→merge flow across two real instances is unverified.506-507-The suite is **not** "mostly unit tests" in volume terms (e2e is a third of it), but the e2e coverage is concentrated on the popup and thin exactly where integration matters most (autofill on real pages, sync across devices). It skews happy-path: lots of "does X work" and few "does X fail gracefully / does X suppress correctly after Y."508-509----510-511-## Section 9 — Prioritized Gap List512-513-### Priority 1 — Must Have Before Audit514-515-**T-001: BIP39 word-list integrity check**516-File: src/shared/recovery-word-list.ts — assert length 2048, all unique, all 3–8 ASCII letters, SHA-256(joined) == canonical BIP39 English digest. Why P1: silent transcription error = unrecoverable vaults; no test guards it.517-518-**T-002: BIP39 checksum rejection**519-File: src/shared/recovery-words.ts:110-113 — valid 12 words with a deliberately wrong checksum (swap last word) must fail recovery. Why P1: PCH-01-005 escrow integrity; current "wrong words" test uses all-valid words with unasserted checksum.520-521-**T-003: 128-bit prefix-KEK invariant (PCH-01-005)**522-File: src/shared/recovery-words.ts:118-154 — two root keys equal in bytes 0–15, differing in 16–31, yield identical recovery words; bytes 16–31 zero-padded on reconstruct. Why P1: silent escrow regression / wrong security claim.523-524-**T-004: TOTP RFC 6238 known-answer vectors (PCH-01-019)**525-File: src/crypto/totp.ts — RFC 6238 App. B vectors for SHA1/SHA256/SHA512 at fixed timestamps; period-boundary roll. Why P1: a wrong 2FA code is a hard user failure with no current guard.526-527-**T-005: peachPayload declared-length bomb (MV-4)**528-File: src/crypto/peachPayload.ts — payload declaring length > MAX_DECLARED_LENGTH throws before decompress; section-sum > MAX_DECOMPRESSED_BYTES rejected. Why P1: first-line MV-4 defense on import/codex, no regression test.529-530-**T-006: backup/import NFC raw-fallback decrypt (LV-1/PCH-01-001 sibling)**531-File: src/shared/backup.ts:96-111, import.ts:96-111 — seal with raw non-NFC password → import succeeds via raw fallback; NFC password still opens. Why P1: canonical pattern, data-loss risk on disaster path, never exercised.532-533-**T-007: MV-9 pdf.js sandbox flags**534-File: src/popup/codex/pdf-import.ts:259 — spy on getDocument, assert isEvalSupported:false + disableFontFace/Range/Stream. Why P1: untrusted-PDF eval surface, zero test.535-536-**T-008: PCH-01-016 batch-add canary/tag sanitization**537-File: src/background/messages.ts BATCH_ADD_ENTRIES — non-HTTPS canary webhook + oversized tags via batch/import → sanitized or rejected. Why P1: MV-6's actually-attacker-reachable surface, untested.538-539-**T-009: license Ed25519 signature verification**540-File: src/background/license.ts:94-99 — valid accepts; tampered payload / wrong sig / wrong prefix / bad length reject. Why P1: defeated check = forged Pro, no test.541-542-**T-010: PCH-01-018 subnet-probe identity-leak integration**543-File: src/shared/chirp-sync-transport.ts:132-140 — probe finds wrong-pubkey host → abort before msg3, no local static transmitted. Why P1: the exact threat PCH-01-018 fixed, only isolation-tested.544-545-### Priority 2 — High Value546-547-**T-011: index.ts dismissal model** — unit-test the 3 layers (lastDismissedField per-field lock; DISMISS_LOCK_MS global lock; fill-success `wasAlreadyFilled` form+page-grace). Drive focusin/Escape/click-outside sequences; assert no reopen after fill, no reopen on rapid cross-field focus. (Refactor index.ts to export the predicate functions.) Why P2: historically regression-prone (v1.4.14, §4.12), zero coverage.548-549-**T-012: index.ts handleFieldInteraction orchestration** — ML-predict 150ms timeout race, locked-vault prompt, signup-mode (no domain entries → identities + confirm-password), email-first (no pwField) path. Why P2: central autofill decision logic, untested.550-551-**T-013: index.ts initialScan aggression levels** — level 0 (no form → skip), level 1, level 2 final-pass injecting bare password fields; manualSummonDomains recall mapping. Why P2: aggressive injection on recall domains untested.552-553-**T-014: SUMMON_AUTOFILL / VAULT_UNLOCKED message routing (index.ts)** — shortcut vs passkey-unlock vs search-palette branch; VAULT_UNLOCKED re-trigger vs passkeySummonActive (no double-trigger). Why P2: message-handler branches untested.554-555-**T-015: Ed25519 (-8) passkey round-trip** — generate → sign → verify → export/re-import. Why P2: supported algorithm, only ES256 tested.556-557-**T-016: MV-8 rotateSyncIdentity** — rotate generates new root/syncMasterKey, clears paired devices, old key can't decrypt. Why P2: lost/stolen-device revocation, function untested.558-559-**T-017: Codex multi-stripe restore** — generate a >200-shard (2-stripe) codex, restore, assert vault matches. Why P2: large-vault restore path, only single-stripe tested.560-561-**T-018: vault corruption vs wrong-password discrimination** — unlock with good AES-GCM tag but bad schema → consumeLastUnlockFailureReason signals corruption (M7), distinct from wrong-password. Why P2: user-facing error correctness.562-563-**T-019: Argon2id KAT + parameter assertion** — pin a known (password, salt) → expected key with m/t/p; assert cost params can't silently downgrade. Why P2: KDF strength unverified.564-565-**T-020: WebSocket sync-disconnect handling (PCH-01-026 #9)** — onerror/onclose mid-handshake → session aborts cleanly, parked receive() rejects, no hang. Why P2: peer-disconnect resilience, no test.566-567-**T-021: CSV formula-injection on import** — import CSV with `=cmd`/`@`/`+`/`-` prefixed fields → values sanitized or flagged (mirror Export.csv coverage on the import side). Why P2: known injection class, import side uncovered.568-569-**T-022: GET_ENTRY_FOR_EDIT via sender.url + locked-vault includeAll** — EDIT origin check through `sender.url` (not just tab); AUTOFILL_QUERY includeAll on a locked vault returns vault_locked before processing. Why P2: untested branches of tested fixes.570-571-**T-023: vault.consolidatePasskeyEntries** — after passkey create, same-domain TOTP/login entries merge with correct winner (lastUsed ranking). Why P2: called on every registration, untested directly.572-573-**T-024: vault.purgeExpiredTrash retention cutoff** — entries older than retention purged, within-window kept, boundary exact. Why P2: data-deletion correctness.574-575-### Priority 3 — Good To Have576-577-**T-025: generate.ts empty-pool guard** — all chars forbidden → no infinite loop / defined error; 128-char boundary; minLength>maxLength policy. 578-**T-026: timestamp-clamp + manifest-freshness boundaries** — now+SKEW±1ms; issuedAt at exact freshness edge. 579-**T-027: full Codex UI round-trip e2e** — generate → download PDF → re-upload → restore → vault matches (replace hard-coded fixture path with a generated one). 580-**T-028: autofill real-page e2e** — host a fixture login page (with form submit), assert icon→fill→submit→post-submit suppression; email-first two-step; cross-origin iframe. 581-**T-029: inject.ts selector keyboard nav** — Arrow/Enter selection, search box (MV-2 lazy load fires GET on gesture), Escape dismiss — beyond the isTrusted guard. 582-**T-030: ml.ts predictBatch** — empty/single/large batches, softmax stability with extreme weights. 583-**T-031: SW eviction mid-operation** — terminate SW during import/clipboard-timer, assert recovery. 584-**T-032: backup.ts empty-password contradiction** — login entry with no password: define and assert behavior (currently defaults `""` then validator rejects). 585-**T-033: Firefox passkey e2e** — un-skip `passkeys-firefox.spec.ts` in CI or document the manual gate. 586-**T-034: LV-2 homograph UI surfacing** — non-ASCII URL saved → warning rendered in entry edit. 587-**T-035: HIBP breach-check integration** — mock endpoint, verify k-anonymity (5-char SHA-1 prefix sent, suffix matched). 588-**T-036: reed-solomon try/finally free (PCH-01-026 #8)** — WASM instance freed on reconstruct trap. 589-**T-037: clipboard double-failure / alreadyWritten path** — both clipboard APIs fail; alreadyWritten=true skips write. 590-**T-038: favicon canvas fallbacks** — titleInitials/titleBackground determinism, tainted-canvas error handling. 591-**T-039: chirp pairing failure paths** — playback failure, mic discontinuity, replay of captured chirp (nonce defeats it). 592-**T-040: sync alert queue bounds** — sync-alerts MAX_PENDING boundary, malformed-alert filtering.593-594----595-596-## Section 10 — Summary597-598-**The numbers:** 109 test files, ~1,632 declared tests (~3,200 executed after parameterization) against ~44k LOC. By volume this is a well-tested codebase — roughly 1 declared test per 27 lines, with the security-critical primitives (crypto round-trips, Noise handshakes, codex erasure, message-router origin checks, recovery word cycles) genuinely well covered, and 24 of 26 PCH findings plus most audit findings carrying real regression tests.599-600-**The biggest coverage gap, in one sentence:** the 5,668-line content-script autofill orchestration (`index.ts` + `inject.ts`) — the most regression-prone code in the project — has essentially no unit coverage and only synthetic, state-machine-bypassing e2e coverage, so the logic deciding *when* to open, suppress, and fill is untested at exactly the layer where past regressions happened.601-602-**The one test that would buy the most confidence:** a real-page autofill e2e (T-028) — load an actual login form, autofill, submit, and assert the popup is suppressed afterward — because it exercises `index.ts`'s entire decision/dismissal/fill-success machine end-to-end in one go, the single largest untested surface.603-604-**Runner-up by risk-density:** the recovery-path trio (T-001/T-002/T-003) — BIP39 list integrity, checksum rejection, and the 128-bit prefix-KEK invariant — because that is where a silent, *unrecoverable* data-loss bug would hide with no current test to catch it.605-606-**Readiness for a fresh external review:** Good on cryptography, sync protocol, codex, and the background message boundary — a reviewer will find these defensible and well-pinned. They will, correctly, flag (1) the autofill content-script layer as under-tested relative to its complexity and trust position, (2) the recovery/BIP39 integrity gaps on the disaster-recovery path, (3) TOTP lacking RFC vectors, and (4) a handful of fixes (MV-9, PCH-01-016, MV-4 declared-length, license verification) that are implemented but have no regression test and could be reverted unnoticed. Closing Priority 1 (T-001–T-010) before review would neutralize the most likely "this critical path has no test" findings; Priority 2 would close the autofill-orchestration gap that a thorough reviewer will otherwise raise.vault-adoption-design.md+0 −250
@@ -1,250 +0,0 @@1-# Vault Adoption — Second-Device Setup over SAS-Gated Pairing2-3-**Status:** Design spec (no code yet) — awaiting sign-off before crypto ships.4-**Author:** claude-opus-4-8, 2026-06-19.5-**Scope:** Let a brand-new device adopt an existing device's vault by pairing, so6-it ends up with the *same* root key → same `vaultId` → sync brings the entries,7-and Pro rides along. Decision taken: **transfer the root key over the8-SAS-gated Noise channel** (vs. an export bundle or recovery-words path).9-10-> This is the crown-jewels path: the 32-byte **root key** crosses the wire.11-> Everything else in this doc exists to make that one transfer safe.12-13----14-15-## 1. Why this is the only mechanism that actually works16-17-From the architecture (see file refs in §9):18-19-- Sync identity (X25519), manifest signing key (Ed25519), per-entry envelope20- keys, and `vaultId` **all derive from the 32-byte root key**21- (`vault.ts:getRootKeyHex`, `sync-identity.ts:computeVaultId`,22- `chirp-sync-vault-adapter.ts` per-entry HKDF).23-- The sync coordinator refuses to sync across **different `vaultId`s**24- (`chirp-sync-coordinator.ts` SYNC_HELLO check). So two devices must share the25- *exact* root key, or nothing syncs.26-- **Recovery words encode only `rootKey[0..16]` (128-bit)** and merely derive the27- key that *unwraps* the stored `recovery_wrapped_root_key` blob28- (`recovery-words.ts:23-26`). Words alone cannot reconstruct the root key on a29- device that has no vault blob.30-- Import/restore (`RESTORE_ENTRIES`) deliberately mints a **new** root key →31- new `vaultId` → does not sync. That's why "import a backup" is not adoption.32-33-**Conclusion:** adoption = move the existing root key to the new device. Once it34-has the key, the existing sync transport fills the entries — we do **not**35-re-transfer entries inline.36-37----38-39-## 2. Roles & terminology40-41-| Term | Meaning |42-|---|---|43-| **Source** | Existing device, vault unlocked, holds the root key. Grants adoption. |44-| **Receiver** | Brand-new (vault-less) device. Requests adoption, receives the root key, stands up a local vault around it. |45-| **Root key** | 32 random bytes; the secret everything derives from. Crown jewels. |46-| **Local password** | Per-device password the receiver chooses; only wraps the root key at rest on *that* device. Need not match the source's password. |47-48-The channel is **device↔device chirp** (same-room audio + Noise NK). The49-desktop helper (peach-sync) is **not** an adoption source — it stores opaque50-entry blobs and never holds the root key. (Post-adoption, the helper is still a51-valid *entry* source because the receiver can now decrypt its blobs.)52-53----54-55-## 3. UX flow56-57-**Receiver (new device):**58-1. Onboarding gains a third path beside "Create vault" / "Restore from backup":59- **"I already have Peach on another device."**60-2. Receiver sets a **local password** (wraps the adopted key on this device) and61- confirms it — same strength gate as create-vault.62-3. Receiver enters adoption-pairing (chirp, same room). Shows the SAS.63-4. On success: "Joined <Source>'s vault" → vault opens, sync begins filling entries.64-65-**Source (existing device):**66-1. While unlocked, a peer requests adoption → modal:67- **"A new device wants to join this vault. Verify the code matches: `1234`.68- Allowing this copies your entire vault and Pro to that device."**69-2. The SAS confirmation **is** the consent gate. Reject → abort, nothing sent.70-71----72-73-## 4. Protocol74-75-Built on the existing chirp stack: chirp ephemeral exchange → Noise NK →76-shared handshake hash → SAS. All adoption frames travel **inside the Noise77-transport** (ChaCha20-Poly1305) and are exchanged **only after both sides78-confirm the SAS**.79-80-The receiver is vault-less, so it has **no long-term identity** to put in a81-normal `PAIR_IDENTITY`. Adoption is therefore an asymmetric sub-protocol the82-receiver signals by sending `ADOPT_REQUEST` *instead of* `PAIR_IDENTITY`.83-84-### Frames (JSON, snake_case, browser/Android parity)85-86-```87-ADOPT_REQUEST { type:"ADOPT_REQUEST", device_id, device_name, device_type }88-ADOPT_GRANT { type:"ADOPT_GRANT",89- root_key_hex, // 64 lowercase hex chars (32 bytes)90- vault_id, // for receiver sanity-check91- license?, // signed PPK- blob, if source is Pro92- source: { device_id, device_name, device_type,93- long_term_public_key_hex, signing_public_key_hex,94- ws_port? } }95-ADOPT_IDENTITY { type:"ADOPT_IDENTITY", device_id, device_name, device_type,96- long_term_public_key_hex, signing_public_key_hex }97-```98-99-### Sequence (after Noise NK established + SAS confirmed on BOTH)100-101-```102-Receiver → Source : ADOPT_REQUEST (receiver is blank; no keys yet)103- Source shows consent modal (SAS doubles as approval). On reject → close, abort.104-Source → Receiver: ADOPT_GRANT (root key + source identity + license?)105- Receiver:106- - assert root_key_hex is 64 hex chars107- - createVaultFromRootKey(localPassword, root_key_hex) [new vault.ts fn]108- - assert computeVaultId(rootKey) == vault_id [sanity]109- - if license present and we're not Pro → validate+store (existing path)110- - upsert `source` as a paired device (trustSource:"direct")111- - now the receiver HAS an identity (derived from the adopted root key)112-Receiver → Source : ADOPT_IDENTITY (receiver's now-derivable identity)113- Source: upsert receiver as a paired device114-Both: trigger a sync round → entries flow over the existing transport.115-```116-117-If the receiver aborts before `createVaultFromRootKey`, it remains vault-less.118-The source never persists anything until it receives `ADOPT_IDENTITY`.119-120-### Recovery words after adoption121-122-`createVaultFromRootKey` regenerates the recovery escrow from the adopted key, so123-the 12 recovery words are **identical** to the source's (they derive from124-`rootKey[0..16]`). Recovery is consistent across all devices on the vault. The125-receiver's *local password* is independent.126-127----128-129-## 5. Threat model130-131-What changes vs. today's pairing (which only swaps public keys):132-133-- **Asset on the wire:** the 32-byte root key (full vault control), not just a134- sync link. Protected by (a) Noise NK transport encryption, (b) the135- user-confirmed **SAS** out-of-band check, (c) explicit source-side consent copy136- that names the consequence.137-- **Residual risk:** an in-room MITM who completes the handshake gets the root138- key *iff the user mis-confirms the SAS*. Same ~1e-6 SAS bound as pairing, but139- higher blast radius. Mitigations to decide (§8): stronger consent on the140- source; optional source re-auth (re-enter master password) before `ADOPT_GRANT`.141-- **No plaintext at rest in transit:** root key handled as transient bytes,142- **zeroed** after `createVaultFromRootKey`; never logged; never written except as143- the wrapped blobs the vault already uses.144-- **Sanity gates:** receiver verifies 32-byte length and that145- `computeVaultId(rootKey) === grant.vault_id` before committing.146-- **Downgrade/abuse:** `ADOPT_*` are new frames; a pre-adoption peer that147- receives `ADOPT_REQUEST` errors out (can't be tricked into granting). Only148- updated source devices can grant.149-150----151-152-## 6. Vault-less execution model (browser)153-154-The chirp orchestrator runs in the **popup** (needs `getUserMedia`); the155-background owns the vault. Adoption adds background handlers that **do not156-require an unlocked vault**:157-158-- `CREATE_VAULT_FROM_ROOT_KEY { password, rootKeyHex }` → `vault.createVaultFromRootKey(...)`.159- Vault-less-allowed (it *creates* the vault). Returns ok + computed vaultId.160-- The popup receives `ADOPT_GRANT` over the channel, hands `rootKeyHex` to the bg161- via that message, then proceeds to identity exchange + sync.162-- Source side: a normal (unlocked) device's pairing entry point must recognize an163- inbound `ADOPT_REQUEST` and run the grant path (with the consent gate) instead164- of the normal identity exchange.165-166-The root key crosses **popup→background** via runtime messaging (in-process,167-same extension) — acceptable; it's the same boundary `CREATE_VAULT` already uses168-for the password.169-170----171-172-## 7. Cross-platform contract173-174-| Repo | Work |175-|---|---|176-| **peach-browser** | `createVaultFromRootKey`; `CREATE_VAULT_FROM_ROOT_KEY` handler; adoption sub-protocol (new `chirp-adopt.ts` or extend `chirp-handshake.ts`/`chirp-pair.ts`) for both receiver and source; onboarding entry + adopt view; source-side `ADOPT_REQUEST` recognition + consent UI. |177-| **peach-android** | Mirror: `VaultRepository.createVaultFromRootKey`; adoption frames in `ChirpPairing.kt`; onboarding "another device" entry; source-side consent. Public key & license infra already present (license transfer just landed). |178-| **peach-sync (Zig)** | **None for adoption** — the helper is not a key source. (It remains an entry source post-adoption. A future "helper rendezvous relay" so two non-co-located devices can adopt without same-room audio is **out of scope** here and noted as a follow-up.) |179-180-Frames are additive and snake_case so the two app codebases interoperate; the181-SAS KAT and Noise KATs already pin cross-impl agreement.182-183----184-185-## 8. Open decisions (need sign-off before build)186-187-1. **Source re-auth before granting?** Require the source user to re-enter their188- master password (or biometric) in the consent modal before `ADOPT_GRANT`?189- *Recommendation: yes* — the SAS proves channel integrity but not that the190- person at the source consents to exporting the whole vault.191-2. **Receiver password timing:** set the local password *before* the SAS (so192- success is one tap) or *after* a confirmed grant? *Recommendation: before* —193- keeps the post-SAS path non-interactive and avoids holding the root key in194- memory while prompting.195-3. **Same-room only (chirp) for v1**, with helper-relay rendezvous as a later196- add? *Recommendation: yes, chirp-only v1.*197-198----199-200-## 9. Implementation map (file references)201-202-**Vault / keys (peach-browser):**203-- `src/background/vault.ts` — `createVault` (965-1007), `generateAndWrapRootKey`204- (301-342), `wrapRootKeyForDerivedKey`, `unwrapRootKeyForRecovery` (349-381),205- `getRootKeyHex` (1456-1458). **New:** `createVaultFromRootKey(password, rootKeyHex)`206- — mirror `createVault` but take the root key as input instead of generating it.207-- `src/background/messages.ts` — `CREATE_VAULT` (285-299), `RECOVER_WITH_WORDS`208- (364-399), sync handlers (1318-1407). **New:** `CREATE_VAULT_FROM_ROOT_KEY`.209-- `src/shared/sync-identity.ts` — `computeVaultId` (75-82).210-- `src/shared/recovery-words.ts` — escrow encoding (23-62).211-212-**Pairing (peach-browser):**213-- `src/shared/chirp-handshake.ts` — `LocalIdentity`/`PairingFrame` (49-87),214- initiator/responder, `derivePairingSAS`. **New:** adoption frames + a215- receiver/source path that swaps the identity exchange for `ADOPT_*`.216-- `src/popup/chirp-pair.ts` — orchestrator, `gateOnSAS`. **New:** adoption deps217- (`requestAdoption`, `grantAdoption`, `createVaultFromRootKey`).218-219-**Onboarding (peach-browser):**220-- `src/popup/views/Onboarding.tsx`, `src/popup/views/Lock.tsx` (create + restore221- footer), `src/popup/App.tsx` (view state machine). **New:** "another device"222- entry + adopt view.223-224-**Android mirror:** `ChirpPairing.kt`, `ChirpIdentity.kt`,225-`ui/pairing/ChirpPairViewModel.kt`, `VaultRepository`, onboarding.226-227----228-229-## 10. Test plan230-231-- `createVaultFromRootKey` round-trip: unlock with the local password yields the232- same root key; `computeVaultId` equals the source's.233-- Recovery words from an adopted vault equal the source's words.234-- Adoption handshake KAT: receiver obtains the root key; mutual identity235- exchange registers both devices; `vault_id` sanity check passes.236-- SAS-reject / consent-reject aborts **before** any `ADOPT_GRANT` is sent and the237- receiver stays vault-less.238-- Cross-impl: an Android source ↔ browser receiver (and vice-versa) agree on the239- SAS and the frame shapes (extend existing cross-platform KATs).240-241----242-243-## 11. Build sequencing (once decisions in §8 are settled)244-245-1. **vault.ts `createVaultFromRootKey` + `CREATE_VAULT_FROM_ROOT_KEY`** (+ tests) — pure, testable, no UI.246-2. **Adoption sub-protocol** in `chirp-handshake.ts`/`chirp-pair.ts` (+ handshake KAT).247-3. **Onboarding entry + adopt view + source consent UI.**248-4. **Android mirror.**249-5. Version bumps per repo; full test + build; coordinated promotion (browser +250- Android land together, like the license-transfer change).