check-cross-client-sync.mjs

16.8 KB · raw

1import { existsSync, readFileSync, mkdtempSync } from "node:fs";2import { dirname, resolve } from "node:path";3import { fileURLToPath } from "node:url";4import { spawn } from "node:child_process";5import { tmpdir } from "node:os";67const browserRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");8const androidRoot = resolve(browserRoot, "../peach-android");9const localJavaHome = "/opt/homebrew/opt/openjdk@17";10const javaHome = process.env.JAVA_HOME ?? (existsSync(localJavaHome) ? localJavaHome : undefined);11const browserCoordinator = readFileSync(resolve(browserRoot, "src/background/sync-coordinator.ts"), "utf8");12const androidCoordinator = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt"), "utf8");13const browserSyncTypes = readFileSync(resolve(browserRoot, "src/shared/sync-types.ts"), "utf8");14const androidManifest = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt"), "utf8");15const androidSyncMessage = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/SyncMessage.kt"), "utf8");16const browserPairedDevices = readFileSync(resolve(browserRoot, "src/background/paired-devices.ts"), "utf8");17const androidPairedDevices = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt"), "utf8");18const browserAdoption = readFileSync(resolve(browserRoot, "src/shared/adoption-protocol.ts"), "utf8");19const androidAdoption = [20  "AdoptionProtocol.kt",21  "VaultAdopt.kt",22].map((file) => readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync", file), "utf8")).join("\n");2324// Keep the source guards as cheap secondary checks. The real gate below crosses25// a process boundary and exercises each production coordinator and JSON codec.26if (browserCoordinator.includes('helloIn.version === localVersion')) throw new Error("Browser still contains the forbidden equal-version fast path");27if (!browserCoordinator.includes("helloIn.deviceManifestVersion === DEVICE_MANIFEST_VERSION")) throw new Error("Browser does not negotiate device manifests from SYNC_HELLO");28if (!androidCoordinator.includes("val exchangeDeviceManifest = vault.negotiatesDeviceManifest(hello)")) throw new Error("Android does not negotiate device manifests from SYNC_HELLO");29if (!androidCoordinator.includes("peerVersion == DEVICE_MANIFEST_VERSION")) throw new Error("Android does not require an exact device-manifest version match");3031const numericConstant = (source, declaration) => {32  const match = source.match(new RegExp(`${declaration}\\s*=\\s*(0x[0-9a-f]+|\\d+)`));33  if (!match) throw new Error(`Missing ${declaration} declaration`);34  return Number(match[1]);35};36if (numericConstant(browserSyncTypes, "DEVICE_MANIFEST_VERSION") !== numericConstant(androidManifest, "DEVICE_MANIFEST_VERSION")) {37  throw new Error("Browser and Android device-manifest versions differ");38}39if (numericConstant(browserSyncTypes, "DEVICE_REVOCATION_RECORD_VERSION") !== numericConstant(androidSyncMessage, "DEVICE_REVOCATION_RECORD_VERSION")) {40  throw new Error("Browser and Android revocation record versions differ");41}42// The canonical revocation object is signed field-by-field, so a renamed or43// dropped field silently stops the two clients verifying each other.44for (const field of ["recordVersion", "deviceId", "longTermPublicKeyHex", "revokedByDeviceId"]) {45  if (!browserSyncTypes.includes(field) || !androidSyncMessage.includes(field)) {46    throw new Error(`A client lacks revocation field ${field}`);47  }48}49if (browserSyncTypes.includes("issuerSigningPublicKeyHex") || androidSyncMessage.includes("issuerSigningKeyHex")) {50  throw new Error("A client still carries an issuer key inside the revocation record");51}52const syncProtocolVersion = (source, label) => {53  const match = source.match(/SYNC_PROTOCOL_VERSION\s*=\s*(\d+)/);54  if (!match) throw new Error(`Missing SYNC_PROTOCOL_VERSION declaration in ${label}`);55  return Number(match[1]);56};57const browserProtocolVersion = syncProtocolVersion(browserSyncTypes, "Browser");58const androidProtocolVersion = syncProtocolVersion(androidCoordinator, "Android");59if (browserProtocolVersion !== androidProtocolVersion) {60  throw new Error(`Browser and Android Sync protocol versions differ (${browserProtocolVersion} vs ${androidProtocolVersion})`);61}62if (numericConstant(browserAdoption, "ADOPTION_VERSION") !== numericConstant(androidAdoption, "ADOPTION_VERSION")) {63  throw new Error("Browser and Android adoption transcript versions differ");64}65for (const frame of [66  "ADOPT_REQUEST_V2",67  "ADOPT_CONFIRM_REQUIRED",68  "ADOPT_CONFIRM",69  "ADOPT_PREPARE",70  "ADOPT_READY",71  "ADOPT_GRANT",72  "ADOPT_APPLIED",73  "ADOPT_COMPLETE",74  "ADOPT_REJECT",75]) {76  if (!browserAdoption.includes(frame) || !androidAdoption.includes(frame)) {77    throw new Error(`A client lacks adoption v2 frame ${frame}`);78  }79}80// Both clients must require exact equality, never a "greater-or-equal" pass.81if (browserCoordinator.includes("protocolVersion ?? 1) < SYNC_PROTOCOL_VERSION") ||82    androidCoordinator.includes("< SYNC_PROTOCOL_VERSION")) {83  throw new Error("A client still accepts a peer protocol version below its own");84}85if (!browserSyncTypes.includes("signatureHex") || !androidSyncMessage.includes("signatureHex")) {86  throw new Error("A client lacks the revocation signature field");87}88if (!browserPairedDevices.includes("MAX_REVOCATIONS = 500") || !androidPairedDevices.includes("MAX_REVOCATION_RECORDS = 500")) throw new Error("Browser and Android revocation ledger caps differ");89if (!browserPairedDevices.includes("enqueueDeviceMutation") || !androidPairedDevices.includes("RegistryMutationGate")) throw new Error("A paired-device registry lacks whole-list mutation serialization");90if (!browserPairedDevices.includes("function capRevocations") || !androidPairedDevices.includes("fun capRevocationRecords")) throw new Error("A client lacks largest-issuer-first revocation retention");91const browserEndpoint = String.raw`92import { createInterface } from "node:readline";93import { createServer, createConnection } from "node:net";94const root = process.env.PEACH_BROWSER_ROOT;95const coordinator = await import(root + "/src/background/sync-coordinator.ts");96const syncTypes = await import(root + "/src/shared/sync-types.ts");97const mode = process.env.PEACH_SYNC_HARNESS_MODE;98const portFile = process.env.PEACH_SYNC_HARNESS_PORT_FILE;99const transcriptFile = process.env.PEACH_SYNC_HARNESS_TRANSCRIPT_FILE;100const id = mode === "initiator" ? "browser" : "android";101const socket = await new Promise((resolve, reject) => {102  if (mode === "initiator") {103    const server = createServer((client) => {104      // Stop listening as soon as the one conformance peer connects. Leaving105      // this listener open keeps Bun alive after the coordinator completes.106      server.close();107      resolve(client);108    });109    server.once("error", reject);110    server.listen(0, "127.0.0.1", () => require("node:fs").writeFileSync(portFile, String(server.address().port)));111  } else {112    const client = createConnection({ host: "127.0.0.1", port: Number(process.env.PEACH_SYNC_HARNESS_PORT) });113    client.once("connect", () => resolve(client));114    client.once("error", reject);115  }116});117socket.setEncoding("utf8");118const lines = createInterface({ input: socket });119const iterator = lines[Symbol.asyncIterator]();120const events = [];121const typeOf = (message) => message.type;122const session = {123  remoteStaticPublicKey: new Uint8Array(32).fill(7),124  send(bytes) {125    const message = JSON.parse(new TextDecoder().decode(bytes));126    events.push("send:" + typeOf(message));127    socket.write(Buffer.from(bytes).toString("base64") + "\n");128  },129  async receive() {130    const next = await iterator.next();131    if (next.done) throw new Error("cross-client peer closed");132    const bytes = Uint8Array.from(Buffer.from(next.value.trim(), "base64"));133    events.push("recv:" + typeOf(JSON.parse(new TextDecoder().decode(bytes))));134    return bytes;135  },136  async close() { socket.end(); },137};138const entryId = id + "-entry";139const localMtime = id === "browser" ? 10 : 20;140const remoteMtime = id === "browser" ? 20 : 10;141const adapter = {142  async computeVaultId() { return "cross-client-vault"; },143  async getOurDeviceId() { return id; },144  async getLocalVersion() { return 7; },145  async buildEntryManifest() { return { [entryId]: localMtime }; },146  async buildTombstoneManifest() { return {}; },147  async getEncryptedEntries(ids) { return Object.fromEntries(ids.filter((value) => value === entryId).map((value) => [value, id + "-frame"])); },148  async applyEntries() {},149  async applyTombstones() {},150  async buildOutgoingDeviceManifest() { return { body: { manifestVersion: syncTypes.DEVICE_MANIFEST_VERSION, issuerDeviceId: id, issuedAt: 1700000000000, devices: [], revoked: [] }, signatureHex: "11".repeat(64) }; },151  async applyIncomingDeviceManifest() {},152};153const outcome = mode === "initiator"154  ? await coordinator.runSyncInitiator(session, adapter)155  : await coordinator.runSyncResponder(session, adapter);156require("node:fs").writeFileSync(transcriptFile, JSON.stringify({ status: outcome.status, events }));157socket.end();158`.replace("const remoteMtime = id === \"browser\" ? 20 : 10;", "");159160const MAX_DIAGNOSTIC_BYTES = 12_000;161162function spawnProcess(command, args, env) {163  const child = spawn(command, args, { cwd: env.PEACH_PROCESS_ROOT ?? env.PEACH_BROWSER_ROOT ?? browserRoot, env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"] });164  const output = { stdout: "", stderr: "" };165  for (const [name, stream] of [["stdout", child.stdout], ["stderr", child.stderr]]) {166    stream.setEncoding("utf8");167    stream.on("data", (chunk) => {168      output[name] = (output[name] + chunk).slice(-MAX_DIAGNOSTIC_BYTES);169    });170  }171  child.diagnostics = output;172  return child;173}174175function waitForExit(child) {176  return new Promise((resolveExit, reject) => {177    child.once("error", reject);178    child.once("exit", (code, signal) => resolveExit({ code, signal, diagnostics: child.diagnostics }));179  });180}181182function terminate(child) {183  if (!child || child.exitCode !== null || child.signalCode !== null) return;184  child.kill("SIGTERM");185  setTimeout(() => {186    if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");187  }, 1_000).unref();188}189190function formatResult(result) {191  const output = result.diagnostics;192  return `code=${result.code ?? "none"} signal=${result.signal ?? "none"}; stdout=${JSON.stringify(output.stdout)} stderr=${JSON.stringify(output.stderr)}`;193}194195async function waitForFile(path, child) {196  const deadline = Date.now() + 30_000;197  while (!existsSync(path)) {198    if (child.exitCode !== null) throw new Error(`endpoint exited before publishing ${path} (${child.exitCode})`);199    if (Date.now() > deadline) throw new Error(`timed out waiting for ${path}`);200    await new Promise((resolve) => setTimeout(resolve, 50));201  }202  return readFileSync(path, "utf8").trim();203}204205function readTranscript(path) {206  return JSON.parse(readFileSync(path, "utf8"));207}208209const androidTask = ["--no-daemon", "--no-configuration-cache", "--rerun-tasks", "testDebugUnitTest", "--tests", "com.peachpasswords.sync.CrossClientSyncProcessTest.exchange_production_frames_with_browser_process"];210const gradleProperty = (name, value) => `-D${name.toLowerCase().replaceAll("_", ".")}=${value}`;211212async function runRound({ initiator, mismatch = false }) {213  const dir = mkdtempSync(resolve(tmpdir(), "peach-cross-sync-"));214  const androidTranscript = resolve(dir, "android.json");215  const browserTranscript = resolve(dir, "browser.json");216  const androidPortFile = resolve(dir, "android.port");217  const browserPortFile = resolve(dir, "browser.port");218  const common = { PEACH_BROWSER_ROOT: browserRoot };219  const launchBrowser = (mode, port, transcript, portFile) => spawnProcess("bun", ["--eval", browserEndpoint], {220    ...common,221    PEACH_SYNC_HARNESS_MODE: mode,222    PEACH_SYNC_HARNESS_PORT: port ?? "",223    PEACH_SYNC_HARNESS_PORT_FILE: portFile ?? "",224    PEACH_SYNC_HARNESS_TRANSCRIPT_FILE: transcript,225  });226  const launchAndroid = (mode, port) => spawnProcess(resolve(androidRoot, "gradlew"), [227    gradleProperty("PEACH_SYNC_HARNESS_MODE", mode),228    gradleProperty("PEACH_SYNC_HARNESS_PORT", port ?? ""),229    gradleProperty("PEACH_SYNC_HARNESS_PORT_FILE", androidPortFile),230    gradleProperty("PEACH_SYNC_HARNESS_TRANSCRIPT_FILE", androidTranscript),231    ...(mismatch ? [gradleProperty("PEACH_SYNC_HARNESS_MISMATCH", "message-order")] : []),232    ...androidTask,233  ], {234    ...common,235    PEACH_PROCESS_ROOT: androidRoot,236    PEACH_SYNC_HARNESS_MODE: mode,237    PEACH_SYNC_HARNESS_PORT: port ?? "",238    PEACH_SYNC_HARNESS_PORT_FILE: androidPortFile,239    PEACH_SYNC_HARNESS_TRANSCRIPT_FILE: androidTranscript,240    ...(mismatch ? { PEACH_SYNC_HARNESS_MISMATCH: "message-order" } : {}),241    ...(javaHome ? { JAVA_HOME: javaHome } : {}),242  });243244  let first;245  let second;246  let timeoutId;247  try {248    if (initiator === "android") {249      first = launchAndroid("initiator");250      const port = await waitForFile(androidPortFile, first);251      second = launchBrowser("responder", port, browserTranscript);252    } else {253      first = launchBrowser("initiator", undefined, browserTranscript, browserPortFile);254      const port = await waitForFile(browserPortFile, first);255      second = launchAndroid("responder", port);256    }257    const timeout = new Promise((_, reject) => {258      timeoutId = setTimeout(259        () => reject(new Error(`cross-client ${initiator} round timed out after 120s`)),260        120_000,261      );262    });263    const results = await Promise.race([264      Promise.all([waitForExit(first), waitForExit(second)]),265      timeout,266    ]);267    const [firstResult, secondResult] = results;268    if (!mismatch && (firstResult.code !== 0 || secondResult.code !== 0)) {269      throw new Error(`cross-client ${initiator} round failed: first ${formatResult(firstResult)}; second ${formatResult(secondResult)}`);270    }271    return { android: readTranscript(androidTranscript), browser: readTranscript(browserTranscript) };272  } catch (error) {273    const details = [first, second].filter(Boolean).map((child) => child.diagnostics).map((output) => `stdout=${JSON.stringify(output.stdout)} stderr=${JSON.stringify(output.stderr)}`).join("; ");274    throw new Error(`${error.message}; child diagnostics: ${details}`, { cause: error });275  } finally {276    clearTimeout(timeoutId);277    terminate(first);278    terminate(second);279  }280}281282function assertTranscript(actual, expected, label) {283  const got = JSON.stringify(actual.events);284  const want = JSON.stringify(expected);285  if (got !== want) throw new Error(`${label} transcript mismatch: expected ${want}, got ${got}`);286}287288const 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"];289const 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"];290const 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"];291const 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"];292293const mismatch = await runRound({ initiator: "browser", mismatch: true });294if (mismatch.android.status === "COMPLETED" || mismatch.browser.status === "completed") throw new Error("intentional message-order mismatch unexpectedly completed");295const androidRound = await runRound({ initiator: "android" });296if (androidRound.android.status !== "COMPLETED" || androidRound.browser.status !== "completed") throw new Error("Android-initiated cross-client round did not complete");297assertTranscript(androidRound.android, androidInitiator, "Android initiator");298assertTranscript(androidRound.browser, browserResponder, "Browser responder");299const browserRound = await runRound({ initiator: "browser" });300if (browserRound.android.status !== "COMPLETED" || browserRound.browser.status !== "completed") throw new Error("Browser-initiated cross-client round did not complete");301assertTranscript(browserRound.browser, browserInitiator, "Browser initiator");302assertTranscript(browserRound.android, androidResponder, "Android responder");303console.log("Cross-client sync process conformance passed (both initiators, manifest negotiation, mismatch rejection).");