1d225ca17f0f

wire SYNC_DEVICE_MANIFEST into ChirpSyncInitiator — optional adapter methods, exchanged between SYNC_TOMBSTONES and SYNC_DONE; placeholder for no-manifest case; coordinator test covers the opt-in path (13 tests)

time
2026-05-18 02:47 UTC · 5 months ago
machine
koh2
parent
458f39ca4f5a
2 files src/background/chirp-sync-coordinator.test.ts+47 −1src/background/chirp-sync-coordinator.ts+54 −1
src/background/chirp-sync-coordinator.test.ts+47 −1
@@ -1,7 +1,9 @@11 import { describe, expect, it } from "vitest";22 import { runChirpSyncInitiator, type ChirpSyncVaultAdapter } from "./chirp-sync-coordinator";3-import type { ChirpSyncMessage } from "../shared/sync-types";3+import type { ChirpSyncMessage, SignedDeviceManifest } from "../shared/sync-types";44 import type { ChirpSyncSession } from "../shared/chirp-sync-transport";5+import { buildSignedDeviceManifest } from "../shared/device-manifest";6+import { deriveSyncSigningKeypair } from "../shared/sync-identity";57 68 function inMemoryPair(): [ChirpSyncSession, FakePeer] {79   const a: Uint8Array[] = [];@@ -155,6 +157,50 @@155157     const outcome = await initPromise;156158     await peerProtocol;157159     expect(outcome.status).toBe("vault_mismatch");160+  });161+162+  it("exchanges device manifests when both adapters opt in", async () => {163+    const [session, peer] = inMemoryPair();164+    const ourKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x11), "us");165+    const peerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x22), "them");166+167+    const outgoing = await buildSignedDeviceManifest("us", [], ourKp.privateKey);168+    const incomingFromPeer = await buildSignedDeviceManifest("them", [], peerKp.privateKey);169+    const applied: SignedDeviceManifest[] = [];170+171+    const base = makeVaultAdapter({});172+    const vault: ChirpSyncVaultAdapter = {173+      ...base,174+      async buildOutgoingDeviceManifest() { return outgoing; },175+      async applyIncomingDeviceManifest(m) { applied.push(m); },176+    };177+178+    const initPromise = runChirpSyncInitiator(session, vault);179+    const peerProtocol = (async () => {180+      await peer.receive(); // hello181+      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p" });182+      await peer.receive(); // manifest183+      await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });184+      await peer.receive(); // request185+      await peer.send({ type: "SYNC_REQUEST", entryIds: [] });186+      await peer.receive(); // entries187+      await peer.receive(); // tombstones188+      await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });189+      const ourManifest = await peer.receive();190+      expect(ourManifest.type).toBe("SYNC_DEVICE_MANIFEST");191+      if (ourManifest.type === "SYNC_DEVICE_MANIFEST") {192+        expect(ourManifest.manifest.body.issuerDeviceId).toBe("us");193+      }194+      await peer.send({ type: "SYNC_DEVICE_MANIFEST", manifest: incomingFromPeer });195+      await peer.receive(); // done196+      await peer.send({ type: "SYNC_DONE" });197+    })();198+199+    const outcome = await initPromise;200+    await peerProtocol;201+    expect(outcome.status).toBe("completed");202+    expect(applied).toHaveLength(1);203+    expect(applied[0].body.issuerDeviceId).toBe("them");158204   });159205 160206   it("returns errored when peer sends a wrong frame", async () => {

view file

src/background/chirp-sync-coordinator.ts+54 −1
@@ -18,11 +18,21 @@1818 // Last-write-wins at the entry level by mtime; tombstones override updates1919 // with a later deletion timestamp.2020 21-import type { ChirpSyncMessage } from "../shared/sync-types";21+import type { ChirpSyncMessage, SignedDeviceManifest } from "../shared/sync-types";2222 import type { ChirpSyncSession } from "../shared/chirp-sync-transport";2323 2424 const SESSION_TIMEOUT_MS = 30_000;2525 26+// Sentinel signature used when an adapter can't build a real manifest (e.g.27+// the master key isn't accessible from the coordinator context). The peer28+// recognises this exact value and skips signature verification. 64 hex29+// chars of zero is impossible to produce as a real Ed25519 signature for a30+// fresh body, so collisions are negligible.31+const EMPTY_MANIFEST_PLACEHOLDER: SignedDeviceManifest = {32+  body: { manifestVersion: 1, issuerDeviceId: "", issuedAt: 0, devices: [] },33+  signatureHex: "0".repeat(128),34+};35+2636 /**2737  * Vault-side adapter — the coordinator's dependency on the vault layer.2838  * The vault holds the canonical entry/tombstone tables; the coordinator@@ -42,6 +52,19 @@4252   applyEntries(entries: Record<string, string>): Promise<void>;4353   /** Apply incoming tombstones; vault retains the latest deletion ts. */4454   applyTombstones(tombstones: Record<string, number>): Promise<void>;55+  /**56+   * Optional — v2 brief §4.3 device manifest exchange. The extension (as57+   * the trust anchor for the device graph) returns a signed manifest58+   * enumerating every paired device it knows about; the peer verifies it59+   * against the extension's known Ed25519 signing pubkey and upserts any60+   * newly-vouched devices into its own registry.61+   *62+   * When this method is undefined, the coordinator skips the manifest63+   * step entirely. Both ends must opt in for the exchange to occur.64+   */65+  buildOutgoingDeviceManifest?(): Promise<SignedDeviceManifest | null>;66+  /** v2 brief §4.3 — handle a manifest received from the peer. */67+  applyIncomingDeviceManifest?(manifest: SignedDeviceManifest): Promise<void>;4568 }4669 4770 export interface SyncOutcome {@@ -139,6 +162,36 @@139162     }140163     await vault.applyTombstones(tombstonesIn.tombstones);141164     result.tombstonesReceived = Object.keys(tombstonesIn.tombstones).length;165+166+    // v2 brief §5.6 / §4.3 — device manifest exchange. Optional: both ends167+    // must expose the adapter methods. Disabling on either side cleanly168+    // bypasses the step. The exchange is symmetric (each side sends one169+    // and applies the other's) even though in the current device graph170+    // only the extension actually has anything to vouch for; mobiles still171+    // produce a signed manifest, just with an empty `devices[]`, so the172+    // protocol stays uniform.173+    if (vault.buildOutgoingDeviceManifest && vault.applyIncomingDeviceManifest) {174+      const ours = await vault.buildOutgoingDeviceManifest();175+      if (ours) {176+        await sendMessage(session, { type: "SYNC_DEVICE_MANIFEST", manifest: ours });177+      } else {178+        // No manifest to send (e.g. master key unavailable) — still emit a179+        // protocol frame so the peer's recv doesn't hang. Empty body is180+        // serialised by the adapter when present; if it returned null we181+        // synthesise a no-op shape the receiver can ignore.182+        await sendMessage(session, {183+          type: "SYNC_DEVICE_MANIFEST",184+          manifest: EMPTY_MANIFEST_PLACEHOLDER,185+        });186+      }187+      const manifestIn = await receiveMessage(session, timer);188+      if (manifestIn.type !== "SYNC_DEVICE_MANIFEST") {189+        return errorOutcome(result, `expected SYNC_DEVICE_MANIFEST, got ${manifestIn.type}`);190+      }191+      if (manifestIn.manifest && manifestIn.manifest.signatureHex !== EMPTY_MANIFEST_PLACEHOLDER.signatureHex) {192+        await vault.applyIncomingDeviceManifest(manifestIn.manifest);193+      }194+    }142195 143196     await sendMessage(session, { type: "SYNC_DONE" });144197     const done = await receiveMessage(session, timer);

view file