1136f6f812bd

drive thirteen matrix scenarios plus the manifest adversarial cases

time
2026-09-11 00:17 UTC · 1 month ago
machine
koh2
agent
deepseek/deepseek-v4-flash
session
cross-client matrix
parent
4c9c7d9d496d
1 file scripts/check-cross-client-sync.mjs+182 −12
scripts/check-cross-client-sync.mjs+182 −12
@@ -1,7 +1,8 @@11 import { existsSync, readFileSync, mkdtempSync } from "node:fs";22 import { dirname, resolve } from "node:path";33 import { fileURLToPath } from "node:url";4-import { spawn } from "node:child_process";4+import { execFileSync, spawn } from "node:child_process";5+import { createPrivateKey, createPublicKey, sign as signEd25519Node } from "node:crypto";56 import { tmpdir } from "node:os";67 78 const browserRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");@@ -115,6 +116,11 @@115116   // the live vault (PB-26-112 C).116117   locked: process.env.PEACH_SYNC_HARNESS_LOCKED === "1",117118   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 ?? "",118124 };119125 120126 const id = mode === "initiator" ? "browser" : "android";@@ -141,6 +147,7 @@141147 const typeOf = (message) => message.type;142148 let outcome = null;143149 let counted = { sent: 0, received: 0, applied: 0, staged: 0 };150+let manifestVerdict = null;144151 const fs = require("node:fs");145152 const writeTranscript = (status) => fs.writeFileSync(transcriptFile, JSON.stringify({146153   status,@@ -149,6 +156,7 @@149156   entriesReceived: counted.received,150157   applied: counted.applied,151158   staged: counted.staged,159+  manifestAccepted: manifestVerdict === null ? null : manifestVerdict === "accepted",152160   events,153161 }));154162 const session = {@@ -178,6 +186,10 @@178186 // is newer for a given index: the same parity on both sides means equal data179187 // versions (nothing to send), opposite parities means each side is newer for180188 // half the vault (entries flow both ways).189+const manifestApi = await import(root + "/src/shared/device-manifest.ts");190+const hexBytes = (hex) => Uint8Array.from(hex.match(/../g).map((pair) => parseInt(pair, 16)));191+const crafted = knobs.signedManifestJson === "" ? null : JSON.parse(knobs.signedManifestJson);192+181193 const ids = Array.from({ length: knobs.entryCount }, (_, index) => "shared-entry-" + index);182194 const mtimeFor = (index) =>183195   knobs.parity === "" ? 10 : (index % 2 === Number(knobs.parity) ? 15 : 10);@@ -208,9 +220,18 @@208220   ...(knobs.manifestSupport && !knobs.locked209221     ? {210222         async buildOutgoingDeviceManifest() {211-          return { body: { manifestVersion: syncTypes.DEVICE_MANIFEST_VERSION, issuerDeviceId: id, issuedAt: 1700000000000, devices: [], revoked: [] }, signatureHex: "11".repeat(64) };223+          return crafted ?? { body: { manifestVersion: syncTypes.DEVICE_MANIFEST_VERSION, issuerDeviceId: id, issuedAt: Date.now(), devices: [], revoked: [] }, signatureHex: "11".repeat(64) };212224         },213-        async applyIncomingDeviceManifest() {},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+        },214235       }215236     : {}),216237 };@@ -239,6 +260,68 @@239260 }240261 `;241262 263+/**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+ */270+const PKCS8_ED25519_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");271+const trustedKey = createPrivateKey({272+  key: Buffer.concat([PKCS8_ED25519_PREFIX, Buffer.from("11".repeat(32), "hex")]),273+  format: "der",274+  type: "pkcs8",275+});276+const TRUSTED_PUBKEY_HEX = createPublicKey(trustedKey)277+  .export({ format: "der", type: "spki" })278+  .subarray(-32)279+  .toString("hex");280+const 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");285+286+function 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+}292+293+/** Sign a crafted body; `seed` picks the key, so a bad signature is one argument. */294+function 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+}313+314+const 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+});324+242325 const MAX_DIAGNOSTIC_BYTES = 12_000;243326 244327 function spawnProcess(command, args, env) {@@ -299,7 +382,15 @@299382  * non-zero exit means "my side did not behave as the matrix says it should" and300383  * the scenario is free to describe a failure outcome without special-casing.301384  */302-async function runRound({ initiator, android = {}, browser = {}, timeoutMs = 180_000, label = "", expectFailure = [] }) {385+async 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+    : {};303394   const dir = mkdtempSync(resolve(tmpdir(), "peach-cross-sync-"));304395   const androidTranscript = resolve(dir, "android.json");305396   const browserTranscript = resolve(dir, "browser.json");@@ -308,7 +399,7 @@308399   const common = { PEACH_BROWSER_ROOT: browserRoot };309400   const launchBrowser = (mode, port, transcript, portFile) => spawnProcess("bun", ["--eval", browserEndpoint], {310401     ...common,311-    ...knobEnv(browser),402+    ...knobEnv({ ...manifestKnobs, ...browser }),312403     PEACH_SYNC_HARNESS_MODE: mode,313404     PEACH_SYNC_HARNESS_PORT: port ?? "",314405     PEACH_SYNC_HARNESS_PORT_FILE: portFile ?? "",@@ -319,7 +410,7 @@319410     ...androidTask,320411   ], {321412     ...common,322-    ...knobEnv(android),413+    ...knobEnv({ ...manifestKnobs, ...android }),323414     PEACH_PROCESS_ROOT: androidRoot,324415     PEACH_SYNC_HARNESS_MODE: mode,325416     PEACH_SYNC_HARNESS_PORT: port ?? "",@@ -572,6 +663,31 @@572663     },573664   },574665   {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+  {575691     id: 15,576692     name: "10,000-entry vault with padded frames",577693     initiator: "android",@@ -610,6 +726,14 @@610726       if (!predicate(round[side])) throw new Error(`${label}: unexpected transcript for ${side}: ${JSON.stringify(round[side].events)}`);611727     }612728   },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+  },613737   side(round, side, label, expected) {614738     const got = JSON.stringify(round[side].events);615739     const want = JSON.stringify(expected);@@ -617,6 +741,23 @@617741   },618742 };619743 744+/**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+ */750+const 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+];760+620761 // PEACH_SYNC_HARNESS_ONLY=13,15 runs a subset. Debugging the matrix one full621762 // pass at a time is otherwise a four-minute loop.622763 const only = (process.env.PEACH_SYNC_HARNESS_ONLY ?? "").split(",").filter(Boolean).map(Number);@@ -634,16 +775,45 @@634775   console.log(`  ✓ ${String(scenario.id).padStart(2)} ${scenario.name} (${(elapsed / 1000).toFixed(1)}s)`);635776 }636777 778+// 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.780+const manifestResults = [];781+if (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+}800+637801 console.log("");638-console.log(`Cross-client sync process conformance passed: ${results.length} of 15 §7.1 scenarios.`);802+const scope = only.length > 0 ? ` (filtered to ${only.join(",")})` : "";803+console.log(`Cross-client sync process conformance passed: ${results.length} of 15 §7.1 scenarios${scope}.`);639804 for (const result of results) {640805   const timing = result.id === 15 ? ` — 10,000-entry round ${(result.elapsed / 1000).toFixed(1)}s wall clock` : "";641806   console.log(`  #${result.id} ${result.name}${timing}`);642807 }643808 console.log("");809+for (const result of manifestResults) { /* listed above with their verdicts */ }810+console.log("");644811 console.log("Not yet covered by this gate (see the campaign §7.1 table):");645-console.log("  #7  android cold-locked — evidence class is unit + real-device, not a process transcript");646-console.log("  #11 revocation then re-vouch, #12 excessive vouch depth, #14 freshness window —");647-console.log("      these need a correctly signed adversarial manifest on the wire; the");648-console.log("      signed-manifest fixture is not built yet");649-console.log("  the adoption-v2 group, and the eight §16.3 manifest-v5 adversarial cases");812+console.log("  #7  android cold-locked — evidence class is unit + real-device, NOT a process");813+console.log("      transcript; the unit half is LockedSyncSpoolModelTest. Do not fake it here.");814+console.log("  #11 revocation then re-vouch — the rule is entry ADMISSION, not manifest");815+console.log("      validity: the Browser enforces it in sync-vault-adapter and Android in");816+console.log("      PairedDeviceStore, both of which need real storage this endpoint has no");817+console.log("      JVM-reachable path to. Its unit coverage exists on both clients; the");818+console.log("      process class needs an adapter-level harness.");819+console.log("  the adoption-v2 group — a separate transcript, not yet driven across the boundary.");

view file