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
↓ peach-browser-7fa035f5.tar.gzatlas at this save
files changed5 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 −4manifest.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": {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",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",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+);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.");