import { existsSync, readFileSync, mkdtempSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { execFileSync, spawn } from "node:child_process"; import { createPrivateKey, createPublicKey, sign as signEd25519Node } from "node:crypto"; import { tmpdir } from "node:os"; const browserRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const androidRoot = resolve(browserRoot, "../peach-android"); const localJavaHome = "/opt/homebrew/opt/openjdk@17"; const javaHome = process.env.JAVA_HOME ?? (existsSync(localJavaHome) ? localJavaHome : undefined); const browserCoordinator = readFileSync(resolve(browserRoot, "src/background/sync-coordinator.ts"), "utf8"); const androidCoordinator = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt"), "utf8"); const browserSyncTypes = readFileSync(resolve(browserRoot, "src/shared/sync-types.ts"), "utf8"); const androidManifest = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt"), "utf8"); const androidSyncMessage = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/SyncMessage.kt"), "utf8"); const browserPairedDevices = readFileSync(resolve(browserRoot, "src/background/paired-devices.ts"), "utf8"); const androidPairedDevices = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt"), "utf8"); const browserAdoption = readFileSync(resolve(browserRoot, "src/shared/adoption-protocol.ts"), "utf8"); const androidAdoption = [ "AdoptionProtocol.kt", "VaultAdopt.kt", ].map((file) => readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync", file), "utf8")).join("\n"); // Keep the source guards as cheap secondary checks. The real gate below crosses // a process boundary and exercises each production coordinator and JSON codec. if (browserCoordinator.includes('helloIn.version === localVersion')) throw new Error("Browser still contains the forbidden equal-version fast path"); if (!browserCoordinator.includes("helloIn.deviceManifestVersion === DEVICE_MANIFEST_VERSION")) throw new Error("Browser does not negotiate device manifests from SYNC_HELLO"); if (!androidCoordinator.includes("val exchangeDeviceManifest = vault.negotiatesDeviceManifest(hello)")) throw new Error("Android does not negotiate device manifests from SYNC_HELLO"); if (!androidCoordinator.includes("peerVersion == DEVICE_MANIFEST_VERSION")) throw new Error("Android does not require an exact device-manifest version match"); const numericConstant = (source, declaration) => { const match = source.match(new RegExp(`${declaration}\\s*=\\s*(0x[0-9a-f]+|\\d+)`)); if (!match) throw new Error(`Missing ${declaration} declaration`); return Number(match[1]); }; if (numericConstant(browserSyncTypes, "DEVICE_MANIFEST_VERSION") !== numericConstant(androidManifest, "DEVICE_MANIFEST_VERSION")) { throw new Error("Browser and Android device-manifest versions differ"); } if (numericConstant(browserSyncTypes, "DEVICE_REVOCATION_RECORD_VERSION") !== numericConstant(androidSyncMessage, "DEVICE_REVOCATION_RECORD_VERSION")) { throw new Error("Browser and Android revocation record versions differ"); } // The canonical revocation object is signed field-by-field, so a renamed or // dropped field silently stops the two clients verifying each other. for (const field of ["recordVersion", "deviceId", "longTermPublicKeyHex", "revokedByDeviceId"]) { if (!browserSyncTypes.includes(field) || !androidSyncMessage.includes(field)) { throw new Error(`A client lacks revocation field ${field}`); } } if (browserSyncTypes.includes("issuerSigningPublicKeyHex") || androidSyncMessage.includes("issuerSigningKeyHex")) { throw new Error("A client still carries an issuer key inside the revocation record"); } const syncProtocolVersion = (source, label) => { const match = source.match(/SYNC_PROTOCOL_VERSION\s*=\s*(\d+)/); if (!match) throw new Error(`Missing SYNC_PROTOCOL_VERSION declaration in ${label}`); return Number(match[1]); }; const browserProtocolVersion = syncProtocolVersion(browserSyncTypes, "Browser"); const androidProtocolVersion = syncProtocolVersion(androidCoordinator, "Android"); if (browserProtocolVersion !== androidProtocolVersion) { throw new Error(`Browser and Android Sync protocol versions differ (${browserProtocolVersion} vs ${androidProtocolVersion})`); } if (numericConstant(browserAdoption, "ADOPTION_VERSION") !== numericConstant(androidAdoption, "ADOPTION_VERSION")) { throw new Error("Browser and Android adoption transcript versions differ"); } for (const frame of [ "ADOPT_REQUEST_V2", "ADOPT_CONFIRM_REQUIRED", "ADOPT_CONFIRM", "ADOPT_PREPARE", "ADOPT_READY", "ADOPT_GRANT", "ADOPT_APPLIED", "ADOPT_COMPLETE", "ADOPT_REJECT", ]) { if (!browserAdoption.includes(frame) || !androidAdoption.includes(frame)) { throw new Error(`A client lacks adoption v2 frame ${frame}`); } } // Both clients must require exact equality, never a "greater-or-equal" pass. if (browserCoordinator.includes("protocolVersion ?? 1) < SYNC_PROTOCOL_VERSION") || androidCoordinator.includes("< SYNC_PROTOCOL_VERSION")) { throw new Error("A client still accepts a peer protocol version below its own"); } if (!browserSyncTypes.includes("signatureHex") || !androidSyncMessage.includes("signatureHex")) { throw new Error("A client lacks the revocation signature field"); } if (!browserPairedDevices.includes("MAX_REVOCATIONS = 500") || !androidPairedDevices.includes("MAX_REVOCATION_RECORDS = 500")) throw new Error("Browser and Android revocation ledger caps differ"); if (!browserPairedDevices.includes("enqueueDeviceMutation") || !androidPairedDevices.includes("RegistryMutationGate")) throw new Error("A paired-device registry lacks whole-list mutation serialization"); if (!browserPairedDevices.includes("function capRevocations") || !androidPairedDevices.includes("fun capRevocationRecords")) throw new Error("A client lacks largest-issuer-first revocation retention"); const browserEndpoint = String.raw` import { createInterface } from "node:readline"; import { createServer, createConnection } from "node:net"; const root = process.env.PEACH_BROWSER_ROOT; const coordinator = await import(root + "/src/background/sync-coordinator.ts"); const syncTypes = await import(root + "/src/shared/sync-types.ts"); const padding = await import(root + "/src/shared/sync-padding.ts"); const mode = process.env.PEACH_SYNC_HARNESS_MODE; const portFile = process.env.PEACH_SYNC_HARNESS_PORT_FILE; const transcriptFile = process.env.PEACH_SYNC_HARNESS_TRANSCRIPT_FILE; // Scenario shape arrives entirely from the parent; this endpoint does not know // what a scenario is. Both clients read the same knobs, so the matrix lives in // one readable place rather than half here and half in the harness. const knobs = { parity: process.env.PEACH_SYNC_HARNESS_MTIME_PARITY ?? "", localVersion: Number(process.env.PEACH_SYNC_HARNESS_LOCAL_VERSION ?? "7"), vaultId: process.env.PEACH_SYNC_HARNESS_VAULT_ID ?? "cross-client-vault", manifestSupport: process.env.PEACH_SYNC_HARNESS_MANIFEST_VERSION !== "3", entryCount: Number(process.env.PEACH_SYNC_HARNESS_ENTRY_COUNT ?? "1"), expectStatus: process.env.PEACH_SYNC_HARNESS_EXPECT_STATUS ?? "completed", drain: process.env.PEACH_SYNC_HARNESS_DRAIN === "1", // A locked session may hold the transport key and stage ciphertext, but it // never authors or applies a device manifest and never merges entries into // the live vault (PB-26-112 C). locked: process.env.PEACH_SYNC_HARNESS_LOCKED === "1", stopAfter: process.env.PEACH_SYNC_HARNESS_STOP_AFTER ?? "", // A manifest the harness signed: the wire shape IS the scenario, and one // signer (the harness, in Node) keeps both clients verifying the same bytes // rather than each signing its own. signedManifestJson: process.env.PEACH_SYNC_HARNESS_SIGNED_MANIFEST ?? "", trustedPubkeyHex: process.env.PEACH_SYNC_HARNESS_TRUSTED_PUBKEY ?? "", }; const id = mode === "initiator" ? "browser" : "android"; const socket = await new Promise((resolve, reject) => { if (mode === "initiator") { const server = createServer((client) => { // Stop listening as soon as the one conformance peer connects. Leaving // this listener open keeps Bun alive after the coordinator completes. server.close(); resolve(client); }); server.once("error", reject); server.listen(0, "127.0.0.1", () => require("node:fs").writeFileSync(portFile, String(server.address().port))); } else { const client = createConnection({ host: "127.0.0.1", port: Number(process.env.PEACH_SYNC_HARNESS_PORT) }); client.once("connect", () => resolve(client)); client.once("error", reject); } }); socket.setEncoding("utf8"); const lines = createInterface({ input: socket }); const iterator = lines[Symbol.asyncIterator](); const events = []; const typeOf = (message) => message.type; let outcome = null; let counted = { sent: 0, received: 0, applied: 0, staged: 0 }; let manifestVerdict = null; const fs = require("node:fs"); const writeTranscript = (status) => fs.writeFileSync(transcriptFile, JSON.stringify({ status, error: outcome?.error, entriesSent: counted.sent, entriesReceived: counted.received, applied: counted.applied, staged: counted.staged, manifestAccepted: manifestVerdict === null ? null : manifestVerdict === "accepted", events, })); const session = { remoteStaticPublicKey: new Uint8Array(32).fill(7), send(bytes) { const message = JSON.parse(new TextDecoder().decode(bytes)); events.push("send:" + typeOf(message)); // Simulated process death: the transcript is on disk before the process // goes, so the parent can still say what the peer had seen. if (knobs.stopAfter !== "" && knobs.stopAfter === typeOf(message)) { writeTranscript("DIED"); process.exit(7); } socket.write(Buffer.from(padding.padSyncFrame(bytes)).toString("base64") + "\n"); }, async receive() { const next = await iterator.next(); if (next.done) throw new Error("cross-client peer closed"); const payload = padding.unpadSyncFrame(Uint8Array.from(Buffer.from(next.value.trim(), "base64"))); events.push("recv:" + typeOf(JSON.parse(new TextDecoder().decode(payload)))); return payload; }, async close() { socket.end(); }, }; // Entries are shared ids so a diff can actually flow. Parity decides which side // is newer for a given index: the same parity on both sides means equal data // versions (nothing to send), opposite parities means each side is newer for // half the vault (entries flow both ways). const manifestApi = await import(root + "/src/shared/device-manifest.ts"); const hexBytes = (hex) => Uint8Array.from(hex.match(/../g).map((pair) => parseInt(pair, 16))); const crafted = knobs.signedManifestJson === "" ? null : JSON.parse(knobs.signedManifestJson); const ids = Array.from({ length: knobs.entryCount }, (_, index) => "shared-entry-" + index); const mtimeFor = (index) => knobs.parity === "" ? 10 : (index % 2 === Number(knobs.parity) ? 15 : 10); const entries = Object.fromEntries(ids.map((entryId, index) => [entryId, mtimeFor(index)])); const blobs = Object.fromEntries(ids.map((entryId, index) => [entryId, id + "-frame-" + (index % 8)])); const adapter = { async computeVaultId() { return knobs.vaultId; }, async getOurDeviceId() { return id; }, async getLocalVersion() { return knobs.localVersion; }, async buildEntryManifest() { return { ...entries }; }, async buildTombstoneManifest() { return {}; }, async getEncryptedEntries(requested) { return Object.fromEntries(requested.filter((value) => value in blobs).map((value) => [value, blobs[value]])); }, async applyEntries(entries) { if (knobs.locked) { counted.staged += Object.keys(entries).length; return; } counted.applied += Object.keys(entries).length; }, async applyTombstones() {}, // The coordinator reads capability off the adapter's shape, not a flag: a // manifest-v3 peer is one that does not expose the exchange at all. Modelling // it any other way would let this fixture advertise a version it cannot speak // — the same class of mistake the scenario is here to catch. ...(knobs.manifestSupport && !knobs.locked ? { async buildOutgoingDeviceManifest() { return crafted ?? { body: { manifestVersion: syncTypes.DEVICE_MANIFEST_VERSION, issuerDeviceId: id, issuedAt: Date.now(), devices: [], revoked: [] }, signatureHex: "11".repeat(64) }; }, async applyIncomingDeviceManifest(incoming) { if (!incoming) return; if (knobs.trustedPubkeyHex === "") { manifestVerdict = "not-checked"; return; } try { await manifestApi.verifyDeviceManifest(incoming, hexBytes(knobs.trustedPubkeyHex), {}); manifestVerdict = "accepted"; } catch { manifestVerdict = "rejected"; } }, } : {}), }; outcome = mode === "initiator" ? await coordinator.runSyncInitiator(session, adapter) : await coordinator.runSyncResponder(session, adapter); counted.sent = outcome.entriesSent ?? 0; counted.received = outcome.entriesReceived ?? 0; // A clean stop is only deterministic if our socket is still up when the peer // writes its last frame, so hold it open until the peer goes away rather than // exiting the instant the round ends. if (knobs.drain) { await Promise.race([ (async () => { for await (const _ of lines) { /* drain */ } })(), new Promise((resolve) => setTimeout(resolve, 2000)), ]); } writeTranscript(outcome.status); socket.end(); if (outcome.status !== knobs.expectStatus) { console.error("expected " + knobs.expectStatus + ", got " + outcome.status); process.exit(1); } `; /** * Manifest fixtures for the adversarial cases. * * The canonical bytes come from the Browser's *production* canonicaliser (so * the harness cannot drift from the wire contract), and the Ed25519 signature * comes from Node — one signer, both clients verifying the identical bytes. */ const PKCS8_ED25519_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex"); const trustedKey = createPrivateKey({ key: Buffer.concat([PKCS8_ED25519_PREFIX, Buffer.from("11".repeat(32), "hex")]), format: "der", type: "pkcs8", }); const TRUSTED_PUBKEY_HEX = createPublicKey(trustedKey) .export({ format: "der", type: "spki" }) .subarray(-32) .toString("hex"); const UNTRUSTED_PUBKEY_HEX = createPublicKey(createPrivateKey({ key: Buffer.concat([PKCS8_ED25519_PREFIX, Buffer.from("22".repeat(32), "hex")]), format: "der", type: "pkcs8", })).export({ format: "der", type: "spki" }).subarray(-32).toString("hex"); function canonicalBody(body) { return execFileSync("bun", [ "--eval", `import { canonicalise } from ${JSON.stringify(resolve(browserRoot, "src/shared/device-manifest.ts"))}; process.stdout.write(canonicalise(JSON.parse(process.env.PEACH_MANIFEST_SPEC)));`, ], { env: { ...process.env, PEACH_MANIFEST_SPEC: JSON.stringify(body) } }).toString(); } /** Sign a crafted body; `seed` picks the key, so a bad signature is one argument. */ function signManifest({ issuerDeviceId = "crafted-issuer", nowOffsetMs = 0, devices = [], revoked = [], seed = "11".repeat(32) } = {}) { const body = { // Read, never assumed: a hard-coded version makes every case reject for the // same reason, and the whole group proves nothing. The control case is what // catches that. manifestVersion: numericConstant(browserSyncTypes, "DEVICE_MANIFEST_VERSION"), issuerDeviceId, issuedAt: Date.now() + nowOffsetMs, devices, revoked, }; const key = createPrivateKey({ key: Buffer.concat([PKCS8_ED25519_PREFIX, Buffer.from(seed, "hex")]), format: "der", type: "pkcs8", }); const signature = signEd25519Node(null, Buffer.from(canonicalBody(body), "utf8"), key); return JSON.stringify({ body, signatureHex: signature.toString("hex") }); } const deviceEntry = (overrides = {}) => ({ deviceId: "crafted-device", deviceName: "Crafted", deviceType: "android", longTermPublicKeyHex: "aa".repeat(32), signingPublicKeyHex: "bb".repeat(32), pairedAt: 1_700_000_000_000, vouchDepth: 0, ...overrides, }); const MAX_DIAGNOSTIC_BYTES = 12_000; function spawnProcess(command, args, env) { const child = spawn(command, args, { cwd: env.PEACH_PROCESS_ROOT ?? env.PEACH_BROWSER_ROOT ?? browserRoot, env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"] }); const output = { stdout: "", stderr: "" }; for (const [name, stream] of [["stdout", child.stdout], ["stderr", child.stderr]]) { stream.setEncoding("utf8"); stream.on("data", (chunk) => { output[name] = (output[name] + chunk).slice(-MAX_DIAGNOSTIC_BYTES); }); } child.diagnostics = output; return child; } function waitForExit(child) { return new Promise((resolveExit, reject) => { child.once("error", reject); child.once("exit", (code, signal) => resolveExit({ code, signal, diagnostics: child.diagnostics })); }); } function terminate(child) { if (!child || child.exitCode !== null || child.signalCode !== null) return; child.kill("SIGTERM"); setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }, 1_000).unref(); } function formatResult(result) { const output = result.diagnostics; return `code=${result.code ?? "none"} signal=${result.signal ?? "none"}; stdout=${JSON.stringify(output.stdout)} stderr=${JSON.stringify(output.stderr)}`; } async function waitForFile(path, child) { const deadline = Date.now() + 30_000; while (!existsSync(path)) { if (child.exitCode !== null) throw new Error(`endpoint exited before publishing ${path} (${child.exitCode})`); if (Date.now() > deadline) throw new Error(`timed out waiting for ${path}`); await new Promise((resolve) => setTimeout(resolve, 50)); } return readFileSync(path, "utf8").trim(); } function readTranscript(path) { return JSON.parse(readFileSync(path, "utf8")); } const androidTask = ["--no-daemon", "--no-configuration-cache", "--rerun-tasks", "testDebugUnitTest", "--tests", "com.peachpasswords.sync.CrossClientSyncProcessTest.exchange_production_frames_with_browser_process"]; const gradleProperty = (name, value) => `-D${name.toLowerCase().replaceAll("_", ".")}=${value}`; const KNOB_PREFIX = "PEACH_SYNC_HARNESS_"; const knobEnv = (knobs) => Object.fromEntries(Object.entries(knobs).map(([name, value]) => [KNOB_PREFIX + name, String(value)])); /** * Run one scenario round. Both endpoints assert their own expected status, so a * non-zero exit means "my side did not behave as the matrix says it should" and * the scenario is free to describe a failure outcome without special-casing. */ async function runRound({ initiator, android = {}, browser = {}, timeoutMs = 180_000, label = "", expectFailure = [], manifest = null }) { // A crafted manifest goes to both sides: each sends it and each verdicts the // peer's, so one scenario exercises both clients against identical bytes. // Signed per round, not at module load: a fixture built once drifts out of // its own freshness window while earlier rounds run, and a "+10 minutes in the // future" case silently stops being in the future. const manifestKnobs = manifest ? { SIGNED_MANIFEST: typeof manifest === "function" ? manifest() : manifest, TRUSTED_PUBKEY: TRUSTED_PUBKEY_HEX } : {}; const dir = mkdtempSync(resolve(tmpdir(), "peach-cross-sync-")); const androidTranscript = resolve(dir, "android.json"); const browserTranscript = resolve(dir, "browser.json"); const androidPortFile = resolve(dir, "android.port"); const browserPortFile = resolve(dir, "browser.port"); const common = { PEACH_BROWSER_ROOT: browserRoot }; const launchBrowser = (mode, port, transcript, portFile) => spawnProcess("bun", ["--eval", browserEndpoint], { ...common, ...knobEnv({ ...manifestKnobs, ...browser }), PEACH_SYNC_HARNESS_MODE: mode, PEACH_SYNC_HARNESS_PORT: port ?? "", PEACH_SYNC_HARNESS_PORT_FILE: portFile ?? "", PEACH_SYNC_HARNESS_TRANSCRIPT_FILE: transcript, }); const launchAndroid = (mode, port) => spawnProcess(resolve(androidRoot, "gradlew"), [ gradleProperty("PEACH_SYNC_HARNESS_MODE", mode), ...androidTask, ], { ...common, ...knobEnv({ ...manifestKnobs, ...android }), PEACH_PROCESS_ROOT: androidRoot, PEACH_SYNC_HARNESS_MODE: mode, PEACH_SYNC_HARNESS_PORT: port ?? "", PEACH_SYNC_HARNESS_PORT_FILE: androidPortFile, PEACH_SYNC_HARNESS_TRANSCRIPT_FILE: androidTranscript, ...(javaHome ? { JAVA_HOME: javaHome } : {}), }); let first; let second; let timeoutId; try { if (initiator === "android") { first = launchAndroid("initiator"); const port = await waitForFile(androidPortFile, first); second = launchBrowser("responder", port, browserTranscript); } else { first = launchBrowser("initiator", undefined, browserTranscript, browserPortFile); const port = await waitForFile(browserPortFile, first); second = launchAndroid("responder", port); } const timeout = new Promise((_, reject) => { timeoutId = setTimeout( () => reject(new Error(`${label} timed out after ${Math.round(timeoutMs / 1000)}s`)), timeoutMs, ); }); const results = await Promise.race([ Promise.all([waitForExit(first), waitForExit(second)]), timeout, ]); const [firstResult, secondResult] = results; // A scenario that deliberately kills an endpoint names that side here; the // other side must still behave, which is what makes the round meaningful. const initiatorSide = initiator; const responderSide = initiator === "android" ? "browser" : "android"; const tolerate = (side, result) => expectFailure.includes(side) || result.code === 0; if (!tolerate(initiatorSide, firstResult) || !tolerate(responderSide, secondResult)) { throw new Error(`${label} failed: initiator(${initiatorSide}) ${formatResult(firstResult)}; responder(${responderSide}) ${formatResult(secondResult)}`); } return { android: readTranscript(androidTranscript), browser: readTranscript(browserTranscript) }; } catch (error) { const details = [first, second].filter(Boolean).map((child) => child.diagnostics) .map((output) => `stdout=${JSON.stringify(output.stdout)} stderr=${JSON.stringify(output.stderr)}`).join("; "); throw new Error(`${error.message}; child diagnostics: ${details}`, { cause: error }); } finally { clearTimeout(timeoutId); terminate(first); terminate(second); } } /** The two halves of a completed round, as a function of what the scenario expects to happen. */ function expectedRound({ flow, manifests }) { return { initiator: [ "send:SYNC_HELLO", "recv:SYNC_HELLO", "send:SYNC_MANIFEST", "recv:SYNC_MANIFEST", "send:SYNC_REQUEST", "recv:SYNC_REQUEST", "send:SYNC_ENTRIES", ...(flow ? ["recv:SYNC_ENTRIES"] : []), "send:SYNC_TOMBSTONES", "recv:SYNC_TOMBSTONES", ...(manifests ? ["send:SYNC_DEVICE_MANIFEST", "recv:SYNC_DEVICE_MANIFEST"] : []), "send:SYNC_DONE", "recv:SYNC_DONE", ], responder: [ "recv:SYNC_HELLO", "send:SYNC_HELLO", "recv:SYNC_MANIFEST", "send:SYNC_MANIFEST", "recv:SYNC_REQUEST", "send:SYNC_REQUEST", "recv:SYNC_ENTRIES", ...(flow ? ["send:SYNC_ENTRIES"] : []), "recv:SYNC_TOMBSTONES", "send:SYNC_TOMBSTONES", ...(manifests ? ["recv:SYNC_DEVICE_MANIFEST", "send:SYNC_DEVICE_MANIFEST"] : []), "recv:SYNC_DONE", "send:SYNC_DONE", ], }; } const normalise = (status) => String(status).toLowerCase(); const allEvents = (round) => [...round.android.events, ...round.browser.events]; function assertTranscript(actual, expected, label) { const got = JSON.stringify(actual.events); const want = JSON.stringify(expected); if (got !== want) throw new Error(`${label} transcript mismatch: expected ${want}, got ${got}`); } function assertCompleted(round, { flow, manifests, label }) { const expected = expectedRound({ flow, manifests }); for (const side of ["android", "browser"]) { const status = normalise(round[side].status); if (status !== "completed") throw new Error(`${label}: ${side} ended ${round[side].status} (${round[side].error ?? "no error"})`); const role = side === (round.initiatedBy === "android" ? "android" : "browser"); assertTranscript(round[side], expected[role], `${label}: ${side}`); } } /** * The §7.1 matrix. Each entry names the scenario it implements, configures both * clients with the same knob vocabulary, and states the observable result. */ const SCENARIOS = [ { id: 1, name: "equal versions, both unlocked, both manifest-v5", initiator: "android", // Same parity on both sides means identical mtimes: equal data versions. android: { MTIME_PARITY: "0", EXPECT_STATUS: "COMPLETED" }, browser: { MTIME_PARITY: "0", EXPECT_STATUS: "completed" }, check(round, { completed, transcript }) { completed(round, { flow: false, manifests: true, label: "scenario 1" }); // The equal-version fast path must not come back: the manifest exchange // runs in full even when neither side has anything to send. transcript(round, "scenario 1", (t) => t.events.includes("send:SYNC_MANIFEST")); }, }, { id: 2, name: "unequal data versions, both unlocked, both manifest-v5", initiator: "android", // Two entries so each side is newer for one of them: entries genuinely flow // in both directions rather than one side requesting nothing. android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCAL_VERSION: "7", EXPECT_STATUS: "COMPLETED" }, browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCAL_VERSION: "9", EXPECT_STATUS: "completed" }, check(round, { completed, expect }) { completed(round, { flow: true, manifests: true, label: "scenario 2" }); expect(round.android.entriesSent > 0 && round.android.entriesReceived > 0, "scenario 2: entries must flow both ways"); expect(round.browser.entriesSent > 0 && round.browser.entriesReceived > 0, "scenario 2: entries must flow both ways"); }, }, { id: 3, name: "equal versions, one manifest-v5 and one manifest-v3", initiator: "android", android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", EXPECT_STATUS: "COMPLETED" }, browser: { MTIME_PARITY: "0", ENTRY_COUNT: "2", MANIFEST_VERSION: "3", EXPECT_STATUS: "completed" }, check(round, { completed, expect }) { completed(round, { flow: false, manifests: false, label: "scenario 3" }); expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 3: the manifest step must be skipped"); }, }, { id: 4, name: "unequal data versions, one manifest-v5 and one manifest-v3", initiator: "browser", android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", MANIFEST_VERSION: "3", EXPECT_STATUS: "COMPLETED" }, browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCAL_VERSION: "9", EXPECT_STATUS: "completed" }, check(round, { completed, expect }) { completed(round, { flow: true, manifests: false, label: "scenario 4" }); expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 4: the manifest step must be skipped"); }, }, { id: 9, name: "manifest negotiation: no peer sends SYNC_DEVICE_MANIFEST unilaterally", initiator: "android", android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", EXPECT_STATUS: "COMPLETED" }, browser: { MTIME_PARITY: "0", ENTRY_COUNT: "2", MANIFEST_VERSION: "3", EXPECT_STATUS: "completed" }, check(round, { expect }) { // The capable side must not proceed on its own while the peer follows the // legacy sequence; that used to deadlock until the 30s session timeout. const events = allEvents(round); expect(!events.some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 9: a peer sent SYNC_DEVICE_MANIFEST while the other expected SYNC_DONE"); expect(events.filter((event) => event.endsWith("SYNC_DONE")).length === 4, "scenario 9: both sides must still reach SYNC_DONE"); }, }, { id: 13, name: "vault id mismatch stops cleanly", initiator: "browser", android: { MTIME_PARITY: "0", VAULT_ID: "vault-android", EXPECT_STATUS: "VAULT_MISMATCH", DRAIN: "1" }, browser: { MTIME_PARITY: "0", VAULT_ID: "vault-browser", EXPECT_STATUS: "vault_mismatch", DRAIN: "1" }, check(round, { expect, side }) { expect(normalise(round.android.status) === "vault_mismatch", `scenario 13: android ${round.android.status}`); expect(normalise(round.browser.status) === "vault_mismatch", `scenario 13: browser ${round.browser.status}`); // The initiator names the reason and stops; the responder stops without // reading another frame. side(round, "browser", "scenario 13: browser initiator", ["send:SYNC_HELLO", "recv:SYNC_HELLO", "send:SYNC_ERROR"]); side(round, "android", "scenario 13: android responder", ["recv:SYNC_HELLO", "send:SYNC_HELLO"]); expect(!allEvents(round).some((event) => event.endsWith("SYNC_MANIFEST")), "scenario 13: no data frames may follow a vault-id mismatch"); }, }, { id: 5, name: "browser locked (spool active), android unlocked, unequal versions", initiator: "android", android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCAL_VERSION: "7", EXPECT_STATUS: "COMPLETED" }, browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCAL_VERSION: "9", LOCKED: "1", EXPECT_STATUS: "completed" }, check(round, { completed, expect }) { completed(round, { flow: true, manifests: false, label: "scenario 5" }); // The locked side stages rather than merging, and authors no manifest. expect(round.browser.staged > 0, "scenario 5: the locked side must stage what it receives"); expect(round.browser.applied === 0, "scenario 5: a locked session must not merge into the live vault"); expect(round.browser.entriesReceived === round.browser.staged, "scenario 5: staged must account for everything received"); expect(round.android.applied > 0, "scenario 5: the unlocked side applies normally"); expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 5: a locked session must not exchange a manifest"); }, }, { id: 6, name: "android locked (listener running), browser unlocked", initiator: "browser", android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCKED: "1", EXPECT_STATUS: "COMPLETED" }, browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", EXPECT_STATUS: "completed" }, check(round, { completed, expect }) { completed(round, { flow: true, manifests: false, label: "scenario 6" }); expect(round.android.staged > 0, "scenario 6: the locked side must stage what it receives"); expect(round.android.applied === 0, "scenario 6: a locked session must not merge into the live vault"); expect(round.browser.applied > 0, "scenario 6: the unlocked side applies normally"); expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 6: a locked session must not exchange a manifest"); }, }, { id: 8, name: "both sides locked", initiator: "android", android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCKED: "1", EXPECT_STATUS: "COMPLETED" }, browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCKED: "1", EXPECT_STATUS: "completed" }, check(round, { completed, expect }) { completed(round, { flow: true, manifests: false, label: "scenario 8" }); for (const side of ["android", "browser"]) { expect(round[side].staged > 0, `scenario 8: ${side} must stage`); expect(round[side].applied === 0, `scenario 8: ${side} must not merge while locked`); } expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 8: neither locked side may exchange a manifest"); }, }, { id: 10, name: "process death mid-session, next round completes cleanly", initiator: "android", // The initiator dies immediately after its own HELLO: the responder must // survive that as an error, and the next round must be byte-clean — no // stale frame from the dead round may leak into it. android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", STOP_AFTER: "SYNC_HELLO", EXPECT_STATUS: "COMPLETED" }, browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", EXPECT_STATUS: "errored", DRAIN: "1" }, expectFailure: ["android"], check(round, { expect, side }) { expect(normalise(round.android.status) === "died", `scenario 10: android ended ${round.android.status}`); expect(normalise(round.browser.status) === "errored", `scenario 10: browser ended ${round.browser.status}`); side(round, "android", "scenario 10: the dead initiator", ["send:SYNC_HELLO"]); }, async afterwards({ completed }, { runRound: run }) { const restarted = await run({ initiator: "android", label: "scenario 10 (restarted)", android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCAL_VERSION: "7", EXPECT_STATUS: "COMPLETED" }, browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCAL_VERSION: "9", EXPECT_STATUS: "completed" }, }); restarted.initiatedBy = "android"; completed(restarted, { flow: true, manifests: true, label: "scenario 10 (restarted)" }); }, }, { id: 12, name: "manifest with a device vouched too deeply is rejected", initiator: "android", manifest: () => signManifest({ devices: [deviceEntry({ vouchDepth: 99 })] }), android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", EXPECT_STATUS: "COMPLETED" }, browser: { MTIME_PARITY: "0", ENTRY_COUNT: "2", EXPECT_STATUS: "completed" }, check(round, { completed, expect, manifestVerdict }) { completed(round, { flow: false, manifests: true, label: "scenario 12" }); manifestVerdict(round, "scenario 12", false); }, }, { id: 14, name: "manifest issued outside the freshness window is rejected", initiator: "android", // Ten minutes stale, against a five-minute window. manifest: () => signManifest({ nowOffsetMs: -10 * 60_000 }), android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", EXPECT_STATUS: "COMPLETED" }, browser: { MTIME_PARITY: "0", ENTRY_COUNT: "2", EXPECT_STATUS: "completed" }, check(round, { completed, expect, manifestVerdict }) { completed(round, { flow: false, manifests: true, label: "scenario 14" }); manifestVerdict(round, "scenario 14", false); }, }, { id: 15, name: "10,000-entry vault with padded frames", initiator: "android", android: { MTIME_PARITY: "0", ENTRY_COUNT: "10000", EXPECT_STATUS: "COMPLETED" }, browser: { MTIME_PARITY: "1", ENTRY_COUNT: "10000", EXPECT_STATUS: "completed" }, timeoutMs: 300_000, check(round, { completed, expect }) { completed(round, { flow: true, manifests: true, label: "scenario 15" }); // Opposite parities, so each side is newer for exactly half the vault: // all 10,000 entries cross, 5,000 in each direction. for (const side of ["android", "browser"]) { const sent = round[side].entriesSent; const received = round[side].entriesReceived; expect(sent === 5000, `scenario 15: ${side} sent ${sent}`); expect(received === 5000, `scenario 15: ${side} received ${received}`); } }, }, ]; const helpers = { expect(condition, message) { if (!condition) throw new Error(message); }, completed(round, options) { if (options.flow === undefined) throw new Error("completed() needs an explicit flow expectation"); const expected = expectedRound(options); for (const [side, role] of [["android", round.initiatedBy === "android" ? "initiator" : "responder"], ["browser", round.initiatedBy === "browser" ? "initiator" : "responder"]]) { const status = normalise(round[side].status); if (status !== "completed") throw new Error(`${options.label}: ${side} ended ${round[side].status} (${round[side].error ?? "no error"})`); assertTranscript(round[side], expected[role], `${options.label}: ${side} ${role}`); } }, transcript(round, label, predicate) { for (const side of ["android", "browser"]) { if (!predicate(round[side])) throw new Error(`${label}: unexpected transcript for ${side}: ${JSON.stringify(round[side].events)}`); } }, manifestVerdict(round, label, expected) { for (const side of ["android", "browser"]) { const verdict = round[side].manifestAccepted; if (verdict !== expected) { throw new Error(`${label}: ${side} verdict was ${verdict}, expected ${expected}`); } } }, side(round, side, label, expected) { const got = JSON.stringify(round[side].events); const want = JSON.stringify(expected); if (got !== want) throw new Error(`${label}: expected ${want}, got ${got}`); }, }; /** * The §16.3 manifest-v5 adversarial cases: the same bytes offered to both * clients, each verdicting with its own production verification. The last case * is the control — without it, seven rejections would prove only that the * verifier says no to everything. */ const MANIFEST_CASES = [ ["stale issuedAt", () => signManifest({ nowOffsetMs: -10 * 60_000 }), false], ["future issuedAt", () => signManifest({ nowOffsetMs: 10 * 60_000 }), false], ["vouchDepth above the absolute bound", () => signManifest({ devices: [deviceEntry({ vouchDepth: 99 })] }), false], ["device count above the bound", () => signManifest({ devices: Array.from({ length: 257 }, (_, i) => deviceEntry({ deviceId: `d-${i}` })) }), false], ["malformed device key", () => signManifest({ devices: [deviceEntry({ longTermPublicKeyHex: "aa".repeat(31) + "a" })] }), false], ["oversized issuer id", () => signManifest({ issuerDeviceId: "x".repeat(300) }), false], ["signature from an untrusted key", () => signManifest({ seed: "22".repeat(32) }), false], ["valid manifest (control)", () => signManifest({ devices: [deviceEntry()] }), true], ]; // PEACH_SYNC_HARNESS_ONLY=13,15 runs a subset. Debugging the matrix one full // pass at a time is otherwise a four-minute loop. const only = (process.env.PEACH_SYNC_HARNESS_ONLY ?? "").split(",").filter(Boolean).map(Number); const selected = only.length > 0 ? SCENARIOS.filter((scenario) => only.includes(scenario.id)) : SCENARIOS; const results = []; for (const scenario of selected) { const started = Date.now(); const round = await runRound({ ...scenario, label: `scenario ${scenario.id}` }); round.initiatedBy = scenario.initiator; scenario.check(round, helpers); if (scenario.afterwards) await scenario.afterwards(helpers, { runRound }); const elapsed = Date.now() - started; results.push({ ...scenario, elapsed }); console.log(` ✓ ${String(scenario.id).padStart(2)} ${scenario.name} (${(elapsed / 1000).toFixed(1)}s)`); } // The §16.3 adversarial group. Each case is a full round, so this is the slow // half of the gate; it is a release gate, not a pre-commit hook. const manifestResults = []; if (only.length === 0 || only.includes(-1)) { let index = 0; for (const [name, manifest, expected] of MANIFEST_CASES) { index += 1; const started = Date.now(); const round = await runRound({ initiator: "android", manifest, label: `§16.3 manifest case ${index} (${name})`, android: { MTIME_PARITY: "0", ENTRY_COUNT: "1", EXPECT_STATUS: "COMPLETED" }, browser: { MTIME_PARITY: "0", ENTRY_COUNT: "1", EXPECT_STATUS: "completed" }, }); round.initiatedBy = "android"; helpers.completed(round, { flow: false, manifests: true, label: `§16.3 case ${index}` }); helpers.manifestVerdict(round, `§16.3 case ${index} (${name})`, expected); console.log(` ✓ 16.3.${index} ${name} → ${expected ? "accepted" : "rejected"} (${((Date.now() - started) / 1000).toFixed(1)}s)`); manifestResults.push({ name, expected }); } } console.log(""); const scope = only.length > 0 ? ` (filtered to ${only.join(",")})` : ""; console.log(`Cross-client sync process conformance passed: ${results.length} of 15 §7.1 scenarios${scope}.`); for (const result of results) { const timing = result.id === 15 ? ` — 10,000-entry round ${(result.elapsed / 1000).toFixed(1)}s wall clock` : ""; console.log(` #${result.id} ${result.name}${timing}`); } console.log(""); for (const result of manifestResults) { /* listed above with their verdicts */ } console.log(""); console.log("Not yet covered by this gate (see the campaign §7.1 table):"); console.log(" #7 android cold-locked — evidence class is unit + real-device, NOT a process"); console.log(" transcript; the unit half is LockedSyncSpoolModelTest. Do not fake it here."); console.log(" #11 revocation then re-vouch — the rule is entry ADMISSION, not manifest"); console.log(" validity: the Browser enforces it in sync-vault-adapter and Android in"); console.log(" PairedDeviceStore, both of which need real storage this endpoint has no"); console.log(" JVM-reachable path to. Its unit coverage exists on both clients; the"); console.log(" process class needs an adapter-level harness."); console.log(" the adoption-v2 group — a separate transcript, not yet driven across the boundary.");