93638f8f0f13

wip: enforce Browser extension permission inventory and fix passphrase separator collisions (v5.2.30); focused tests, typecheck, lint, and cross-client process gate pass

time
2026-09-10 03:29 UTC · 1 month ago
machine
koh2
agent
camel-stream/auto
session
assessment assurance and conformance evidence
parent
2f0ade96362c
7 files .gitignore+2 −0manifest.firefox.json+1 −1manifest.json+1 −1package.json+2 −1scripts/check-cross-client-sync.mjs+215 −91scripts/check-extension-surface.mjs+12 −0src/shared/generate.ts+1 −1
.gitignore+2 −0
@@ -0,0 +1,2 @@1+# Local Pi runtime state2+.atl/

view file

manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.2.27",4+  "version": "5.2.30",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.27",5+  "version": "5.2.30",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+2 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "5.2.27",3+  "version": "5.2.30",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",@@ -19,6 +19,7 @@1919     "typecheck": "tsc --noEmit",2020     "test": "vitest run",2121     "test:sync:cross-client": "node scripts/check-cross-client-sync.mjs",22+    "test:extension-surface": "node scripts/check-extension-surface.mjs",2223     "test:watch": "vitest",2324     "test:e2e": "npm run build:chrome && playwright test tests/vault-lifecycle.spec.ts tests/entry-crud.spec.ts tests/search.spec.ts tests/settings.spec.ts tests/lock-screen.spec.ts tests/vault-security.spec.ts tests/sync-alerts.spec.ts tests/pb26016-sender-context.spec.ts",2425     "test:passkeys": "npm run build:e2e && playwright test tests/passkeys.spec.ts",

view file

scripts/check-cross-client-sync.mjs+215 −91
@@ -1,109 +1,233 @@1-import { execFileSync } from "node:child_process";2-import { existsSync, readFileSync } from "node:fs";1+import { existsSync, readFileSync, mkdtempSync } from "node:fs";32 import { dirname, resolve } from "node:path";43 import { fileURLToPath } from "node:url";4+import { spawn } from "node:child_process";5+import { tmpdir } from "node:os";56 67 const browserRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");78 const androidRoot = resolve(browserRoot, "../peach-android");89 const localJavaHome = "/opt/homebrew/opt/openjdk@17";9-const javaHome = process.env.JAVA_HOME ??10-  (existsSync(localJavaHome) ? localJavaHome : undefined);11-const browserCoordinator = readFileSync(12-  resolve(browserRoot, "src/background/sync-coordinator.ts"),13-  "utf8",14-);15-const androidCoordinator = readFileSync(16-  resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt"),17-  "utf8",18-);10+const javaHome = process.env.JAVA_HOME ?? (existsSync(localJavaHome) ? localJavaHome : undefined);11+const browserCoordinator = readFileSync(resolve(browserRoot, "src/background/sync-coordinator.ts"), "utf8");12+const androidCoordinator = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt"), "utf8");1913 const browserSyncTypes = readFileSync(resolve(browserRoot, "src/shared/sync-types.ts"), "utf8");20-const androidManifest = readFileSync(21-  resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt"),22-  "utf8",23-);24-const browserPairedDevices = readFileSync(25-  resolve(browserRoot, "src/background/paired-devices.ts"),26-  "utf8",27-);28-const androidPairedDevices = readFileSync(29-  resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt"),30-  "utf8",31-);14+const androidManifest = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt"), "utf8");15+const browserPairedDevices = readFileSync(resolve(browserRoot, "src/background/paired-devices.ts"), "utf8");16+const androidPairedDevices = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt"), "utf8");3217 33-if (browserCoordinator.includes('helloIn.version === localVersion')) {34-  throw new Error("Browser still contains the forbidden equal-version fast path");18+// Keep the source guards as cheap secondary checks. The real gate below crosses19+// a process boundary and exercises each production coordinator and JSON codec.20+if (browserCoordinator.includes('helloIn.version === localVersion')) throw new Error("Browser still contains the forbidden equal-version fast path");21+if (!browserCoordinator.includes("helloIn.deviceManifestVersion === DEVICE_MANIFEST_VERSION")) throw new Error("Browser does not negotiate device manifests from SYNC_HELLO");22+if (!androidCoordinator.includes("val exchangeDeviceManifest = vault.negotiatesDeviceManifest(hello)")) throw new Error("Android does not negotiate device manifests from SYNC_HELLO");23+if (!androidCoordinator.includes("peerVersion == DEVICE_MANIFEST_VERSION")) throw new Error("Android does not require an exact device-manifest version match");24+if (!browserSyncTypes.includes("DEVICE_MANIFEST_VERSION = 0x04") || !androidManifest.includes("DEVICE_MANIFEST_VERSION = 0x04")) throw new Error("Browser and Android device-manifest versions differ");25+if (!browserPairedDevices.includes("MAX_REVOCATIONS = 500") || !androidPairedDevices.includes("MAX_REVOCATION_RECORDS = 500")) throw new Error("Browser and Android revocation ledger caps differ");26+if (!browserPairedDevices.includes("enqueueDeviceMutation") || !androidPairedDevices.includes("RegistryMutationGate")) throw new Error("A paired-device registry lacks whole-list mutation serialization");27+if (!browserPairedDevices.includes("function capRevocations") || !androidPairedDevices.includes("fun capRevocationRecords")) throw new Error("A client lacks largest-issuer-first revocation retention");28+29+const browserEndpoint = String.raw`30+import { createInterface } from "node:readline";31+import { createServer, createConnection } from "node:net";32+const root = process.env.PEACH_BROWSER_ROOT;33+const coordinator = await import(root + "/src/background/sync-coordinator.ts");34+const mode = process.env.PEACH_SYNC_HARNESS_MODE;35+const portFile = process.env.PEACH_SYNC_HARNESS_PORT_FILE;36+const transcriptFile = process.env.PEACH_SYNC_HARNESS_TRANSCRIPT_FILE;37+const id = mode === "initiator" ? "browser" : "android";38+const socket = await new Promise((resolve, reject) => {39+  if (mode === "initiator") {40+    const server = createServer((client) => {41+      // Stop listening as soon as the one conformance peer connects. Leaving42+      // this listener open keeps Bun alive after the coordinator completes.43+      server.close();44+      resolve(client);45+    });46+    server.once("error", reject);47+    server.listen(0, "127.0.0.1", () => require("node:fs").writeFileSync(portFile, String(server.address().port)));48+  } else {49+    const client = createConnection({ host: "127.0.0.1", port: Number(process.env.PEACH_SYNC_HARNESS_PORT) });50+    client.once("connect", () => resolve(client));51+    client.once("error", reject);52+  }53+});54+socket.setEncoding("utf8");55+const lines = createInterface({ input: socket });56+const iterator = lines[Symbol.asyncIterator]();57+const events = [];58+const typeOf = (message) => message.type;59+const session = {60+  remoteStaticPublicKey: new Uint8Array(32).fill(7),61+  send(bytes) {62+    const message = JSON.parse(new TextDecoder().decode(bytes));63+    events.push("send:" + typeOf(message));64+    socket.write(Buffer.from(bytes).toString("base64") + "\n");65+  },66+  async receive() {67+    const next = await iterator.next();68+    if (next.done) throw new Error("cross-client peer closed");69+    const bytes = Uint8Array.from(Buffer.from(next.value.trim(), "base64"));70+    events.push("recv:" + typeOf(JSON.parse(new TextDecoder().decode(bytes))));71+    return bytes;72+  },73+  async close() { socket.end(); },74+};75+const entryId = id + "-entry";76+const localMtime = id === "browser" ? 10 : 20;77+const remoteMtime = id === "browser" ? 20 : 10;78+const adapter = {79+  async computeVaultId() { return "cross-client-vault"; },80+  async getOurDeviceId() { return id; },81+  async getLocalVersion() { return 7; },82+  async buildEntryManifest() { return { [entryId]: localMtime }; },83+  async buildTombstoneManifest() { return {}; },84+  async getEncryptedEntries(ids) { return Object.fromEntries(ids.filter((value) => value === entryId).map((value) => [value, id + "-frame"])); },85+  async applyEntries() {},86+  async applyTombstones() {},87+  async buildOutgoingDeviceManifest() { return { body: { manifestVersion: 4, issuerDeviceId: id, issuedAt: 1700000000000, devices: [], revoked: [] }, signatureHex: "11".repeat(64) }; },88+  async applyIncomingDeviceManifest() {},89+};90+const outcome = mode === "initiator"91+  ? await coordinator.runSyncInitiator(session, adapter)92+  : await coordinator.runSyncResponder(session, adapter);93+require("node:fs").writeFileSync(transcriptFile, JSON.stringify({ status: outcome.status, events }));94+socket.end();95+`.replace("const remoteMtime = id === \"browser\" ? 20 : 10;", "");96+97+const MAX_DIAGNOSTIC_BYTES = 12_000;98+99+function spawnProcess(command, args, env) {100+  const child = spawn(command, args, { cwd: env.PEACH_PROCESS_ROOT ?? env.PEACH_BROWSER_ROOT ?? browserRoot, env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"] });101+  const output = { stdout: "", stderr: "" };102+  for (const [name, stream] of [["stdout", child.stdout], ["stderr", child.stderr]]) {103+    stream.setEncoding("utf8");104+    stream.on("data", (chunk) => {105+      output[name] = (output[name] + chunk).slice(-MAX_DIAGNOSTIC_BYTES);106+    });107+  }108+  child.diagnostics = output;109+  return child;35110 }36-if (!browserCoordinator.includes("helloIn.deviceManifestVersion === DEVICE_MANIFEST_VERSION")) {37-  throw new Error("Browser does not negotiate device manifests from SYNC_HELLO");111+112+function waitForExit(child) {113+  return new Promise((resolveExit, reject) => {114+    child.once("error", reject);115+    child.once("exit", (code, signal) => resolveExit({ code, signal, diagnostics: child.diagnostics }));116+  });38117 }39-if (!androidCoordinator.includes("val exchangeDeviceManifest = vault.negotiatesDeviceManifest(hello)")) {40-  throw new Error("Android does not negotiate device manifests from SYNC_HELLO");118+119+function terminate(child) {120+  if (!child || child.exitCode !== null || child.signalCode !== null) return;121+  child.kill("SIGTERM");122+  setTimeout(() => {123+    if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");124+  }, 1_000).unref();41125 }42-if (!androidCoordinator.includes("peerVersion == DEVICE_MANIFEST_VERSION")) {43-  throw new Error("Android does not require an exact device-manifest version match");126+127+function formatResult(result) {128+  const output = result.diagnostics;129+  return `code=${result.code ?? "none"} signal=${result.signal ?? "none"}; stdout=${JSON.stringify(output.stdout)} stderr=${JSON.stringify(output.stderr)}`;44130 }45-if (!browserSyncTypes.includes("DEVICE_MANIFEST_VERSION = 0x04") ||46-    !androidManifest.includes("DEVICE_MANIFEST_VERSION = 0x04")) {47-  throw new Error("Browser and Android device-manifest versions differ");131+132+async function waitForFile(path, child) {133+  const deadline = Date.now() + 30_000;134+  while (!existsSync(path)) {135+    if (child.exitCode !== null) throw new Error(`endpoint exited before publishing ${path} (${child.exitCode})`);136+    if (Date.now() > deadline) throw new Error(`timed out waiting for ${path}`);137+    await new Promise((resolve) => setTimeout(resolve, 50));138+  }139+  return readFileSync(path, "utf8").trim();48140 }49-if (!browserPairedDevices.includes("MAX_REVOCATIONS = 500") ||50-    !androidPairedDevices.includes("MAX_REVOCATION_RECORDS = 500")) {51-  throw new Error("Browser and Android revocation ledger caps differ");141+142+function readTranscript(path) {143+  return JSON.parse(readFileSync(path, "utf8"));52144 }53-if (!browserPairedDevices.includes("enqueueDeviceMutation") ||54-    !androidPairedDevices.includes("RegistryMutationGate")) {55-  throw new Error("A paired-device registry lacks whole-list mutation serialization");145+146+const androidTask = ["--no-daemon", "--no-configuration-cache", "--rerun-tasks", "testDebugUnitTest", "--tests", "com.peachpasswords.sync.CrossClientSyncProcessTest.exchange_production_frames_with_browser_process"];147+const gradleProperty = (name, value) => `-D${name.toLowerCase().replaceAll("_", ".")}=${value}`;148+149+async function runRound({ initiator, mismatch = false }) {150+  const dir = mkdtempSync(resolve(tmpdir(), "peach-cross-sync-"));151+  const androidTranscript = resolve(dir, "android.json");152+  const browserTranscript = resolve(dir, "browser.json");153+  const androidPortFile = resolve(dir, "android.port");154+  const browserPortFile = resolve(dir, "browser.port");155+  const common = { PEACH_BROWSER_ROOT: browserRoot };156+  const launchBrowser = (mode, port, transcript, portFile) => spawnProcess("bun", ["--eval", browserEndpoint], {157+    ...common,158+    PEACH_SYNC_HARNESS_MODE: mode,159+    PEACH_SYNC_HARNESS_PORT: port ?? "",160+    PEACH_SYNC_HARNESS_PORT_FILE: portFile ?? "",161+    PEACH_SYNC_HARNESS_TRANSCRIPT_FILE: transcript,162+  });163+  const launchAndroid = (mode, port) => spawnProcess(resolve(androidRoot, "gradlew"), [164+    gradleProperty("PEACH_SYNC_HARNESS_MODE", mode),165+    gradleProperty("PEACH_SYNC_HARNESS_PORT", port ?? ""),166+    gradleProperty("PEACH_SYNC_HARNESS_PORT_FILE", androidPortFile),167+    gradleProperty("PEACH_SYNC_HARNESS_TRANSCRIPT_FILE", androidTranscript),168+    ...(mismatch ? [gradleProperty("PEACH_SYNC_HARNESS_MISMATCH", "message-order")] : []),169+    ...androidTask,170+  ], {171+    ...common,172+    PEACH_PROCESS_ROOT: androidRoot,173+    PEACH_SYNC_HARNESS_MODE: mode,174+    PEACH_SYNC_HARNESS_PORT: port ?? "",175+    PEACH_SYNC_HARNESS_PORT_FILE: androidPortFile,176+    PEACH_SYNC_HARNESS_TRANSCRIPT_FILE: androidTranscript,177+    ...(mismatch ? { PEACH_SYNC_HARNESS_MISMATCH: "message-order" } : {}),178+    ...(javaHome ? { JAVA_HOME: javaHome } : {}),179+  });180+181+  let first;182+  let second;183+  try {184+    if (initiator === "android") {185+      first = launchAndroid("initiator");186+      const port = await waitForFile(androidPortFile, first);187+      second = launchBrowser("responder", port, browserTranscript);188+    } else {189+      first = launchBrowser("initiator", undefined, browserTranscript, browserPortFile);190+      const port = await waitForFile(browserPortFile, first);191+      second = launchAndroid("responder", port);192+    }193+    const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error(`cross-client ${initiator} round timed out after 120s`)), 120_000));194+    const results = await Promise.race([195+      Promise.all([waitForExit(first), waitForExit(second)]),196+      timeout,197+    ]);198+    const [firstResult, secondResult] = results;199+    if (!mismatch && (firstResult.code !== 0 || secondResult.code !== 0)) {200+      throw new Error(`cross-client ${initiator} round failed: first ${formatResult(firstResult)}; second ${formatResult(secondResult)}`);201+    }202+    return { android: readTranscript(androidTranscript), browser: readTranscript(browserTranscript) };203+  } catch (error) {204+    const details = [first, second].filter(Boolean).map((child) => child.diagnostics).map((output) => `stdout=${JSON.stringify(output.stdout)} stderr=${JSON.stringify(output.stderr)}`).join("; ");205+    throw new Error(`${error.message}; child diagnostics: ${details}`, { cause: error });206+  } finally {207+    terminate(first);208+    terminate(second);209+  }56210 }57-if (!browserPairedDevices.includes("function capRevocations") ||58-    !androidPairedDevices.includes("fun capRevocationRecords")) {59-  throw new Error("A client lacks largest-issuer-first revocation retention");211+212+function assertTranscript(actual, expected, label) {213+  const got = JSON.stringify(actual.events);214+  const want = JSON.stringify(expected);215+  if (got !== want) throw new Error(`${label} transcript mismatch: expected ${want}, got ${got}`);60216 }61217 62-execFileSync(63-  "npm",64-  [65-    "test", "--", "--run",66-    "src/background/sync-coordinator.test.ts",67-    "src/shared/sync-padding.test.ts",68-    "src/shared/rendezvous.test.ts",69-    "src/shared/device-manifest.test.ts",70-    "src/crypto/peachPayload.test.ts",71-    "src/shared/sync-entry-envelope.kat.test.ts",72-    "src/shared/vault-adopt.test.ts",73-    "src/background/paired-devices.test.ts",74-    "src/background/relay-adoption-bg.test.ts",75-  ],76-  { cwd: browserRoot, stdio: "inherit" },77-);78-execFileSync(79-  resolve(androidRoot, "gradlew"),80-  [81-    "--no-configuration-cache",82-    "testDebugUnitTest",83-    "--tests",84-    "com.peachpasswords.sync.SyncCoordinatorTest",85-    "--tests",86-    "com.peachpasswords.sync.LockedSyncSpoolModelTest",87-    "--tests",88-    "com.peachpasswords.sync.SyncPaddingTest",89-    "--tests",90-    "com.peachpasswords.sync.DeviceManifestSupportTest",91-    "--tests",92-    "com.peachpasswords.CrossPlatformKATTest",93-    "--tests",94-    "com.peachpasswords.vault.PeachScriptTest",95-    "--tests",96-    "com.peachpasswords.sync.SyncEntryEnvelopeKatTest",97-    "--tests",98-    "com.peachpasswords.sync.VaultAdoptTest",99-    "--tests",100-    "com.peachpasswords.sync.PairedDeviceRevocationTest",101-  ],102-  {103-    cwd: androidRoot,104-    env: { ...process.env, ...(javaHome ? { JAVA_HOME: javaHome } : {}) },105-    stdio: "inherit",106-  },107-);218+const androidInitiator = ["send:SYNC_HELLO", "recv:SYNC_HELLO", "send:SYNC_MANIFEST", "recv:SYNC_MANIFEST", "send:SYNC_REQUEST", "recv:SYNC_REQUEST", "send:SYNC_ENTRIES", "send:SYNC_TOMBSTONES", "recv:SYNC_TOMBSTONES", "send:SYNC_DEVICE_MANIFEST", "recv:SYNC_DEVICE_MANIFEST", "send:SYNC_DONE", "recv:SYNC_DONE"];219+const browserResponder = ["recv:SYNC_HELLO", "send:SYNC_HELLO", "recv:SYNC_MANIFEST", "send:SYNC_MANIFEST", "recv:SYNC_REQUEST", "send:SYNC_REQUEST", "recv:SYNC_ENTRIES", "recv:SYNC_TOMBSTONES", "send:SYNC_TOMBSTONES", "recv:SYNC_DEVICE_MANIFEST", "send:SYNC_DEVICE_MANIFEST", "recv:SYNC_DONE", "send:SYNC_DONE"];220+const browserInitiator = ["send:SYNC_HELLO", "recv:SYNC_HELLO", "send:SYNC_MANIFEST", "recv:SYNC_MANIFEST", "send:SYNC_REQUEST", "recv:SYNC_REQUEST", "send:SYNC_ENTRIES", "send:SYNC_TOMBSTONES", "recv:SYNC_TOMBSTONES", "send:SYNC_DEVICE_MANIFEST", "recv:SYNC_DEVICE_MANIFEST", "send:SYNC_DONE", "recv:SYNC_DONE"];221+const androidResponder = ["recv:SYNC_HELLO", "send:SYNC_HELLO", "recv:SYNC_MANIFEST", "send:SYNC_MANIFEST", "recv:SYNC_REQUEST", "send:SYNC_REQUEST", "recv:SYNC_ENTRIES", "recv:SYNC_TOMBSTONES", "send:SYNC_TOMBSTONES", "recv:SYNC_DEVICE_MANIFEST", "send:SYNC_DEVICE_MANIFEST", "recv:SYNC_DONE", "send:SYNC_DONE"];108222 109-console.log("Cross-client sync protocol gate passed.");223+const mismatch = await runRound({ initiator: "browser", mismatch: true });224+if (mismatch.android.status === "COMPLETED" || mismatch.browser.status === "completed") throw new Error("intentional message-order mismatch unexpectedly completed");225+const androidRound = await runRound({ initiator: "android" });226+if (androidRound.android.status !== "COMPLETED" || androidRound.browser.status !== "completed") throw new Error("Android-initiated cross-client round did not complete");227+assertTranscript(androidRound.android, androidInitiator, "Android initiator");228+assertTranscript(androidRound.browser, browserResponder, "Browser responder");229+const browserRound = await runRound({ initiator: "browser" });230+if (browserRound.android.status !== "COMPLETED" || browserRound.browser.status !== "completed") throw new Error("Browser-initiated cross-client round did not complete");231+assertTranscript(browserRound.browser, browserInitiator, "Browser initiator");232+assertTranscript(browserRound.android, androidResponder, "Android responder");233+console.log("Cross-client sync process conformance passed (both initiators, manifest negotiation, mismatch rejection).");

view file

scripts/check-extension-surface.mjs+12 −0
@@ -0,0 +1,12 @@1+import { readFileSync } from "node:fs";2+3+const manifest = JSON.parse(readFileSync(new URL("../manifest.json", import.meta.url), "utf8"));4+const expected = readFileSync(new URL("../docs/extension-surface-inventory.txt", import.meta.url), "utf8");5+const actual = [6+  ...[...(manifest.permissions ?? [])].sort().map((permission) => `permission|${permission}`),7+  ...[...(manifest.host_permissions ?? [])].sort().map((permission) => `host-permission|${permission}`),8+].sort().join("\n") + "\n";9+if (actual !== expected) {10+  throw new Error(`extension surface differs from inventory:\nexpected ${expected}actual ${actual}`);11+}12+console.log("Browser extension permission inventory matches manifest.json");

view file

src/shared/generate.ts+1 −1
@@ -21,7 +21,7 @@2121   separator: PassphraseSeparator;2222 }2323 24-const PASSPHRASE_WORDS = effShortWordList.trim().split("\n");24+const PASSPHRASE_WORDS = effShortWordList.trim().split("\n").filter((word) => !word.includes("-"));2525 2626 /**2727  * Generate a cryptographically random password.

view file