7fa035f54246

add bidirectional adoption v2 process conformance gate (v5.2.42)

time
2026-09-11 02:17 UTC · 1 month ago
machine
koh2
agent
gpt-5
session
adoption v2 gate
parent
ba600c808284
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1scripts/check-cross-client-adoption.test.ts+17 −0scripts/check-cross-client-sync.mjs+178 −4
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.2.41",4+  "version": "5.2.42",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.41",5+  "version": "5.2.42",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "5.2.41",3+  "version": "5.2.42",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

scripts/check-cross-client-adoption.test.ts+17 −0
@@ -0,0 +1,17 @@1+import { execFileSync } from "node:child_process";2+import { resolve } from "node:path";3+import { describe, expect, it } from "vitest";4+5+describe.skipIf(process.env.PEACH_RUN_CROSS_CLIENT_ADOPTION !== "1")(6+  "cross-client adoption process gate",7+  () => {8+  it("completes adoption v2 in both source/receiver directions", () => {9+    const output = execFileSync("node", [resolve("scripts/check-cross-client-sync.mjs")], {10+      env: { ...process.env, PEACH_SYNC_HARNESS_ONLY: "adoption" },11+      timeout: 360_000,12+    }).toString();13+14+    expect(output).toContain("Cross-client adoption v2 process conformance passed: 2 directions.");15+  }, 370_000);16+  },17+);

view file

scripts/check-cross-client-sync.mjs+178 −4
@@ -4,6 +4,7 @@44 import { execFileSync, spawn } from "node:child_process";55 import { createPrivateKey, createPublicKey, sign as signEd25519Node } from "node:crypto";66 import { tmpdir } from "node:os";7+import { generateKeyPairFromSeed } from "@stablelib/x25519";78 89 const browserRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");910 const androidRoot = resolve(browserRoot, "../peach-android");@@ -260,6 +261,105 @@260261 }261262 `;262263 264+const browserAdoptionEndpoint = String.raw`265+import { createInterface } from "node:readline";266+import { createServer, createConnection } from "node:net";267+import { generateKeyPairFromSeed } from "@stablelib/x25519";268+const root = process.env.PEACH_BROWSER_ROOT;269+const adoption = await import(root + "/src/shared/adoption-protocol.ts");270+const noiseApi = await import(root + "/src/shared/noise-js.ts");271+const syncIdentity = await import(root + "/src/shared/sync-identity.ts");272+const role = process.env.PEACH_ADOPTION_HARNESS_ROLE;273+const token = "AAECAwQFBgcICQoLDA0ODw";274+const portFile = process.env.PEACH_SYNC_HARNESS_PORT_FILE;275+const transcriptFile = process.env.PEACH_SYNC_HARNESS_TRANSCRIPT_FILE;276+const socket = await new Promise((resolve, reject) => {277+  if (role === "source") {278+    const server = createServer((client) => { server.close(); resolve(client); });279+    server.once("error", reject);280+    server.listen(0, "127.0.0.1", () => require("node:fs").writeFileSync(portFile, String(server.address().port)));281+  } else {282+    const client = createConnection({ host: "127.0.0.1", port: Number(process.env.PEACH_SYNC_HARNESS_PORT) });283+    client.once("connect", () => resolve(client));284+    client.once("error", reject);285+  }286+});287+socket.setEncoding("utf8");288+const lines = createInterface({ input: socket });289+const iterator = lines[Symbol.asyncIterator]();290+const sendRaw = async (bytes) => socket.write(Buffer.from(bytes).toString("base64") + "\n");291+const receiveRaw = async () => {292+  const next = await iterator.next();293+  if (next.done) throw new Error("cross-client peer closed");294+  return Uint8Array.from(Buffer.from(next.value.trim(), "base64"));295+};296+const events = [];297+const instrument = (session) => ({298+  async send(bytes) {299+    events.push("send:" + JSON.parse(new TextDecoder().decode(bytes)).type);300+    await sendRaw(session.encrypt(bytes));301+  },302+  async receive() {303+    const bytes = session.decrypt(await receiveRaw());304+    events.push("recv:" + JSON.parse(new TextDecoder().decode(bytes)).type);305+    return bytes;306+  },307+});308+const identity = {309+  deviceId: "browser-id", deviceName: "browser", deviceType: "extension",310+  longTermPublicKeyHex: "ab".repeat(32), signingPublicKeyHex: "cd".repeat(32),311+};312+const rootKeyHex = Array.from({ length: 32 }, (_, i) => (i + 1).toString(16).padStart(2, "0")).join("");313+let pending = false;314+let rootAfterPending = false;315+let promoted = false;316+let collisionChecks = 0;317+let rotations = 0;318+let result;319+if (role === "source") {320+  const staticKeys = generateKeyPairFromSeed(new Uint8Array(32).fill(49));321+  const hello = JSON.parse(new TextDecoder().decode(await receiveRaw()));322+  if (hello.type !== "PEACH_HELLO" || hello.token !== token) throw new Error("invalid relay preamble");323+  await sendRaw(new TextEncoder().encode(JSON.stringify({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: Buffer.from(staticKeys.publicKey).toString("hex") })));324+  const noise = await noiseApi.noiseNKResponder(staticKeys.secretKey, staticKeys.publicKey, sendRaw, receiveRaw);325+  const channel = instrument(noise);326+  const request = JSON.parse(new TextDecoder().decode(await channel.receive()));327+  result = await adoption.grantAdoptionV2(channel, request, {328+    sourceIdentity: identity,329+    async rootKeyHex() { rootAfterPending = pending; return rootKeyHex; },330+    async vaultId() { return await syncIdentity.computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex"))); },331+    async license() { return "PPK-cross-client"; },332+    async applyLicense() {}, async onConsent() { return true; },333+    async persistPendingIdentity() { pending = true; return true; },334+    async completePendingIdentity() { promoted = true; },335+    async hasDeviceIdentityCollision() { return collisionChecks++ === 0; }, async rotateSourceIdentity() { return identity; },336+    async upsertPairedDevice() {}, snapshotCapable() { return false; },337+  });338+  noise.close();339+} else {340+  await sendRaw(new TextEncoder().encode(JSON.stringify({ type: "PEACH_HELLO", token })));341+  const ack = JSON.parse(new TextDecoder().decode(await receiveRaw()));342+  const staticKey = Uint8Array.from(Buffer.from(ack.ephemeralPublicKeyHex, "hex"));343+  const noise = await noiseApi.noiseNKInitiator(staticKey, sendRaw, receiveRaw);344+  const channel = instrument(noise);345+  result = await adoption.requestAdoptionV2(channel, {346+    deviceName: "browser", async localIdentity() { return identity; },347+    async rotateLocalIdentity() { rotations += 1; return { ...identity, deviceId: "browser-rotated" }; },348+    async license() { return "PPK-cross-client"; }, async confirmSas() { return true; }, async validateSource() {},349+    async hasSourceIdentityCollision() { return false; },350+    async createVaultFromRootKey(value) { return { vaultId: await syncIdentity.computeVaultId(Uint8Array.from(Buffer.from(value, "hex"))) }; },351+    async applyLicense() {}, async upsertPairedDeviceAndClearRevocation() {}, onError(error) { throw new Error(error); },352+  });353+  noise.close();354+}355+require("node:fs").writeFileSync(transcriptFile, JSON.stringify({356+  status: result ? "completed" : "rejected", pendingBeforeRoot: rootAfterPending,357+  promotedAfterApplied: promoted, rotations, events,358+}));359+socket.end();360+if (!result) process.exit(1);361+`;362+263363 /**264364  * Manifest fixtures for the adversarial cases.265365  *@@ -463,6 +563,52 @@463563   }464564 }465565 566+async function runAdoptionRound(source) {567+  const dir = mkdtempSync(resolve(tmpdir(), "peach-cross-adoption-"));568+  const androidTranscript = resolve(dir, "android.json");569+  const browserTranscript = resolve(dir, "browser.json");570+  const portFile = resolve(dir, "endpoint.port");571+  const browserStatic = generateKeyPairFromSeed(new Uint8Array(32).fill(49)).publicKey;572+  const launchBrowser = (role, port) => spawnProcess("bun", ["--eval", browserAdoptionEndpoint], {573+    PEACH_BROWSER_ROOT: browserRoot,574+    PEACH_ADOPTION_HARNESS_ROLE: role,575+    PEACH_SYNC_HARNESS_PORT: port ?? "",576+    PEACH_SYNC_HARNESS_PORT_FILE: portFile,577+    PEACH_SYNC_HARNESS_TRANSCRIPT_FILE: browserTranscript,578+  });579+  const launchAndroid = (role, port) => spawnProcess(resolve(androidRoot, "gradlew"), [580+    "--no-daemon", "--no-configuration-cache", "--rerun-tasks", "testDebugUnitTest",581+    "--tests", "com.peachpasswords.sync.CrossClientSyncProcessTest.exchange_adoption_v2_with_browser_process",582+  ], {583+    PEACH_PROCESS_ROOT: androidRoot,584+    PEACH_ADOPTION_HARNESS_ROLE: role,585+    PEACH_SYNC_HARNESS_PORT: port ?? "",586+    PEACH_SYNC_HARNESS_PORT_FILE: portFile,587+    PEACH_SYNC_HARNESS_TRANSCRIPT_FILE: androidTranscript,588+    PEACH_ADOPTION_HARNESS_STATIC: Buffer.from(browserStatic).toString("hex"),589+    ...(javaHome ? { JAVA_HOME: javaHome } : {}),590+  });591+  let first;592+  let second;593+  try {594+    if (source === "android") {595+      first = launchAndroid("source");596+      second = launchBrowser("receiver", await waitForFile(portFile, first));597+    } else {598+      first = launchBrowser("source");599+      second = launchAndroid("receiver", await waitForFile(portFile, first));600+    }601+    const results = await Promise.all([waitForExit(first), waitForExit(second)]);602+    if (results.some((result) => result.code !== 0)) {603+      throw new Error(`${source} source failed: ${results.map(formatResult).join("; ")}`);604+    }605+    return { android: readTranscript(androidTranscript), browser: readTranscript(browserTranscript) };606+  } finally {607+    terminate(first);608+    terminate(second);609+  }610+}611+466612 /** The two halves of a completed round, as a function of what the scenario expects to happen. */467613 function expectedRound({ flow, manifests }) {468614   return {@@ -760,11 +906,13 @@760906 761907 // PEACH_SYNC_HARNESS_ONLY=13,15 runs a subset. Debugging the matrix one full762908 // pass at a time is otherwise a four-minute loop.763-const only = (process.env.PEACH_SYNC_HARNESS_ONLY ?? "").split(",").filter(Boolean).map(Number);909+const selection = process.env.PEACH_SYNC_HARNESS_ONLY ?? "";910+const adoptionOnly = selection === "adoption";911+const only = adoptionOnly ? [] : selection.split(",").filter(Boolean).map(Number);764912 const selected = only.length > 0 ? SCENARIOS.filter((scenario) => only.includes(scenario.id)) : SCENARIOS;765913 766914 const results = [];767-for (const scenario of selected) {915+for (const scenario of adoptionOnly ? [] : selected) {768916   const started = Date.now();769917   const round = await runRound({ ...scenario, label: `scenario ${scenario.id}` });770918   round.initiatedBy = scenario.initiator;@@ -778,7 +926,7 @@778926 // The §16.3 adversarial group. Each case is a full round, so this is the slow779927 // half of the gate; it is a release gate, not a pre-commit hook.780928 const manifestResults = [];781-if (only.length === 0 || only.includes(-1)) {929+if (!adoptionOnly && (only.length === 0 || only.includes(-1))) {782930   let index = 0;783931   for (const [name, manifest, expected] of MANIFEST_CASES) {784932     index += 1;@@ -798,6 +946,33 @@798946   }799947 }800948 949+if (adoptionOnly) {950+  const expectedFrames = [951+    "ADOPT_REQUEST_V2", "ADOPT_CONFIRM_REQUIRED", "ADOPT_CONFIRM", "ADOPT_REJECT",952+    "ADOPT_REQUEST_V2", "ADOPT_PREPARE", "ADOPT_READY", "ADOPT_GRANT", "ADOPT_APPLIED", "ADOPT_COMPLETE",953+  ];954+  for (const source of ["android", "browser"]) {955+    const round = await runAdoptionRound(source);956+    for (const side of ["android", "browser"]) {957+      if (round[side].status !== "completed") throw new Error(`${source} source: ${side} did not complete`);958+    }959+    const observer = round.browser.events.length > 0 ? round.browser : round.android;960+    const frames = observer.events.map((event) => event.slice(5));961+    if (JSON.stringify(frames) !== JSON.stringify(expectedFrames)) {962+      throw new Error(`${source} source adoption transcript mismatch: ${JSON.stringify(observer.events)}`);963+    }964+    const sourceResult = round[source];965+    if (!sourceResult.pendingBeforeRoot || !sourceResult.promotedAfterApplied) {966+      throw new Error(`${source} source violated pending-holder ordering: ${JSON.stringify(sourceResult)}`);967+    }968+    const receiver = source === "android" ? round.browser : round.android;969+    if (receiver.rotations !== 1) throw new Error(`${source} source: receiver rotated ${receiver.rotations} times`);970+    console.log(`  ✓ adoption v2 ${source} source → ${source === "android" ? "Browser" : "Android"} receiver`);971+  }972+  console.log("Cross-client adoption v2 process conformance passed: 2 directions.");973+  process.exit(0);974+}975+801976 console.log("");802977 const scope = only.length > 0 ? ` (filtered to ${only.join(",")})` : "";803978 console.log(`Cross-client sync process conformance passed: ${results.length} of 15 §7.1 scenarios${scope}.`);@@ -816,4 +991,3 @@816991 console.log("      PairedDeviceStore, both of which need real storage this endpoint has no");817992 console.log("      JVM-reachable path to. Its unit coverage exists on both clients; the");818993 console.log("      process class needs an adapter-level harness.");819-console.log("  the adoption-v2 group — a separate transcript, not yet driven across the boundary.");

view file