check-cross-client-sync.mjs

40.7 KB · raw

1import { existsSync, readFileSync, mkdtempSync } from "node:fs";2import { dirname, resolve } from "node:path";3import { fileURLToPath } from "node:url";4import { execFileSync, spawn } from "node:child_process";5import { createPrivateKey, createPublicKey, sign as signEd25519Node } from "node:crypto";6import { tmpdir } from "node:os";78const browserRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");9const androidRoot = resolve(browserRoot, "../peach-android");10const localJavaHome = "/opt/homebrew/opt/openjdk@17";11const javaHome = process.env.JAVA_HOME ?? (existsSync(localJavaHome) ? localJavaHome : undefined);12const browserCoordinator = readFileSync(resolve(browserRoot, "src/background/sync-coordinator.ts"), "utf8");13const androidCoordinator = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt"), "utf8");14const browserSyncTypes = readFileSync(resolve(browserRoot, "src/shared/sync-types.ts"), "utf8");15const androidManifest = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt"), "utf8");16const androidSyncMessage = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/SyncMessage.kt"), "utf8");17const browserPairedDevices = readFileSync(resolve(browserRoot, "src/background/paired-devices.ts"), "utf8");18const androidPairedDevices = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt"), "utf8");19const browserAdoption = readFileSync(resolve(browserRoot, "src/shared/adoption-protocol.ts"), "utf8");20const androidAdoption = [21  "AdoptionProtocol.kt",22  "VaultAdopt.kt",23].map((file) => readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync", file), "utf8")).join("\n");2425// Keep the source guards as cheap secondary checks. The real gate below crosses26// a process boundary and exercises each production coordinator and JSON codec.27if (browserCoordinator.includes('helloIn.version === localVersion')) throw new Error("Browser still contains the forbidden equal-version fast path");28if (!browserCoordinator.includes("helloIn.deviceManifestVersion === DEVICE_MANIFEST_VERSION")) throw new Error("Browser does not negotiate device manifests from SYNC_HELLO");29if (!androidCoordinator.includes("val exchangeDeviceManifest = vault.negotiatesDeviceManifest(hello)")) throw new Error("Android does not negotiate device manifests from SYNC_HELLO");30if (!androidCoordinator.includes("peerVersion == DEVICE_MANIFEST_VERSION")) throw new Error("Android does not require an exact device-manifest version match");3132const numericConstant = (source, declaration) => {33  const match = source.match(new RegExp(`${declaration}\\s*=\\s*(0x[0-9a-f]+|\\d+)`));34  if (!match) throw new Error(`Missing ${declaration} declaration`);35  return Number(match[1]);36};37if (numericConstant(browserSyncTypes, "DEVICE_MANIFEST_VERSION") !== numericConstant(androidManifest, "DEVICE_MANIFEST_VERSION")) {38  throw new Error("Browser and Android device-manifest versions differ");39}40if (numericConstant(browserSyncTypes, "DEVICE_REVOCATION_RECORD_VERSION") !== numericConstant(androidSyncMessage, "DEVICE_REVOCATION_RECORD_VERSION")) {41  throw new Error("Browser and Android revocation record versions differ");42}43// The canonical revocation object is signed field-by-field, so a renamed or44// dropped field silently stops the two clients verifying each other.45for (const field of ["recordVersion", "deviceId", "longTermPublicKeyHex", "revokedByDeviceId"]) {46  if (!browserSyncTypes.includes(field) || !androidSyncMessage.includes(field)) {47    throw new Error(`A client lacks revocation field ${field}`);48  }49}50if (browserSyncTypes.includes("issuerSigningPublicKeyHex") || androidSyncMessage.includes("issuerSigningKeyHex")) {51  throw new Error("A client still carries an issuer key inside the revocation record");52}53const syncProtocolVersion = (source, label) => {54  const match = source.match(/SYNC_PROTOCOL_VERSION\s*=\s*(\d+)/);55  if (!match) throw new Error(`Missing SYNC_PROTOCOL_VERSION declaration in ${label}`);56  return Number(match[1]);57};58const browserProtocolVersion = syncProtocolVersion(browserSyncTypes, "Browser");59const androidProtocolVersion = syncProtocolVersion(androidCoordinator, "Android");60if (browserProtocolVersion !== androidProtocolVersion) {61  throw new Error(`Browser and Android Sync protocol versions differ (${browserProtocolVersion} vs ${androidProtocolVersion})`);62}63if (numericConstant(browserAdoption, "ADOPTION_VERSION") !== numericConstant(androidAdoption, "ADOPTION_VERSION")) {64  throw new Error("Browser and Android adoption transcript versions differ");65}66for (const frame of [67  "ADOPT_REQUEST_V2",68  "ADOPT_CONFIRM_REQUIRED",69  "ADOPT_CONFIRM",70  "ADOPT_PREPARE",71  "ADOPT_READY",72  "ADOPT_GRANT",73  "ADOPT_APPLIED",74  "ADOPT_COMPLETE",75  "ADOPT_REJECT",76]) {77  if (!browserAdoption.includes(frame) || !androidAdoption.includes(frame)) {78    throw new Error(`A client lacks adoption v2 frame ${frame}`);79  }80}81// Both clients must require exact equality, never a "greater-or-equal" pass.82if (browserCoordinator.includes("protocolVersion ?? 1) < SYNC_PROTOCOL_VERSION") ||83    androidCoordinator.includes("< SYNC_PROTOCOL_VERSION")) {84  throw new Error("A client still accepts a peer protocol version below its own");85}86if (!browserSyncTypes.includes("signatureHex") || !androidSyncMessage.includes("signatureHex")) {87  throw new Error("A client lacks the revocation signature field");88}89if (!browserPairedDevices.includes("MAX_REVOCATIONS = 500") || !androidPairedDevices.includes("MAX_REVOCATION_RECORDS = 500")) throw new Error("Browser and Android revocation ledger caps differ");90if (!browserPairedDevices.includes("enqueueDeviceMutation") || !androidPairedDevices.includes("RegistryMutationGate")) throw new Error("A paired-device registry lacks whole-list mutation serialization");91if (!browserPairedDevices.includes("function capRevocations") || !androidPairedDevices.includes("fun capRevocationRecords")) throw new Error("A client lacks largest-issuer-first revocation retention");92const browserEndpoint = String.raw`93import { createInterface } from "node:readline";94import { createServer, createConnection } from "node:net";95const root = process.env.PEACH_BROWSER_ROOT;96const coordinator = await import(root + "/src/background/sync-coordinator.ts");97const syncTypes = await import(root + "/src/shared/sync-types.ts");98const padding = await import(root + "/src/shared/sync-padding.ts");99const mode = process.env.PEACH_SYNC_HARNESS_MODE;100const portFile = process.env.PEACH_SYNC_HARNESS_PORT_FILE;101const transcriptFile = process.env.PEACH_SYNC_HARNESS_TRANSCRIPT_FILE;102103// Scenario shape arrives entirely from the parent; this endpoint does not know104// what a scenario is. Both clients read the same knobs, so the matrix lives in105// one readable place rather than half here and half in the harness.106const knobs = {107  parity: process.env.PEACH_SYNC_HARNESS_MTIME_PARITY ?? "",108  localVersion: Number(process.env.PEACH_SYNC_HARNESS_LOCAL_VERSION ?? "7"),109  vaultId: process.env.PEACH_SYNC_HARNESS_VAULT_ID ?? "cross-client-vault",110  manifestSupport: process.env.PEACH_SYNC_HARNESS_MANIFEST_VERSION !== "3",111  entryCount: Number(process.env.PEACH_SYNC_HARNESS_ENTRY_COUNT ?? "1"),112  expectStatus: process.env.PEACH_SYNC_HARNESS_EXPECT_STATUS ?? "completed",113  drain: process.env.PEACH_SYNC_HARNESS_DRAIN === "1",114  // A locked session may hold the transport key and stage ciphertext, but it115  // never authors or applies a device manifest and never merges entries into116  // the live vault (PB-26-112 C).117  locked: process.env.PEACH_SYNC_HARNESS_LOCKED === "1",118  stopAfter: process.env.PEACH_SYNC_HARNESS_STOP_AFTER ?? "",119  // A manifest the harness signed: the wire shape IS the scenario, and one120  // signer (the harness, in Node) keeps both clients verifying the same bytes121  // rather than each signing its own.122  signedManifestJson: process.env.PEACH_SYNC_HARNESS_SIGNED_MANIFEST ?? "",123  trustedPubkeyHex: process.env.PEACH_SYNC_HARNESS_TRUSTED_PUBKEY ?? "",124};125126const id = mode === "initiator" ? "browser" : "android";127const socket = await new Promise((resolve, reject) => {128  if (mode === "initiator") {129    const server = createServer((client) => {130      // Stop listening as soon as the one conformance peer connects. Leaving131      // this listener open keeps Bun alive after the coordinator completes.132      server.close();133      resolve(client);134    });135    server.once("error", reject);136    server.listen(0, "127.0.0.1", () => require("node:fs").writeFileSync(portFile, String(server.address().port)));137  } else {138    const client = createConnection({ host: "127.0.0.1", port: Number(process.env.PEACH_SYNC_HARNESS_PORT) });139    client.once("connect", () => resolve(client));140    client.once("error", reject);141  }142});143socket.setEncoding("utf8");144const lines = createInterface({ input: socket });145const iterator = lines[Symbol.asyncIterator]();146const events = [];147const typeOf = (message) => message.type;148let outcome = null;149let counted = { sent: 0, received: 0, applied: 0, staged: 0 };150let manifestVerdict = null;151const fs = require("node:fs");152const writeTranscript = (status) => fs.writeFileSync(transcriptFile, JSON.stringify({153  status,154  error: outcome?.error,155  entriesSent: counted.sent,156  entriesReceived: counted.received,157  applied: counted.applied,158  staged: counted.staged,159  manifestAccepted: manifestVerdict === null ? null : manifestVerdict === "accepted",160  events,161}));162const session = {163  remoteStaticPublicKey: new Uint8Array(32).fill(7),164  send(bytes) {165    const message = JSON.parse(new TextDecoder().decode(bytes));166    events.push("send:" + typeOf(message));167    // Simulated process death: the transcript is on disk before the process168    // goes, so the parent can still say what the peer had seen.169    if (knobs.stopAfter !== "" && knobs.stopAfter === typeOf(message)) {170      writeTranscript("DIED");171      process.exit(7);172    }173    socket.write(Buffer.from(padding.padSyncFrame(bytes)).toString("base64") + "\n");174  },175  async receive() {176    const next = await iterator.next();177    if (next.done) throw new Error("cross-client peer closed");178    const payload = padding.unpadSyncFrame(Uint8Array.from(Buffer.from(next.value.trim(), "base64")));179    events.push("recv:" + typeOf(JSON.parse(new TextDecoder().decode(payload))));180    return payload;181  },182  async close() { socket.end(); },183};184185// Entries are shared ids so a diff can actually flow. Parity decides which side186// is newer for a given index: the same parity on both sides means equal data187// versions (nothing to send), opposite parities means each side is newer for188// half the vault (entries flow both ways).189const manifestApi = await import(root + "/src/shared/device-manifest.ts");190const hexBytes = (hex) => Uint8Array.from(hex.match(/../g).map((pair) => parseInt(pair, 16)));191const crafted = knobs.signedManifestJson === "" ? null : JSON.parse(knobs.signedManifestJson);192193const ids = Array.from({ length: knobs.entryCount }, (_, index) => "shared-entry-" + index);194const mtimeFor = (index) =>195  knobs.parity === "" ? 10 : (index % 2 === Number(knobs.parity) ? 15 : 10);196const entries = Object.fromEntries(ids.map((entryId, index) => [entryId, mtimeFor(index)]));197const blobs = Object.fromEntries(ids.map((entryId, index) => [entryId, id + "-frame-" + (index % 8)]));198199const adapter = {200  async computeVaultId() { return knobs.vaultId; },201  async getOurDeviceId() { return id; },202  async getLocalVersion() { return knobs.localVersion; },203  async buildEntryManifest() { return { ...entries }; },204  async buildTombstoneManifest() { return {}; },205  async getEncryptedEntries(requested) {206    return Object.fromEntries(requested.filter((value) => value in blobs).map((value) => [value, blobs[value]]));207  },208  async applyEntries(entries) {209    if (knobs.locked) {210      counted.staged += Object.keys(entries).length;211      return;212    }213    counted.applied += Object.keys(entries).length;214  },215  async applyTombstones() {},216  // The coordinator reads capability off the adapter's shape, not a flag: a217  // manifest-v3 peer is one that does not expose the exchange at all. Modelling218  // it any other way would let this fixture advertise a version it cannot speak219  // — the same class of mistake the scenario is here to catch.220  ...(knobs.manifestSupport && !knobs.locked221    ? {222        async buildOutgoingDeviceManifest() {223          return crafted ?? { body: { manifestVersion: syncTypes.DEVICE_MANIFEST_VERSION, issuerDeviceId: id, issuedAt: Date.now(), devices: [], revoked: [] }, signatureHex: "11".repeat(64) };224        },225        async applyIncomingDeviceManifest(incoming) {226          if (!incoming) return;227          if (knobs.trustedPubkeyHex === "") { manifestVerdict = "not-checked"; return; }228          try {229            await manifestApi.verifyDeviceManifest(incoming, hexBytes(knobs.trustedPubkeyHex), {});230            manifestVerdict = "accepted";231          } catch {232            manifestVerdict = "rejected";233          }234        },235      }236    : {}),237};238239outcome = mode === "initiator"240  ? await coordinator.runSyncInitiator(session, adapter)241  : await coordinator.runSyncResponder(session, adapter);242counted.sent = outcome.entriesSent ?? 0;243counted.received = outcome.entriesReceived ?? 0;244245// A clean stop is only deterministic if our socket is still up when the peer246// writes its last frame, so hold it open until the peer goes away rather than247// exiting the instant the round ends.248if (knobs.drain) {249  await Promise.race([250    (async () => { for await (const _ of lines) { /* drain */ } })(),251    new Promise((resolve) => setTimeout(resolve, 2000)),252  ]);253}254255writeTranscript(outcome.status);256socket.end();257if (outcome.status !== knobs.expectStatus) {258  console.error("expected " + knobs.expectStatus + ", got " + outcome.status);259  process.exit(1);260}261`;262263/**264 * Manifest fixtures for the adversarial cases.265 *266 * The canonical bytes come from the Browser's *production* canonicaliser (so267 * the harness cannot drift from the wire contract), and the Ed25519 signature268 * comes from Node — one signer, both clients verifying the identical bytes.269 */270const PKCS8_ED25519_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");271const trustedKey = createPrivateKey({272  key: Buffer.concat([PKCS8_ED25519_PREFIX, Buffer.from("11".repeat(32), "hex")]),273  format: "der",274  type: "pkcs8",275});276const TRUSTED_PUBKEY_HEX = createPublicKey(trustedKey)277  .export({ format: "der", type: "spki" })278  .subarray(-32)279  .toString("hex");280const UNTRUSTED_PUBKEY_HEX = createPublicKey(createPrivateKey({281  key: Buffer.concat([PKCS8_ED25519_PREFIX, Buffer.from("22".repeat(32), "hex")]),282  format: "der",283  type: "pkcs8",284})).export({ format: "der", type: "spki" }).subarray(-32).toString("hex");285286function canonicalBody(body) {287  return execFileSync("bun", [288    "--eval",289    `import { canonicalise } from ${JSON.stringify(resolve(browserRoot, "src/shared/device-manifest.ts"))}; process.stdout.write(canonicalise(JSON.parse(process.env.PEACH_MANIFEST_SPEC)));`,290  ], { env: { ...process.env, PEACH_MANIFEST_SPEC: JSON.stringify(body) } }).toString();291}292293/** Sign a crafted body; `seed` picks the key, so a bad signature is one argument. */294function signManifest({ issuerDeviceId = "crafted-issuer", nowOffsetMs = 0, devices = [], revoked = [], seed = "11".repeat(32) } = {}) {295  const body = {296    // Read, never assumed: a hard-coded version makes every case reject for the297    // same reason, and the whole group proves nothing. The control case is what298    // catches that.299    manifestVersion: numericConstant(browserSyncTypes, "DEVICE_MANIFEST_VERSION"),300    issuerDeviceId,301    issuedAt: Date.now() + nowOffsetMs,302    devices,303    revoked,304  };305  const key = createPrivateKey({306    key: Buffer.concat([PKCS8_ED25519_PREFIX, Buffer.from(seed, "hex")]),307    format: "der",308    type: "pkcs8",309  });310  const signature = signEd25519Node(null, Buffer.from(canonicalBody(body), "utf8"), key);311  return JSON.stringify({ body, signatureHex: signature.toString("hex") });312}313314const deviceEntry = (overrides = {}) => ({315  deviceId: "crafted-device",316  deviceName: "Crafted",317  deviceType: "android",318  longTermPublicKeyHex: "aa".repeat(32),319  signingPublicKeyHex: "bb".repeat(32),320  pairedAt: 1_700_000_000_000,321  vouchDepth: 0,322  ...overrides,323});324325const MAX_DIAGNOSTIC_BYTES = 12_000;326327function spawnProcess(command, args, env) {328  const child = spawn(command, args, { cwd: env.PEACH_PROCESS_ROOT ?? env.PEACH_BROWSER_ROOT ?? browserRoot, env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"] });329  const output = { stdout: "", stderr: "" };330  for (const [name, stream] of [["stdout", child.stdout], ["stderr", child.stderr]]) {331    stream.setEncoding("utf8");332    stream.on("data", (chunk) => {333      output[name] = (output[name] + chunk).slice(-MAX_DIAGNOSTIC_BYTES);334    });335  }336  child.diagnostics = output;337  return child;338}339340function waitForExit(child) {341  return new Promise((resolveExit, reject) => {342    child.once("error", reject);343    child.once("exit", (code, signal) => resolveExit({ code, signal, diagnostics: child.diagnostics }));344  });345}346347function terminate(child) {348  if (!child || child.exitCode !== null || child.signalCode !== null) return;349  child.kill("SIGTERM");350  setTimeout(() => {351    if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");352  }, 1_000).unref();353}354355function formatResult(result) {356  const output = result.diagnostics;357  return `code=${result.code ?? "none"} signal=${result.signal ?? "none"}; stdout=${JSON.stringify(output.stdout)} stderr=${JSON.stringify(output.stderr)}`;358}359360async function waitForFile(path, child) {361  const deadline = Date.now() + 30_000;362  while (!existsSync(path)) {363    if (child.exitCode !== null) throw new Error(`endpoint exited before publishing ${path} (${child.exitCode})`);364    if (Date.now() > deadline) throw new Error(`timed out waiting for ${path}`);365    await new Promise((resolve) => setTimeout(resolve, 50));366  }367  return readFileSync(path, "utf8").trim();368}369370function readTranscript(path) {371  return JSON.parse(readFileSync(path, "utf8"));372}373374const androidTask = ["--no-daemon", "--no-configuration-cache", "--rerun-tasks", "testDebugUnitTest", "--tests", "com.peachpasswords.sync.CrossClientSyncProcessTest.exchange_production_frames_with_browser_process"];375const gradleProperty = (name, value) => `-D${name.toLowerCase().replaceAll("_", ".")}=${value}`;376const KNOB_PREFIX = "PEACH_SYNC_HARNESS_";377const knobEnv = (knobs) =>378  Object.fromEntries(Object.entries(knobs).map(([name, value]) => [KNOB_PREFIX + name, String(value)]));379380/**381 * Run one scenario round. Both endpoints assert their own expected status, so a382 * non-zero exit means "my side did not behave as the matrix says it should" and383 * the scenario is free to describe a failure outcome without special-casing.384 */385async function runRound({ initiator, android = {}, browser = {}, timeoutMs = 180_000, label = "", expectFailure = [], manifest = null }) {386  // A crafted manifest goes to both sides: each sends it and each verdicts the387  // peer's, so one scenario exercises both clients against identical bytes.388  // Signed per round, not at module load: a fixture built once drifts out of389  // its own freshness window while earlier rounds run, and a "+10 minutes in the390  // future" case silently stops being in the future.391  const manifestKnobs = manifest392    ? { SIGNED_MANIFEST: typeof manifest === "function" ? manifest() : manifest, TRUSTED_PUBKEY: TRUSTED_PUBKEY_HEX }393    : {};394  const dir = mkdtempSync(resolve(tmpdir(), "peach-cross-sync-"));395  const androidTranscript = resolve(dir, "android.json");396  const browserTranscript = resolve(dir, "browser.json");397  const androidPortFile = resolve(dir, "android.port");398  const browserPortFile = resolve(dir, "browser.port");399  const common = { PEACH_BROWSER_ROOT: browserRoot };400  const launchBrowser = (mode, port, transcript, portFile) => spawnProcess("bun", ["--eval", browserEndpoint], {401    ...common,402    ...knobEnv({ ...manifestKnobs, ...browser }),403    PEACH_SYNC_HARNESS_MODE: mode,404    PEACH_SYNC_HARNESS_PORT: port ?? "",405    PEACH_SYNC_HARNESS_PORT_FILE: portFile ?? "",406    PEACH_SYNC_HARNESS_TRANSCRIPT_FILE: transcript,407  });408  const launchAndroid = (mode, port) => spawnProcess(resolve(androidRoot, "gradlew"), [409    gradleProperty("PEACH_SYNC_HARNESS_MODE", mode),410    ...androidTask,411  ], {412    ...common,413    ...knobEnv({ ...manifestKnobs, ...android }),414    PEACH_PROCESS_ROOT: androidRoot,415    PEACH_SYNC_HARNESS_MODE: mode,416    PEACH_SYNC_HARNESS_PORT: port ?? "",417    PEACH_SYNC_HARNESS_PORT_FILE: androidPortFile,418    PEACH_SYNC_HARNESS_TRANSCRIPT_FILE: androidTranscript,419    ...(javaHome ? { JAVA_HOME: javaHome } : {}),420  });421422  let first;423  let second;424  let timeoutId;425  try {426    if (initiator === "android") {427      first = launchAndroid("initiator");428      const port = await waitForFile(androidPortFile, first);429      second = launchBrowser("responder", port, browserTranscript);430    } else {431      first = launchBrowser("initiator", undefined, browserTranscript, browserPortFile);432      const port = await waitForFile(browserPortFile, first);433      second = launchAndroid("responder", port);434    }435    const timeout = new Promise((_, reject) => {436      timeoutId = setTimeout(437        () => reject(new Error(`${label} timed out after ${Math.round(timeoutMs / 1000)}s`)),438        timeoutMs,439      );440    });441    const results = await Promise.race([442      Promise.all([waitForExit(first), waitForExit(second)]),443      timeout,444    ]);445    const [firstResult, secondResult] = results;446    // A scenario that deliberately kills an endpoint names that side here; the447    // other side must still behave, which is what makes the round meaningful.448    const initiatorSide = initiator;449    const responderSide = initiator === "android" ? "browser" : "android";450    const tolerate = (side, result) => expectFailure.includes(side) || result.code === 0;451    if (!tolerate(initiatorSide, firstResult) || !tolerate(responderSide, secondResult)) {452      throw new Error(`${label} failed: initiator(${initiatorSide}) ${formatResult(firstResult)}; responder(${responderSide}) ${formatResult(secondResult)}`);453    }454    return { android: readTranscript(androidTranscript), browser: readTranscript(browserTranscript) };455  } catch (error) {456    const details = [first, second].filter(Boolean).map((child) => child.diagnostics)457      .map((output) => `stdout=${JSON.stringify(output.stdout)} stderr=${JSON.stringify(output.stderr)}`).join("; ");458    throw new Error(`${error.message}; child diagnostics: ${details}`, { cause: error });459  } finally {460    clearTimeout(timeoutId);461    terminate(first);462    terminate(second);463  }464}465466/** The two halves of a completed round, as a function of what the scenario expects to happen. */467function expectedRound({ flow, manifests }) {468  return {469    initiator: [470      "send:SYNC_HELLO", "recv:SYNC_HELLO",471      "send:SYNC_MANIFEST", "recv:SYNC_MANIFEST",472      "send:SYNC_REQUEST", "recv:SYNC_REQUEST",473      "send:SYNC_ENTRIES", ...(flow ? ["recv:SYNC_ENTRIES"] : []),474      "send:SYNC_TOMBSTONES", "recv:SYNC_TOMBSTONES",475      ...(manifests ? ["send:SYNC_DEVICE_MANIFEST", "recv:SYNC_DEVICE_MANIFEST"] : []),476      "send:SYNC_DONE", "recv:SYNC_DONE",477    ],478    responder: [479      "recv:SYNC_HELLO", "send:SYNC_HELLO",480      "recv:SYNC_MANIFEST", "send:SYNC_MANIFEST",481      "recv:SYNC_REQUEST", "send:SYNC_REQUEST",482      "recv:SYNC_ENTRIES", ...(flow ? ["send:SYNC_ENTRIES"] : []),483      "recv:SYNC_TOMBSTONES", "send:SYNC_TOMBSTONES",484      ...(manifests ? ["recv:SYNC_DEVICE_MANIFEST", "send:SYNC_DEVICE_MANIFEST"] : []),485      "recv:SYNC_DONE", "send:SYNC_DONE",486    ],487  };488}489490const normalise = (status) => String(status).toLowerCase();491const allEvents = (round) => [...round.android.events, ...round.browser.events];492493function assertTranscript(actual, expected, label) {494  const got = JSON.stringify(actual.events);495  const want = JSON.stringify(expected);496  if (got !== want) throw new Error(`${label} transcript mismatch: expected ${want}, got ${got}`);497}498499function assertCompleted(round, { flow, manifests, label }) {500  const expected = expectedRound({ flow, manifests });501  for (const side of ["android", "browser"]) {502    const status = normalise(round[side].status);503    if (status !== "completed") throw new Error(`${label}: ${side} ended ${round[side].status} (${round[side].error ?? "no error"})`);504    const role = side === (round.initiatedBy === "android" ? "android" : "browser");505    assertTranscript(round[side], expected[role], `${label}: ${side}`);506  }507}508509/**510 * The §7.1 matrix. Each entry names the scenario it implements, configures both511 * clients with the same knob vocabulary, and states the observable result.512 */513const SCENARIOS = [514  {515    id: 1,516    name: "equal versions, both unlocked, both manifest-v5",517    initiator: "android",518    // Same parity on both sides means identical mtimes: equal data versions.519    android: { MTIME_PARITY: "0", EXPECT_STATUS: "COMPLETED" },520    browser: { MTIME_PARITY: "0", EXPECT_STATUS: "completed" },521    check(round, { completed, transcript }) {522      completed(round, { flow: false, manifests: true, label: "scenario 1" });523      // The equal-version fast path must not come back: the manifest exchange524      // runs in full even when neither side has anything to send.525      transcript(round, "scenario 1", (t) => t.events.includes("send:SYNC_MANIFEST"));526    },527  },528  {529    id: 2,530    name: "unequal data versions, both unlocked, both manifest-v5",531    initiator: "android",532    // Two entries so each side is newer for one of them: entries genuinely flow533    // in both directions rather than one side requesting nothing.534    android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCAL_VERSION: "7", EXPECT_STATUS: "COMPLETED" },535    browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCAL_VERSION: "9", EXPECT_STATUS: "completed" },536    check(round, { completed, expect }) {537      completed(round, { flow: true, manifests: true, label: "scenario 2" });538      expect(round.android.entriesSent > 0 && round.android.entriesReceived > 0, "scenario 2: entries must flow both ways");539      expect(round.browser.entriesSent > 0 && round.browser.entriesReceived > 0, "scenario 2: entries must flow both ways");540    },541  },542  {543    id: 3,544    name: "equal versions, one manifest-v5 and one manifest-v3",545    initiator: "android",546    android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", EXPECT_STATUS: "COMPLETED" },547    browser: { MTIME_PARITY: "0", ENTRY_COUNT: "2", MANIFEST_VERSION: "3", EXPECT_STATUS: "completed" },548    check(round, { completed, expect }) {549      completed(round, { flow: false, manifests: false, label: "scenario 3" });550      expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 3: the manifest step must be skipped");551    },552  },553  {554    id: 4,555    name: "unequal data versions, one manifest-v5 and one manifest-v3",556    initiator: "browser",557    android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", MANIFEST_VERSION: "3", EXPECT_STATUS: "COMPLETED" },558    browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCAL_VERSION: "9", EXPECT_STATUS: "completed" },559    check(round, { completed, expect }) {560      completed(round, { flow: true, manifests: false, label: "scenario 4" });561      expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 4: the manifest step must be skipped");562    },563  },564  {565    id: 9,566    name: "manifest negotiation: no peer sends SYNC_DEVICE_MANIFEST unilaterally",567    initiator: "android",568    android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", EXPECT_STATUS: "COMPLETED" },569    browser: { MTIME_PARITY: "0", ENTRY_COUNT: "2", MANIFEST_VERSION: "3", EXPECT_STATUS: "completed" },570    check(round, { expect }) {571      // The capable side must not proceed on its own while the peer follows the572      // legacy sequence; that used to deadlock until the 30s session timeout.573      const events = allEvents(round);574      expect(!events.some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 9: a peer sent SYNC_DEVICE_MANIFEST while the other expected SYNC_DONE");575      expect(events.filter((event) => event.endsWith("SYNC_DONE")).length === 4, "scenario 9: both sides must still reach SYNC_DONE");576    },577  },578  {579    id: 13,580    name: "vault id mismatch stops cleanly",581    initiator: "browser",582    android: { MTIME_PARITY: "0", VAULT_ID: "vault-android", EXPECT_STATUS: "VAULT_MISMATCH", DRAIN: "1" },583    browser: { MTIME_PARITY: "0", VAULT_ID: "vault-browser", EXPECT_STATUS: "vault_mismatch", DRAIN: "1" },584    check(round, { expect, side }) {585      expect(normalise(round.android.status) === "vault_mismatch", `scenario 13: android ${round.android.status}`);586      expect(normalise(round.browser.status) === "vault_mismatch", `scenario 13: browser ${round.browser.status}`);587      // The initiator names the reason and stops; the responder stops without588      // reading another frame.589      side(round, "browser", "scenario 13: browser initiator", ["send:SYNC_HELLO", "recv:SYNC_HELLO", "send:SYNC_ERROR"]);590      side(round, "android", "scenario 13: android responder", ["recv:SYNC_HELLO", "send:SYNC_HELLO"]);591      expect(!allEvents(round).some((event) => event.endsWith("SYNC_MANIFEST")), "scenario 13: no data frames may follow a vault-id mismatch");592    },593  },594  {595    id: 5,596    name: "browser locked (spool active), android unlocked, unequal versions",597    initiator: "android",598    android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCAL_VERSION: "7", EXPECT_STATUS: "COMPLETED" },599    browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCAL_VERSION: "9", LOCKED: "1", EXPECT_STATUS: "completed" },600    check(round, { completed, expect }) {601      completed(round, { flow: true, manifests: false, label: "scenario 5" });602      // The locked side stages rather than merging, and authors no manifest.603      expect(round.browser.staged > 0, "scenario 5: the locked side must stage what it receives");604      expect(round.browser.applied === 0, "scenario 5: a locked session must not merge into the live vault");605      expect(round.browser.entriesReceived === round.browser.staged, "scenario 5: staged must account for everything received");606      expect(round.android.applied > 0, "scenario 5: the unlocked side applies normally");607      expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 5: a locked session must not exchange a manifest");608    },609  },610  {611    id: 6,612    name: "android locked (listener running), browser unlocked",613    initiator: "browser",614    android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCKED: "1", EXPECT_STATUS: "COMPLETED" },615    browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", EXPECT_STATUS: "completed" },616    check(round, { completed, expect }) {617      completed(round, { flow: true, manifests: false, label: "scenario 6" });618      expect(round.android.staged > 0, "scenario 6: the locked side must stage what it receives");619      expect(round.android.applied === 0, "scenario 6: a locked session must not merge into the live vault");620      expect(round.browser.applied > 0, "scenario 6: the unlocked side applies normally");621      expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 6: a locked session must not exchange a manifest");622    },623  },624  {625    id: 8,626    name: "both sides locked",627    initiator: "android",628    android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCKED: "1", EXPECT_STATUS: "COMPLETED" },629    browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCKED: "1", EXPECT_STATUS: "completed" },630    check(round, { completed, expect }) {631      completed(round, { flow: true, manifests: false, label: "scenario 8" });632      for (const side of ["android", "browser"]) {633        expect(round[side].staged > 0, `scenario 8: ${side} must stage`);634        expect(round[side].applied === 0, `scenario 8: ${side} must not merge while locked`);635      }636      expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 8: neither locked side may exchange a manifest");637    },638  },639  {640    id: 10,641    name: "process death mid-session, next round completes cleanly",642    initiator: "android",643    // The initiator dies immediately after its own HELLO: the responder must644    // survive that as an error, and the next round must be byte-clean — no645    // stale frame from the dead round may leak into it.646    android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", STOP_AFTER: "SYNC_HELLO", EXPECT_STATUS: "COMPLETED" },647    browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", EXPECT_STATUS: "errored", DRAIN: "1" },648    expectFailure: ["android"],649    check(round, { expect, side }) {650      expect(normalise(round.android.status) === "died", `scenario 10: android ended ${round.android.status}`);651      expect(normalise(round.browser.status) === "errored", `scenario 10: browser ended ${round.browser.status}`);652      side(round, "android", "scenario 10: the dead initiator", ["send:SYNC_HELLO"]);653    },654    async afterwards({ completed }, { runRound: run }) {655      const restarted = await run({656        initiator: "android",657        label: "scenario 10 (restarted)",658        android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCAL_VERSION: "7", EXPECT_STATUS: "COMPLETED" },659        browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCAL_VERSION: "9", EXPECT_STATUS: "completed" },660      });661      restarted.initiatedBy = "android";662      completed(restarted, { flow: true, manifests: true, label: "scenario 10 (restarted)" });663    },664  },665  {666    id: 12,667    name: "manifest with a device vouched too deeply is rejected",668    initiator: "android",669    manifest: () => signManifest({ devices: [deviceEntry({ vouchDepth: 99 })] }),670    android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", EXPECT_STATUS: "COMPLETED" },671    browser: { MTIME_PARITY: "0", ENTRY_COUNT: "2", EXPECT_STATUS: "completed" },672    check(round, { completed, expect, manifestVerdict }) {673      completed(round, { flow: false, manifests: true, label: "scenario 12" });674      manifestVerdict(round, "scenario 12", false);675    },676  },677  {678    id: 14,679    name: "manifest issued outside the freshness window is rejected",680    initiator: "android",681    // Ten minutes stale, against a five-minute window.682    manifest: () => signManifest({ nowOffsetMs: -10 * 60_000 }),683    android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", EXPECT_STATUS: "COMPLETED" },684    browser: { MTIME_PARITY: "0", ENTRY_COUNT: "2", EXPECT_STATUS: "completed" },685    check(round, { completed, expect, manifestVerdict }) {686      completed(round, { flow: false, manifests: true, label: "scenario 14" });687      manifestVerdict(round, "scenario 14", false);688    },689  },690  {691    id: 15,692    name: "10,000-entry vault with padded frames",693    initiator: "android",694    android: { MTIME_PARITY: "0", ENTRY_COUNT: "10000", EXPECT_STATUS: "COMPLETED" },695    browser: { MTIME_PARITY: "1", ENTRY_COUNT: "10000", EXPECT_STATUS: "completed" },696    timeoutMs: 300_000,697    check(round, { completed, expect }) {698      completed(round, { flow: true, manifests: true, label: "scenario 15" });699      // Opposite parities, so each side is newer for exactly half the vault:700      // all 10,000 entries cross, 5,000 in each direction.701      for (const side of ["android", "browser"]) {702        const sent = round[side].entriesSent;703        const received = round[side].entriesReceived;704        expect(sent === 5000, `scenario 15: ${side} sent ${sent}`);705        expect(received === 5000, `scenario 15: ${side} received ${received}`);706      }707    },708  },709];710711const helpers = {712  expect(condition, message) {713    if (!condition) throw new Error(message);714  },715  completed(round, options) {716    if (options.flow === undefined) throw new Error("completed() needs an explicit flow expectation");717    const expected = expectedRound(options);718    for (const [side, role] of [["android", round.initiatedBy === "android" ? "initiator" : "responder"], ["browser", round.initiatedBy === "browser" ? "initiator" : "responder"]]) {719      const status = normalise(round[side].status);720      if (status !== "completed") throw new Error(`${options.label}: ${side} ended ${round[side].status} (${round[side].error ?? "no error"})`);721      assertTranscript(round[side], expected[role], `${options.label}: ${side} ${role}`);722    }723  },724  transcript(round, label, predicate) {725    for (const side of ["android", "browser"]) {726      if (!predicate(round[side])) throw new Error(`${label}: unexpected transcript for ${side}: ${JSON.stringify(round[side].events)}`);727    }728  },729  manifestVerdict(round, label, expected) {730    for (const side of ["android", "browser"]) {731      const verdict = round[side].manifestAccepted;732      if (verdict !== expected) {733        throw new Error(`${label}: ${side} verdict was ${verdict}, expected ${expected}`);734      }735    }736  },737  side(round, side, label, expected) {738    const got = JSON.stringify(round[side].events);739    const want = JSON.stringify(expected);740    if (got !== want) throw new Error(`${label}: expected ${want}, got ${got}`);741  },742};743744/**745 * The §16.3 manifest-v5 adversarial cases: the same bytes offered to both746 * clients, each verdicting with its own production verification. The last case747 * is the control — without it, seven rejections would prove only that the748 * verifier says no to everything.749 */750const MANIFEST_CASES = [751  ["stale issuedAt", () => signManifest({ nowOffsetMs: -10 * 60_000 }), false],752  ["future issuedAt", () => signManifest({ nowOffsetMs: 10 * 60_000 }), false],753  ["vouchDepth above the absolute bound", () => signManifest({ devices: [deviceEntry({ vouchDepth: 99 })] }), false],754  ["device count above the bound", () => signManifest({ devices: Array.from({ length: 257 }, (_, i) => deviceEntry({ deviceId: `d-${i}` })) }), false],755  ["malformed device key", () => signManifest({ devices: [deviceEntry({ longTermPublicKeyHex: "aa".repeat(31) + "a" })] }), false],756  ["oversized issuer id", () => signManifest({ issuerDeviceId: "x".repeat(300) }), false],757  ["signature from an untrusted key", () => signManifest({ seed: "22".repeat(32) }), false],758  ["valid manifest (control)", () => signManifest({ devices: [deviceEntry()] }), true],759];760761// PEACH_SYNC_HARNESS_ONLY=13,15 runs a subset. Debugging the matrix one full762// pass at a time is otherwise a four-minute loop.763const only = (process.env.PEACH_SYNC_HARNESS_ONLY ?? "").split(",").filter(Boolean).map(Number);764const selected = only.length > 0 ? SCENARIOS.filter((scenario) => only.includes(scenario.id)) : SCENARIOS;765766const results = [];767for (const scenario of selected) {768  const started = Date.now();769  const round = await runRound({ ...scenario, label: `scenario ${scenario.id}` });770  round.initiatedBy = scenario.initiator;771  scenario.check(round, helpers);772  if (scenario.afterwards) await scenario.afterwards(helpers, { runRound });773  const elapsed = Date.now() - started;774  results.push({ ...scenario, elapsed });775  console.log(`  ✓ ${String(scenario.id).padStart(2)} ${scenario.name} (${(elapsed / 1000).toFixed(1)}s)`);776}777778// The §16.3 adversarial group. Each case is a full round, so this is the slow779// half of the gate; it is a release gate, not a pre-commit hook.780const manifestResults = [];781if (only.length === 0 || only.includes(-1)) {782  let index = 0;783  for (const [name, manifest, expected] of MANIFEST_CASES) {784    index += 1;785    const started = Date.now();786    const round = await runRound({787      initiator: "android",788      manifest,789      label: `§16.3 manifest case ${index} (${name})`,790      android: { MTIME_PARITY: "0", ENTRY_COUNT: "1", EXPECT_STATUS: "COMPLETED" },791      browser: { MTIME_PARITY: "0", ENTRY_COUNT: "1", EXPECT_STATUS: "completed" },792    });793    round.initiatedBy = "android";794    helpers.completed(round, { flow: false, manifests: true, label: `§16.3 case ${index}` });795    helpers.manifestVerdict(round, `§16.3 case ${index} (${name})`, expected);796    console.log(`  ✓ 16.3.${index} ${name} → ${expected ? "accepted" : "rejected"} (${((Date.now() - started) / 1000).toFixed(1)}s)`);797    manifestResults.push({ name, expected });798  }799}800801console.log("");802const scope = only.length > 0 ? ` (filtered to ${only.join(",")})` : "";803console.log(`Cross-client sync process conformance passed: ${results.length} of 15 §7.1 scenarios${scope}.`);804for (const result of results) {805  const timing = result.id === 15 ? ` — 10,000-entry round ${(result.elapsed / 1000).toFixed(1)}s wall clock` : "";806  console.log(`  #${result.id} ${result.name}${timing}`);807}808console.log("");809for (const result of manifestResults) { /* listed above with their verdicts */ }810console.log("");811console.log("Not yet covered by this gate (see the campaign §7.1 table):");812console.log("  #7  android cold-locked — evidence class is unit + real-device, NOT a process");813console.log("      transcript; the unit half is LockedSyncSpoolModelTest. Do not fake it here.");814console.log("  #11 revocation then re-vouch — the rule is entry ADMISSION, not manifest");815console.log("      validity: the Browser enforces it in sync-vault-adapter and Android in");816console.log("      PairedDeviceStore, both of which need real storage this endpoint has no");817console.log("      JVM-reachable path to. Its unit coverage exists on both clients; the");818console.log("      process class needs an adapter-level harness.");819console.log("  the adoption-v2 group — a separate transcript, not yet driven across the boundary.");