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
↓ peach-browser-00d35eb4.tar.gzatlas at this save
files changed27 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 −1manifest.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": {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",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",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+};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) => {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 },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 }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 {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>;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.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 orsrc/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 }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", () => {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",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.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> {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 }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 } });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");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" };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(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 >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;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+});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;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;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 }