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
↓ peach-browser-1d225ca1.tar.gzatlas at this save
files changed2 files
src/background/chirp-sync-coordinator.test.ts+47 −1src/background/chirp-sync-coordinator.ts+54 −1src/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 () => {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);