00d35eb4423f

resolve all 17 findings of the 2026-09-10 independent audit (v5.2.45)

time
2026-09-11 04:35 UTC · 1 month ago
machine
koh2
agent
deepseek/deepseek-v4-flash
session
assessment findings remediation
parent
3d0561ff4894
27 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+3 −3scripts/bun-ambient.d.ts+24 −0scripts/check-cross-client-sync.mjs+2 −2scripts/ci.ts+4 −0scripts/package-source.test.ts+5 −0scripts/package-source.ts+11 −1scripts/process-readiness.d.mts+17 −0scripts/release-firefox.ts+6 −0scripts/release.ts+6 −0src/background/license.ts+33 −14src/background/message-policy.test.ts+6 −3src/background/message-policy.ts+1 −1src/background/messages.ts+13 −0src/background/pending-adoption-holder.ts+8 −2src/background/sync-generation.ts+6 −4src/background/sync-vault-adapter.ts+7 −2src/background/vault-history.test.ts+30 −0src/background/vault.ts+46 −22src/popup/App.tsx+15 −7src/popup/views/Settings.tsx+15 −2src/shared/adoption-protocol.ts+21 −9src/shared/domain.test.ts+31 −0src/shared/domain.ts+14 −1src/shared/vault-adopt.ts+12 −3tsconfig.json+1 −1
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.2.44",4+  "version": "5.2.45",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -2,7 +2,7 @@22   "manifest_version": 3,33   "minimum_chrome_version": "116",44   "name": "Peach Password Manager",5-  "version": "5.2.44",5+  "version": "5.2.45",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+3 −3
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "5.2.44",3+  "version": "5.2.45",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",@@ -14,8 +14,8 @@1414     "build:e2e": "PEACH_E2E_PASSKEYS=1 BUILD_TARGET=chrome vite build",1515     "test:firefox:sender": "node scripts/test-firefox-sender-context.mjs",1616     "package:firefox": "node scripts/clean-firefox-artifacts.mjs && npm run build:firefox && cd dist-firefox && web-ext build --overwrite-dest",17-    "lint": "biome check src/",18-    "lint:fix": "biome check --write src/",17+    "lint": "biome check src/ scripts/",18+    "lint:fix": "biome check --write src/ scripts/",1919     "typecheck": "tsc --noEmit",2020     "test": "vitest run",2121     "test:sync:cross-client": "node scripts/check-cross-client-sync.mjs",

view file

scripts/bun-ambient.d.ts+24 −0
@@ -0,0 +1,24 @@1+/**2+ * Ambient declarations for the Bun APIs the release scripts use.3+ *4+ * Bun's own type package is not a dependency of this repository, and the5+ * scripts touched by PB-26-912 use exactly two Bun entry points. Declaring6+ * them here lets the scripts directory join the strict TypeScript project7+ * instead of sitting outside every gate.8+ */9+10+declare module "bun" {11+  export interface BunSyncResult {12+    success: boolean;13+    exitCode: number;14+    stdout: Uint8Array;15+    stderr: Uint8Array;16+  }17+18+  export function spawnSync(command: string[], options?: Record<string, unknown>): BunSyncResult;19+}20+21+declare const Bun: {22+  file(path: string): { text(): Promise<string> };23+  spawnSync(command: string[], options?: Record<string, unknown>): import("bun").BunSyncResult;24+};

view file

scripts/check-cross-client-sync.mjs+2 −2
@@ -516,11 +516,11 @@516516   try {517517     if (initiator === "android") {518518       first = launchAndroid("initiator");519-      const port = await waitForFile(androidPortFile, first, 30_000);519+      const port = await waitForFile(androidPortFile, first, 120_000);520520       second = launchBrowser("responder", port, browserTranscript);521521     } else {522522       first = launchBrowser("initiator", undefined, browserTranscript, browserPortFile);523-      const port = await waitForFile(browserPortFile, first, 30_000);523+      const port = await waitForFile(browserPortFile, first, 120_000);524524       second = launchAndroid("responder", port);525525     }526526     const timeout = new Promise((_, reject) => {

view file

scripts/ci.ts+4 −0
@@ -43,6 +43,10 @@4343     cmd: ['node', 'scripts/check-noise-core.mjs'],4444   },4545   {46+    name: 'Extension surface',47+    cmd: ['npm', 'run', 'test:extension-surface'],48+  },49+  {4650     name: 'Chrome build',4751     cmd: ['npm', 'run', 'build:chrome'],4852   },

view file

scripts/package-source.test.ts+5 −0
@@ -9,6 +9,7 @@99   buildSourceManifest,1010   collectSourceFiles,1111   packageSourceArchive,12+  REVIEWED_THIRD_PARTY_FILE,1213   type SourceManifest,1314 } from "./package-source";1415 @@ -67,6 +68,7 @@6768   it("excludes forbidden directories and files", () => {6869     const files = collectSourceFiles();6970     for (const file of files) {71+      if (file === REVIEWED_THIRD_PARTY_FILE) continue;7072       for (const pattern of EXCLUDE_PATTERNS) {7173         expect(pattern.test(file)).toBe(false);7274       }@@ -142,7 +144,10 @@142144   it("excludes forbidden paths from the archive", () => {143145     const archivePath = packageSourceArchive("0.0.0-test");144146     const contents = listArchive(archivePath);147+    // PB-26-913: the reviewed pdf.js worker is the single deliberate148+    // third-party file, allowed through the node_modules exclusion.145149     for (const file of contents) {150+      if (file === REVIEWED_THIRD_PARTY_FILE) continue;146151       for (const pattern of EXCLUDE_PATTERNS) {147152         expect(pattern.test(file)).toBe(false);148153       }

view file

scripts/package-source.ts+11 −1
@@ -12,6 +12,7 @@1212   readFileSync,1313   readdirSync,1414   writeFileSync,15+  type Dirent,1516 } from "node:fs";1617 import { basename, join, relative, resolve } from "node:path";1718 import { fileURLToPath } from "node:url";@@ -46,6 +47,14 @@4647 // ─── File collection ────────────────────────────────────────────────────────4748 4849 const INCLUDE_DIRS = ["src", "scripts", "public", "crypto-core"];50+51+/**52+ * The single deliberate third-party file in the source archive (PB-26-913).53+ * The shipped extension loads it at runtime and SOURCE-MANIFEST.json pins its54+ * digest, so it is allowed through the `node_modules/` exclusion — and only it.55+ */56+export const REVIEWED_THIRD_PARTY_FILE = "node_modules/pdfjs-dist/build/pdf.worker.min.mjs";57+4958 const INCLUDE_FILES = [5059   "fixtures/golden/peach-payload-v4.json",5160   "fixtures/golden/sync-entry-envelope-v1.json",@@ -55,10 +64,11 @@5564   "bun.lock",5665   "tsconfig.json",5766   "vite.config.ts",67+  REVIEWED_THIRD_PARTY_FILE,5868 ];5969 6070 function walkDir(dir: string, files: string[]): void {61-  let entries: ReturnType<typeof readdirSync>;71+  let entries: Dirent<string>[];6272   try {6373     entries = readdirSync(dir, { withFileTypes: true });6474   } catch {

view file

scripts/process-readiness.d.mts+17 −0
@@ -0,0 +1,17 @@1+/**2+ * Types for the plain-JavaScript readiness helper (PB-26-904). It stays a3+ * `.mjs` so the process wrappers can run it without a build step, so the4+ * declaration lives beside it rather than enabling `allowJs` project-wide.5+ */6+7+export interface ReadinessChild {8+  exitCode: number | null;9+  diagnostics: { stdout: string; stderr: string };10+}11+12+export function waitForFile(13+  path: string,14+  child: ReadinessChild,15+  timeoutMs: number,16+  pollMs?: number,17+): Promise<string>;

view file

scripts/release-firefox.ts+6 −0
@@ -39,6 +39,12 @@3939   env: safeEnv(),4040 });4141 42+runCommand("npm", ["run", "test:sync:cross-client"], {43+  label: "Running cross-client sync gate",44+  env: safeEnv(),45+});46+47+4248 // ─── Package source archive ────────────────────────────────────────────────────4349 // AMO requires source for any extension with minified/transpiled code.4450 // This zips everything a reviewer needs to build Peach from source.

view file

scripts/release.ts+6 −0
@@ -75,6 +75,12 @@7575 }7676 success("Build complete");7777 78+runCommand("npm", ["run", "test:sync:cross-client"], {79+  label: "Running cross-client sync gate",80+  env: safeEnv(),81+});82+83+7884 // ─── Chrome release ───────────────────────────────────────────────────────────7985 // PB-26-211: CWS credentials are read and validated here, immediately before8086 // upload. They are passed only to the Chrome upload child, never to build or

view file

src/background/license.ts+33 −14
@@ -27,10 +27,32 @@2727   valid: boolean;2828   plan: "free" | "pro";2929   issued?: Date;30+  /** Random issuer-assigned identifier embedded in the signed payload. */31+  identifier?: number;3032 }3133 3234 const LICENSE_PREFIX = "PPK-";35+const LICENSE_REVOCATIONS_KEY = "peach_license_revocations_v1";36+const LICENSE_PAYLOAD_LENGTH = 12;37+/** Issuer-side helper: embed this random uint32 in the signed payload. */38+export function generateLicenseIdentifier(): number {39+  const bytes = new Uint32Array(1);40+  crypto.getRandomValues(bytes);41+  return bytes[0];42+}3343 44+export async function revokeLicenseIdentifier(identifier: number): Promise<void> {45+  if (!Number.isSafeInteger(identifier) || identifier < 0 || identifier > 0xffffffff) {46+    throw new Error("invalid license identifier");47+  }48+  const result = await chrome.storage.local.get(LICENSE_REVOCATIONS_KEY) as Record<string, unknown>;49+  const current = Array.isArray(result[LICENSE_REVOCATIONS_KEY])50+    ? result[LICENSE_REVOCATIONS_KEY].filter((value): value is number => Number.isSafeInteger(value))51+    : [];52+  if (!current.includes(identifier)) current.push(identifier);53+  await chrome.storage.local.set({ [LICENSE_REVOCATIONS_KEY]: current });54+}55+3456 async function importPublicKey(): Promise<CryptoKey> {3557   const keyData = hexToBytes(PUBLIC_KEY_HEX);3658   return (crypto.subtle as any).importKey(@@ -66,29 +88,21 @@6688     if (groups.length !== 5) {6789       return { valid: false, plan: "free" };6890     }69-70-    const base58String = fromBase58Groups(groups);71-    const decoded = base58Decode(base58String);72-91+    const decoded = base58Decode(fromBase58Groups(groups));7392     if (decoded.length !== 76) {7493       return { valid: false, plan: "free" };7594     }7695 77-    const payload = decoded.slice(0, 12);78-    const signature = decoded.slice(12, 76);96+    const payload = decoded.slice(0, LICENSE_PAYLOAD_LENGTH);97+    const signature = decoded.slice(LICENSE_PAYLOAD_LENGTH, 76);7998 80-    if (payload[0] !== 0x01) {99+    if (payload[0] !== 0x01 || payload[1] !== 0x01 || payload[2] !== 0x01) {81100       return { valid: false, plan: "free" };82101     }83-    if (payload[1] !== 0x01) {84-      return { valid: false, plan: "free" };85-    }86-    if (payload[2] !== 0x01) {87-      return { valid: false, plan: "free" };88-    }89102 90103     const issuedTimestamp = new DataView(payload.buffer, payload.byteOffset + 3, 4).getUint32(0, true);91104     const issuedDate = new Date(issuedTimestamp * 86400 * 1000);105+    const identifier = new DataView(payload.buffer, payload.byteOffset + 7, 4).getUint32(0, true);92106 93107     const publicKey = await importPublicKey();94108     const isValid = await (crypto.subtle as any).verify(@@ -102,7 +116,12 @@102116       return { valid: false, plan: "free" };103117     }104118 105-    return { valid: true, plan: "pro", issued: issuedDate };119+    const revocations = await chrome.storage.local.get(LICENSE_REVOCATIONS_KEY) as Record<string, unknown>;120+    const revoked = Array.isArray(revocations[LICENSE_REVOCATIONS_KEY]) &&121+      revocations[LICENSE_REVOCATIONS_KEY].some((value) => value === identifier);122+    if (revoked) return { valid: false, plan: "free", identifier };123+124+    return { valid: true, plan: "pro", issued: issuedDate, identifier };106125   } catch {107126     return { valid: false, plan: "free" };108127   }

view file

src/background/message-policy.test.ts+6 −3
@@ -74,9 +74,12 @@7474     }7575   });7676 77-  it("allows COPY from popup pages but rejects page-adjacent senders", () => {77+  it("allows COPY from popup pages and from page frames", () => {7878     const extensionOrigin = "chrome-extension://peach-extension/";79-    expect(getMessageContext("COPY")).toBe("extension-page-only");79+    // PB-26-903: the inline selector copies from a page frame, so COPY must80+    // reach the background's arming path — the frame's attested origin is81+    // checked in the handler. Popup copies are unchanged.82+    expect(getMessageContext("COPY")).toBe("content-script-allowed");8083     expect(8184       isMessageSenderAllowed(8285         "COPY",@@ -92,7 +95,7 @@9295         "peach-extension",9396         extensionOrigin,9497       ),95-    ).toBe(false);98+    ).toBe(true);9699   });97100 98101   it("keeps the global identity inventory extension-page-only", () => {

view file

src/background/message-policy.ts+1 −1
@@ -110,7 +110,7 @@110110   ADD_ADDITIONAL_URL: "content-script-allowed",111111   REQUEST_REMEMBER_SITE: "content-script-allowed",112112   AUTOFILL_QUERY: "content-script-allowed",113-  COPY: "extension-page-only",113+  COPY: "content-script-allowed",114114   FIRE_CANARY_PING: "content-script-allowed",115115   GET_ENTRY_FOR_FILL: "content-script-allowed",116116   GET_ENTRY_FOR_EDIT: "extension-page-only",

view file

src/background/messages.ts+13 −0
@@ -667,10 +667,17 @@667667         return { ok: true };668668       }669669       case "GET_VAULT_HISTORY": {670+        // The history store is real-mode state by construction, so a decoy671+        // session must not see it — this is where the duress session would672+        // otherwise leak the real vault's activity and size (PB-26-915).673+        if (vault.isDecoyMode()) return { ok: true, data: [] };670674         const snapshots = await vault.getVaultHistory();671675         return { ok: true, data: snapshots };672676       }673677       case "RESTORE_VAULT_SNAPSHOT": {678+        // Same guard on the write side: a coerced session must not be able to679+        // roll the real vault backwards onto an earlier snapshot.680+        if (vault.isDecoyMode()) return { ok: false, error: "vault_locked" };674681         const { snapshotId } = msg.payload as { snapshotId: string };675682         const result = await vault.restoreVaultSnapshot(snapshotId);676683         if (!result.ok) return { ok: false, error: result.error };@@ -1013,6 +1020,12 @@10131020         return { ok: true, data: safeEntry };10141021       }10151022       case "COPY": {1023+        // Page relays must carry the browser-attested frame origin, as with1024+        // SUMMON/fill. The policy admits content scripts; this binds the1025+        // admitted page sender before arming clipboard auto-clear.1026+        if (isPageSender(sender) && !attestedSenderOrigin(sender)) {1027+          return { ok: false, error: "invalid_sender" };1028+        }10161029         // AUD-002: shape-validate the payload before passing to the clipboard10171030         // helper. A raw cast lets a malformed message reach the clipboard API10181031         // with a non-string and throw.

view file

src/background/pending-adoption-holder.ts+8 −2
@@ -60,8 +60,14 @@6060   device: PairedDevice,6161   createdAt: number,6262 ): Promise<boolean> {63-  await storageSet(PENDING_ADOPTION_IDENTITY_KEY, JSON.stringify({ device, createdAt }));64-  return true;63+  const { rendezvousSecretHex: _rendezvousSecretHex, ...secretlessDevice } = device;64+  const encoded = JSON.stringify({ device: secretlessDevice, createdAt });65+  try {66+    await storageSet(PENDING_ADOPTION_IDENTITY_KEY, encoded);67+    return (await storageGet(PENDING_ADOPTION_IDENTITY_KEY)) === encoded;68+  } catch {69+    return false;70+  }6571 }6672 6773 export async function readPendingAdoptionIdentity(): Promise<PendingAdoptionIdentity | null> {

view file

src/background/sync-generation.ts+6 −4
@@ -11,7 +11,7 @@1111 // inside one — testable without Chrome.1212 1313 import { storageGet, storageSet } from "../shared/storage";14-import { PAIR_SECRETS_KEY, SESSION_PAIR_SECRETS_KEY } from "./pair-secrets";14+import { PAIR_SECRETS_KEY, SESSION_PAIR_SECRETS_KEY, dropSessionCache } from "./pair-secrets";1515 import {1616   PENDING_ADOPTION_HOLDER_KEY,1717   PENDING_ADOPTION_IDENTITY_KEY,@@ -395,16 +395,18 @@395395         PENDING_ADOPTION_HOLDER_KEY,396396         PENDING_ADOPTION_IDENTITY_KEY,397397         PAIR_SECRETS_KEY,398-        SESSION_PAIR_SECRETS_KEY,399398       ]);399+      await dropSessionCache();400400       // Verified rather than assumed: the clear is what licenses the mint.401-      const [paired, revocations, pending, route] = await Promise.all([401+      const [paired, revocations, pending, route, session] = await Promise.all([402402         browserGenerationIo.hasPairedDevices(),403403         browserGenerationIo.hasRevocations(),404404         browserGenerationIo.hasPendingHolders(),405405         browserGenerationIo.hasLegacyRouteMaterial(),406+        chrome.storage.session.get(SESSION_PAIR_SECRETS_KEY),406407       ]);407-      return !paired && !revocations && !pending && !route;408+      return !paired && !revocations && !pending && !route &&409+        !(SESSION_PAIR_SECRETS_KEY in session);408410     } catch {409411       return false;410412     }

view file

src/background/sync-vault-adapter.ts+7 −2
@@ -474,8 +474,13 @@474474       }475475       const longTermChanged = existing.longTermPublicKeyHex &&476476         !constantTimeEqualHex(existing.longTermPublicKeyHex, entry.longTermPublicKeyHex);477-      const signingChanged = existing.signingPublicKeyHex &&478-        !constantTimeEqualHex(existing.signingPublicKeyHex, entry.signingPublicKeyHex);477+      const existingSigning = existing.signingPublicKeyHex;478+      const entrySigning = entry.signingPublicKeyHex;479+      const signingChanged =480+        Boolean(existingSigning) !== Boolean(entrySigning) ||481+        (existingSigning !== undefined &&482+          entrySigning !== undefined &&483+          !constantTimeEqualHex(existingSigning, entrySigning));479484       if (longTermChanged || signingChanged) {480485         const knownName = existing.deviceName ?? entry.deviceName;481486         notifyPopup({ type: "DEVICE_KEY_ROTATED", payload: { deviceName: knownName, deviceId: entry.deviceId } });

view file

src/background/vault-history.test.ts+30 −0
@@ -220,6 +220,36 @@220220     expect(result).toEqual({ ok: false, error: "snapshot_not_found" });221221   });222222 223+  it("PB-26-915: a decoy session cannot read or roll back the real vault history", async () => {224+    const vault = await import("./vault");225+    await vault.createVault("decoy-history-pass-12345");226+    await vault.addEntry({ title: "real", username: "", password: "p1", url: "" });227+    await vault.setupDuress("decoy-history-duress-67890");228+229+    const realHistory = await vault.getVaultHistory();230+    expect(realHistory.length).toBeGreaterThan(0);231+    const realSlots = JSON.stringify(storage.peach_vault_a);232+233+    await vault.lock();234+    expect(await vault.unlock("decoy-history-duress-67890")).toBe(true);235+236+    // Read side: the history store is real-mode state, so the decoy sees none237+    // of it — not the real vault's activity cadence and not its size bucket.238+    await expect(vault.getVaultHistory()).resolves.toEqual([]);239+240+    // Write side: a coerced session cannot roll the real vault backwards.241+    await expect(vault.restoreVaultSnapshot(realHistory[0]!.id)).resolves.toEqual({242+      ok: false,243+      error: "vault_locked",244+    });245+246+    // Nothing was touched, and the real history survives intact.247+    expect(JSON.stringify(storage.peach_vault_a)).toBe(realSlots);248+    await vault.lock();249+    expect(await vault.unlock("decoy-history-pass-12345")).toBe(true);250+    expect((await vault.getVaultHistory()).length).toBe(realHistory.length);251+  });252+223253   it("clearVaultHistory wipes the array", async () => {224254     const vault = await import("./vault");225255     await vault.createVault("clear-pass-12345");

view file

src/background/vault.ts+46 −22
@@ -2010,7 +2010,7 @@20102010   masterPassphrase: string;20112011 }): Promise<20122012   | { ok: true; entryCount: number }2013-  | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }2013+  | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" | "missing_root_escrow" }20142014 > {20152015   let parsedSlotA: StoredVault;20162016   let parsedSlotB: StoredVault | null = null;@@ -2031,45 +2031,61 @@20312031     return { ok: false, reason: "invalid_slot_format" };20322032   }20332033 2034-  // Verify the passphrase against the supplied slotA WITHOUT writing anything.2035-  // If this fails the on-disk vault is left completely untouched.2034+  // Verify the passphrase and the password-wrapped root escrow against the2035+  // staged Slot A WITHOUT writing anything. Payload decoding is part of the2036+  // trial decrypt, so unlock() cannot be the first place these checks run.20362037   const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));20372038   const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");20382039   if (!trialPayload) {20392040     zeroKey(trialKey);20402041     return { ok: false, reason: "wrong_passphrase" };20412042   }2043+  const hasEscrow =2044+    !!parsedSlotA.pw_root_ciphertext && !!parsedSlotA.pw_root_nonce && !!parsedSlotA.pw_root_tag;2045+  if (!hasEscrow) {2046+    zeroKey(trialKey);2047+    return { ok: false, reason: "missing_root_escrow" };2048+  }2049+  const stagedRootKey = await deriveRootKeyFromDerivedKey(trialKey, parsedSlotA);2050+  if (!stagedRootKey) {2051+    zeroKey(trialKey);2052+    return { ok: false, reason: "wrong_passphrase" };2053+  }2054+  zeroKey(stagedRootKey);2055+  zeroKey(trialKey);2056+2057+  // Stage the complete candidate pair in memory before touching storage.2058+  const stagedSlotB = parsedSlotB ?? generateNoiseBlob(parsedSlotA.ciphertext.length / 2);2059+20422060   // Drop the trial key; `unlock()` below derives its own copy. Holding two20432061   // copies of the master key in memory at once would be a needless surface.2044-  zeroKey(trialKey);20452062 20462063   // Snapshot the about-to-be-overwritten on-disk state so the user can revert20472064   // a mistaken Codex restore via the Vault History UI. Non-fatal if it fails.20482065   await takeVaultSnapshot("restore");2049-2050-  await writeSlot(SLOT_A, parsedSlotA);2051-  if (parsedSlotB) {2052-    await writeSlot(SLOT_B, parsedSlotB);2053-  } else {2054-    // Slot B must always exist with ciphertext length matching slot A2055-    // (Projects.md §3.2). Codex prints may omit slot B (duress wasn't2056-    // configured); regenerate noise to satisfy the invariant.2057-    await writeSlot(2058-      SLOT_B,2059-      generateNoiseBlob(parsedSlotA.ciphertext.length / 2),2060-    );2066+  const previousSlotA = await loadSlot(SLOT_A);2067+  const previousSlotB = await loadSlot(SLOT_B);2068+  let slotACommitted = false;2069+  let slotBCommitted = false;2070+  try {2071+    await writeSlot(SLOT_A, parsedSlotA);2072+    slotACommitted = true;2073+    await writeSlot(SLOT_B, stagedSlotB);2074+    slotBCommitted = true;2075+    await chrome.storage.local.set({2076+      peach_last_modified_at: String(params.timestamp),2077+    });2078+  } catch (err) {2079+    if (slotBCommitted && previousSlotB) await writeSlot(SLOT_B, previousSlotB);2080+    if (slotACommitted && previousSlotA) await writeSlot(SLOT_A, previousSlotA);2081+    throw err;20612082   }2062-  await chrome.storage.local.set({2063-    peach_last_modified_at: String(params.timestamp),2064-  });20652083 20662084   // Standard unlock path so domain index, settings, auto-lock, and20672085   // recovery-key state all populate exactly as a normal unlock would.20682086   const ok = await unlock(params.masterPassphrase);20692087   if (!ok) {2070-    // Should be unreachable — we just verified the passphrase against the2071-    // same slotA we wrote. Treat as wrong_passphrase to keep the contract2072-    // simple if some race or storage anomaly intrudes.2088+    // Should be unreachable — the staged candidate passed the same checks.20732089     return { ok: false, reason: "wrong_passphrase" };20742090   }20752091   return { ok: true, entryCount: getEntries().length };@@ -3391,6 +3407,10 @@33913407 33923408 /** Read all snapshots, newest first, for the Settings vault history view. */33933409 export async function getVaultHistory(): Promise<VaultSnapshot[]> {3410+  // The invariant lives here as well as at the message handlers, so it does not3411+  // depend on every caller remembering it — mirroring how takeVaultSnapshot3412+  // already refuses to record while in decoy mode (PB-26-915).3413+  if (vaultMode === "decoy") return [];33943414   const raw = await chrome.storage.local.get(HISTORY_KEY);33953415   const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];33963416   if (!history || !Array.isArray(history.snapshots)) return [];@@ -3406,6 +3426,10 @@34063426 export async function restoreVaultSnapshot(34073427   snapshotId: string,34083428 ): Promise<{ ok: true } | { ok: false; error: string }> {3429+  // A decoy session holds no real-mode authority: refusing here means a coerced3430+  // user cannot be made to roll the real vault back to an earlier snapshot3431+  // (PB-26-915).3432+  if (vaultMode === "decoy") return { ok: false, error: "vault_locked" };34093433   const history = await getVaultHistory();34103434   const snapshot = history.find((s) => s.id === snapshotId);34113435   if (!snapshot) return { ok: false, error: "snapshot_not_found" };

view file

src/popup/App.tsx+15 −7
@@ -263,23 +263,31 @@263263     ]);264264     const s = stateRes as { ok: boolean; data?: SyncOutcomeSummary };265265     if (s.ok && s.data) setSyncSummary(s.data);266-    const d = devicesRes as { ok: boolean; data?: { devices?: unknown[] } };267-    if (d.ok) setPairedCount(d.data?.devices?.length ?? 0);266+    const d = devicesRes as { ok: boolean; error?: string; data?: { devices?: unknown[] } };267+    if (d.ok) {268+      setPairedCount(d.data?.devices?.length ?? 0);269+    } else if (d.error === "trust_data_corrupted") {270+      setPairedCount(0);271+      setSyncSummary({ status: "error", outcome: "trust_data_corrupted" });272+    }268273   }269274 270-  interface InitialSyncPairedDevice extends InitialSyncDevice {271-    pairedAt?: number;272-  }273-274275   async function listInitialSyncDevices(): Promise<InitialSyncPairedDevice[]> {275276     const response = (await sendMessage({276277       type: "LIST_PAIRED_DEVICES",277278     })) as {278279       ok: boolean;280+      error?: string;279281       data?: { devices?: InitialSyncPairedDevice[] };280282     };281-    return response.ok ? response.data?.devices ?? [] : [];283+    if (response.ok) return response.data?.devices ?? [];284+    if (response.error === "trust_data_corrupted") throw new Error("trust_data_corrupted");285+    throw new Error(response.error ?? "list_paired_devices_failed");282286   }287+  interface InitialSyncPairedDevice extends InitialSyncDevice {288+    pairedAt?: number;289+  }290+283291 284292   async function resolveInitialSyncDeviceId(): Promise<string | null> {285293     const stored = await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(

view file

src/popup/views/Settings.tsx+15 −2
@@ -304,6 +304,8 @@304304   const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);305305   // Paired-device management lives here — Settings is the sync hub.306306   const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);307+  const [trustDataCorrupted, setTrustDataCorrupted] = createSignal(false);308+307309   const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);308310   const [renameDeviceId, setRenameDeviceId] = createSignal<string | null>(null);309311   const [renameDraft, setRenameDraft] = createSignal("");@@ -394,9 +396,16 @@394396     if (!isPro()) return;395397     const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {396398       ok: boolean;399+      error?: string;397400       data?: { devices: PairedDevice[] };398401     };399-    if (r.ok) setPairedDevices(r.data?.devices ?? []);402+    if (r.ok) {403+      setTrustDataCorrupted(false);404+      setPairedDevices(r.data?.devices ?? []);405+    } else if (r.error === "trust_data_corrupted") {406+      setPairedDevices([]);407+      setTrustDataCorrupted(true);408+    }400409   }401410   async function unpairDevice(deviceId: string) {402411     await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } });@@ -1705,10 +1714,14 @@17051714       <div class="peach-settings-body">17061715         <div class="peach-settings-group">17071716           <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1717+          <Show when={trustDataCorrupted()}>1718+            <p class="peach-settings-value" role="alert" style="color:var(--peach-danger,#e5805c);line-height:1.5">1719+              Device trust data is damaged — re-pair your devices after updating1720+            </p>1721+          </Show>17081722           <div17091723             class="peach-settings-row peach-settings-row--meta"17101724             style="align-items:center;gap:8px"1711-            role="status"17121725             aria-label={syncLightLabel()}17131726             title={syncLightLabel()}17141727           >

view file

src/shared/adoption-protocol.ts+21 −9
@@ -23,6 +23,13 @@2323 export interface AdoptConfirm {2424   type: "ADOPT_CONFIRM";2525   confirmed: boolean;26+  /**27+   * The receiver's Pro key. It rides the confirm frame — sent only after the28+   * SAS comparison — rather than the first request: a peer that controls the29+   * scanned invitation could otherwise read the key and walk away without ever30+   * being authenticated (PB-26-121). Mirrors Android's AdoptionFrame.Confirm.31+   */32+  license?: string;2633 }2734 export interface AdoptPrepare {2835   type: "ADOPT_PREPARE";@@ -146,7 +153,7 @@146153     pairedAt: Date.now(),147154   };148155 }149-function requestFrame(i: LocalIdentity, license?: string): AdoptRequestV2 {156+function requestFrame(i: LocalIdentity): AdoptRequestV2 {150157   return {151158     type: "ADOPT_REQUEST_V2",152159     adoptionVersion: 2,@@ -155,7 +162,6 @@155162     device_type: i.deviceType,156163     long_term_public_key_hex: i.longTermPublicKeyHex,157164     signing_public_key_hex: i.signingPublicKeyHex ?? "",158-    license,159165     snapshot_capable: true,160166   };161167 }@@ -176,13 +182,14 @@176182 ): Promise<{ device: PairedDevice; snapshotCapable: boolean } | null> {177183   let identity = await deps.localIdentity();178184   const ownLicense = await deps.license();179-  await sendV2(channel, requestFrame(identity, ownLicense));185+  await sendV2(channel, requestFrame(identity));180186   if ((await receiveFrame(channel) as Partial<AdoptConfirmRequired> | null)?.type !== "ADOPT_CONFIRM_REQUIRED") {181187     deps.onError("ADOPT_UNEXPECTED_FRAME");182188     return null;183189   }184190   const confirmed = await deps.confirmSas();185-  await sendV2(channel, { type: "ADOPT_CONFIRM", confirmed });191+  // The Pro key travels only after the SAS decision (PB-26-121).192+  await sendV2(channel, { type: "ADOPT_CONFIRM", confirmed, license: ownLicense });186193   if (!confirmed) {187194     deps.onError("ADOPT_SAS_REJECTED");188195     return null;@@ -190,7 +197,7 @@190197   let frame = await receiveFrame(channel);191198   if (isAdoptRejectV2(frame) && frame.reason === "ADOPT_DEVICE_ID_COLLISION") {192199     identity = await deps.rotateLocalIdentity();193-    await sendV2(channel, requestFrame(identity, ownLicense));200+    await sendV2(channel, requestFrame(identity));194201     frame = await receiveFrame(channel);195202     if (isAdoptRejectV2(frame)) {196203       deps.onError(frame.reason);@@ -276,6 +283,7 @@276283     !confirmation || (confirmation as AdoptConfirm).type !== "ADOPT_CONFIRM" ||277284     !(confirmation as AdoptConfirm).confirmed278285   ) return null;286+  const confirmed = confirmation as AdoptConfirm;279287   if (await deps.hasDeviceIdentityCollision(receiver)) {280288     await sendV2(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });281289     const retry = await receiveFrame(channel);@@ -285,20 +293,24 @@285293       await sendV2(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });286294       return null;287295     }288-    return grantAfterIdentity(channel, retry, receiver, deps);296+    return grantAfterIdentity(channel, retry, receiver, deps, confirmed);289297   }290-  return grantAfterIdentity(channel, request, receiver, deps);298+  return grantAfterIdentity(channel, request, receiver, deps, confirmed);291299 }292300 async function grantAfterIdentity(293301   channel: PairingTransport,294302   request: AdoptRequestV2,295303   receiver: PairedDevice,296304   deps: AdoptionV2SourceDeps,305+  confirmation: AdoptConfirm,297306 ): Promise<PairedDevice | null> {298307   let license = await deps.license();299-  if (!license && request.license) {308+  // The receiver's key arrives on the confirm frame, after both SAS decisions309+  // (PB-26-121). request.license is read only for a peer built before that change.310+  const receiverLicense = confirmation.license ?? request.license;311+  if (!license && receiverLicense) {300312     try {301-      await deps.applyLicense(request.license);313+      await deps.applyLicense(receiverLicense);302314       license = await deps.license();303315     } catch {304316       license = undefined;

view file

src/shared/domain.test.ts+31 −0
@@ -0,0 +1,31 @@1+import { describe, expect, it } from "vitest";2+import { extractDomain } from "./domain";3+4+/**5+ * PB-26-901: the autofill origin control is registrable-domain equality, so the6+ * matcher must separate tenants of a shared private suffix. Under tldts'7+ * default every `*.github.io` host collapsed to `github.io`, making8+ * octocat.github.io and evil.github.io compare equal.9+ */10+describe("extractDomain — private-suffix tenants stay distinct", () => {11+  it("does not collapse two tenants of the same private suffix", () => {12+    expect(extractDomain("https://octocat.github.io")).toBe("octocat.github.io");13+    expect(extractDomain("https://evil.github.io")).toBe("evil.github.io");14+    expect(extractDomain("https://octocat.github.io")).not.toBe(extractDomain("https://evil.github.io"));15+  });16+17+  it("keeps each tenant's own sub-domains together", () => {18+    expect(extractDomain("https://app.octocat.github.io")).toBe("octocat.github.io");19+    expect(extractDomain("https://app.octocat.github.io")).toBe(extractDomain("https://octocat.github.io"));20+  });21+22+  it("still unifies sub-domains of an ordinary registrable domain", () => {23+    expect(extractDomain("https://sub.example.com")).toBe("example.com");24+    expect(extractDomain("https://example.com")).toBe("example.com");25+    expect(extractDomain("https://a.b.example.co.uk")).toBe("example.co.uk");26+  });27+28+  it("still refuses a confusable host", () => {29+    expect(extractDomain("https://github.com.evil.test")).not.toBe("github.com");30+  });31+});

view file

src/shared/domain.ts+14 −1
@@ -3,11 +3,24 @@33 /**44  * Extract eTLD+1 domain from a URL string.55  * Returns null for invalid URLs.6+ *7+ * `allowPrivateDomains: true` is the origin-binding control, not a preference:8+ * under tldts' default every host beneath a shared private suffix (`github.io`,9+ * `vercel.app`, `s3.amazonaws.com` and thousands more) collapses to that10+ * suffix, so `octocat.github.io` and `evil.github.io` compared EQUAL and a11+ * saved login was offered to the attacker's tenant. The sibling passkey matcher12+ * (passkey-utils.ts) already passes the option (PB-26-901).13+ *14+ * Keys derived from this value — `manualSummonDomains`, the15+ * `peach_state11_shown:<domain>` flag — were previously the collapsed suffix.16+ * Those entries simply stop matching rather than matching more broadly, which17+ * is the fail-safe direction, so they are left in place instead of migrating18+ * (the stored records carry no URL to re-derive from).619  */720 export function extractDomain(url: string): string | null {821   try {922     const normalized = url.startsWith("http") ? url : "https://" + url;10-    const parsed = getDomain(normalized);23+    const parsed = getDomain(normalized, { allowPrivateDomains: true });1124     return parsed ?? null;1225   } catch {1326     return null;

view file

src/shared/vault-adopt.ts+12 −3
@@ -44,6 +44,11 @@4444 export interface AdoptConfirm {4545   type: "ADOPT_CONFIRM";4646   confirmed: boolean;47+  /**48+   * The receiver's Pro key, sent only after the SAS comparison (PB-26-121):49+   * a request-frame licence reaches a peer the user has not authenticated.50+   */51+  license?: string;4752 }4853 4954 export interface AdoptIdentity {@@ -625,14 +630,15 @@625630     device_id: deps.generateDeviceId(),626631     device_name: deps.deviceName,627632     device_type: "extension",628-    license: receiverLicense,629633     snapshot_capable: true,630634   });631635 632636   const confirmed = await deps.confirmSas();637+  // Sent only after the SAS decision (PB-26-121).633638   await sendFrame(channel, {634639     type: "ADOPT_CONFIRM",635640     confirmed,641+    license: receiverLicense,636642   });637643   if (!confirmed) {638644     deps.onError("ADOPT_SAS_REJECTED");@@ -769,10 +775,13 @@769775   await deps.persistPendingHolder?.(request);770776 771777   let sourceLicense = await deps.license?.();772-  if (!sourceLicense && request.license) {778+  // The receiver's key arrives on the confirm frame, after both SAS decisions779+  // (PB-26-121). request.license is read only for a peer built before that change.780+  const receiverLicense = confirmation.license ?? request.license;781+  if (!sourceLicense && receiverLicense) {773782     try {774783       if (!deps.applyLicense) throw new Error("license_transfer_unavailable");775-      await deps.applyLicense(request.license);784+      await deps.applyLicense(receiverLicense);776785       sourceLicense = await deps.license?.();777786     } catch {778787       sourceLicense = undefined;

view file

tsconfig.json+1 −1
@@ -16,5 +16,5 @@1616     "resolveJsonModule": true,1717     "types": ["vite/client", "chrome", "node", "playwright"]1818   },19-  "include": ["src", "tests"]19+  "include": ["src", "tests", "scripts/**/*.ts"]2020 }

view file