import { existsSync, readFileSync, mkdtempSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; 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"); // 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})`); } // 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 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; 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; const session = { remoteStaticPublicKey: new Uint8Array(32).fill(7), send(bytes) { const message = JSON.parse(new TextDecoder().decode(bytes)); events.push("send:" + typeOf(message)); socket.write(Buffer.from(bytes).toString("base64") + "\n"); }, async receive() { const next = await iterator.next(); if (next.done) throw new Error("cross-client peer closed"); const bytes = Uint8Array.from(Buffer.from(next.value.trim(), "base64")); events.push("recv:" + typeOf(JSON.parse(new TextDecoder().decode(bytes)))); return bytes; }, async close() { socket.end(); }, }; const entryId = id + "-entry"; const localMtime = id === "browser" ? 10 : 20; const remoteMtime = id === "browser" ? 20 : 10; const adapter = { async computeVaultId() { return "cross-client-vault"; }, async getOurDeviceId() { return id; }, async getLocalVersion() { return 7; }, async buildEntryManifest() { return { [entryId]: localMtime }; }, async buildTombstoneManifest() { return {}; }, async getEncryptedEntries(ids) { return Object.fromEntries(ids.filter((value) => value === entryId).map((value) => [value, id + "-frame"])); }, async applyEntries() {}, async applyTombstones() {}, async buildOutgoingDeviceManifest() { return { body: { manifestVersion: syncTypes.DEVICE_MANIFEST_VERSION, issuerDeviceId: id, issuedAt: 1700000000000, devices: [], revoked: [] }, signatureHex: "11".repeat(64) }; }, async applyIncomingDeviceManifest() {}, }; const outcome = mode === "initiator" ? await coordinator.runSyncInitiator(session, adapter) : await coordinator.runSyncResponder(session, adapter); require("node:fs").writeFileSync(transcriptFile, JSON.stringify({ status: outcome.status, events })); socket.end(); `.replace("const remoteMtime = id === \"browser\" ? 20 : 10;", ""); 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}`; async function runRound({ initiator, mismatch = false }) { 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, 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), gradleProperty("PEACH_SYNC_HARNESS_PORT", port ?? ""), gradleProperty("PEACH_SYNC_HARNESS_PORT_FILE", androidPortFile), gradleProperty("PEACH_SYNC_HARNESS_TRANSCRIPT_FILE", androidTranscript), ...(mismatch ? [gradleProperty("PEACH_SYNC_HARNESS_MISMATCH", "message-order")] : []), ...androidTask, ], { ...common, 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, ...(mismatch ? { PEACH_SYNC_HARNESS_MISMATCH: "message-order" } : {}), ...(javaHome ? { JAVA_HOME: javaHome } : {}), }); let first; let second; 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) => setTimeout(() => reject(new Error(`cross-client ${initiator} round timed out after 120s`)), 120_000)); const results = await Promise.race([ Promise.all([waitForExit(first), waitForExit(second)]), timeout, ]); const [firstResult, secondResult] = results; if (!mismatch && (firstResult.code !== 0 || secondResult.code !== 0)) { throw new Error(`cross-client ${initiator} round failed: first ${formatResult(firstResult)}; second ${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 { terminate(first); terminate(second); } } 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}`); } 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"]; 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"]; 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"]; 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"]; const mismatch = await runRound({ initiator: "browser", mismatch: true }); if (mismatch.android.status === "COMPLETED" || mismatch.browser.status === "completed") throw new Error("intentional message-order mismatch unexpectedly completed"); const androidRound = await runRound({ initiator: "android" }); if (androidRound.android.status !== "COMPLETED" || androidRound.browser.status !== "completed") throw new Error("Android-initiated cross-client round did not complete"); assertTranscript(androidRound.android, androidInitiator, "Android initiator"); assertTranscript(androidRound.browser, browserResponder, "Browser responder"); const browserRound = await runRound({ initiator: "browser" }); if (browserRound.android.status !== "COMPLETED" || browserRound.browser.status !== "completed") throw new Error("Browser-initiated cross-client round did not complete"); assertTranscript(browserRound.browser, browserInitiator, "Browser initiator"); assertTranscript(browserRound.android, androidResponder, "Android responder"); console.log("Cross-client sync process conformance passed (both initiators, manifest negotiation, mismatch rejection).");