3f266d2afda8

fix: SYNC_HELLO adds version + deviceTime; clock skew warning; equal-version no-op; SYNC_DONE carries counts; coordinator no longer owns session close (v3.6.96)

time
2026-05-22 18:29 UTC · 5 months ago
machine
koh2
parent
557ffe75270b
5 files src/background/chirp-sync-coordinator.test.ts+8 −4src/background/chirp-sync-coordinator.ts+34 −3src/background/chirp-sync-runner.test.ts+7 −0src/background/chirp-sync-vault-adapter.ts+15 −0src/shared/sync-types.ts+4 −4
src/background/chirp-sync-coordinator.test.ts+8 −4
@@ -48,6 +48,7 @@4848 function makeVaultAdapter(opts: {4949   vaultId?: string;5050   deviceId?: string;51+  localVersion?: number;5152   entries?: Record<string, number>;5253   tombstones?: Record<string, number>;5354   ciphertext?: Record<string, string>;@@ -60,6 +61,9 @@6061   return {6162     async computeVaultId() { return opts.vaultId ?? "vault-abc"; },6263     async getOurDeviceId() { return opts.deviceId ?? "device-init"; },64+    // Use a non-zero default so the equal-version short circuit doesn't fire in65+    // tests that aren't explicitly testing that path.66+    async getLocalVersion() { return opts.localVersion ?? 5; },6367     async buildEntryManifest() { return { ...(opts.entries ?? {}) }; },6468     async buildTombstoneManifest() { return { ...(opts.tombstones ?? {}) }; },6569     async getEncryptedEntries(ids) {@@ -89,7 +93,7 @@8993     const peerProtocol = (async () => {9094       const hello = await peer.receive();9195       expect(hello.type).toBe("SYNC_HELLO");92-      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "device-peer" });96+      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "device-peer", version: 3, deviceTime: Date.now() });9397 9498       const ourManifest = await peer.receive();9599       expect(ourManifest.type).toBe("SYNC_MANIFEST");@@ -148,7 +152,7 @@148152     const peerProtocol = (async () => {149153       const hello = await peer.receive();150154       expect(hello.type).toBe("SYNC_HELLO");151-      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-B", deviceId: "evil" });155+      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-B", deviceId: "evil", version: 3, deviceTime: Date.now() });152156       // Peer should receive a SYNC_ERROR and we should stop.153157       const err = await peer.receive();154158       expect(err.type).toBe("SYNC_ERROR");@@ -178,7 +182,7 @@178182     const initPromise = runChirpSyncInitiator(session, vault);179183     const peerProtocol = (async () => {180184       await peer.receive(); // hello181-      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p" });185+      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now() });182186       await peer.receive(); // manifest183187       await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });184188       await peer.receive(); // request@@ -210,7 +214,7 @@210214     const initPromise = runChirpSyncInitiator(session, vault);211215     void (async () => {212216       await peer.receive(); // hello213-      await peer.send({ type: "SYNC_HELLO", vaultId: "v", deviceId: "p" });217+      await peer.send({ type: "SYNC_HELLO", vaultId: "v", deviceId: "p", version: 3, deviceTime: Date.now() });214218       await peer.receive(); // our manifest215219       await peer.send({ type: "SYNC_DONE" }); // wrong frame216220     })();

view file

src/background/chirp-sync-coordinator.ts+34 −3
@@ -42,6 +42,8 @@4242 export interface ChirpSyncVaultAdapter {4343   computeVaultId(): Promise<string>;4444   getOurDeviceId(): Promise<string>;45+  /** Returns the vault's monotonic version (`peach_last_modified_at`). */46+  getLocalVersion(): Promise<number>;4547   /** Returns the per-entry mtimes for our active entries. */4648   buildEntryManifest(): Promise<Record<string, number>>;4749   /** Returns the per-entry deletion timestamps for our tombstones. */@@ -98,8 +100,16 @@98100   try {99101     const ourVaultId = await vault.computeVaultId();100102     const ourDeviceId = await vault.getOurDeviceId();103+    const localVersion = await vault.getLocalVersion();104+    const localTime = Date.now();101105 102-    await sendMessage(session, { type: "SYNC_HELLO", vaultId: ourVaultId, deviceId: ourDeviceId });106+    await sendMessage(session, {107+      type: "SYNC_HELLO",108+      vaultId: ourVaultId,109+      deviceId: ourDeviceId,110+      version: localVersion,111+      deviceTime: localTime,112+    });103113     const helloIn = await receiveMessage(session, timer);104114     if (helloIn.type !== "SYNC_HELLO") {105115       return errorOutcome(result, `expected SYNC_HELLO, got ${helloIn.type}`);@@ -110,6 +120,21 @@110120       return result;111121     }112122 123+    if (Math.abs(localTime - (helloIn.deviceTime ?? 0)) > 120_000) {124+      console.warn(`[chirp-sync] clock skew detected: local=${localTime} remote=${helloIn.deviceTime ?? "unknown"}`);125+    }126+127+    // Equal versions — nothing to exchange.128+    if (typeof helloIn.version === "number" && helloIn.version === localVersion) {129+      await sendMessage(session, { type: "SYNC_DONE", entriesReceived: 0, entriesSent: 0 });130+      const done = await receiveMessage(session, timer);131+      if (done.type !== "SYNC_DONE") {132+        return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);133+      }134+      result.status = "completed";135+      return result;136+    }137+113138     const ourEntries = await vault.buildEntryManifest();114139     const ourTombstones = await vault.buildTombstoneManifest();115140     await sendMessage(session, {@@ -191,7 +216,11 @@191216       }192217     }193218 194-    await sendMessage(session, { type: "SYNC_DONE" });219+    await sendMessage(session, {220+      type: "SYNC_DONE",221+      entriesReceived: result.entriesReceived,222+      entriesSent: result.entriesSent,223+    });195224     const done = await receiveMessage(session, timer);196225     if (done.type !== "SYNC_DONE") {197226       return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);@@ -206,8 +235,10 @@206235     }207236     return errorOutcome(result, (err as Error).message);208237   } finally {238+    // Session lifetime is owned by the runner (chirp-sync-runner.ts), which239+    // closes it in its own finally block. The coordinator must not close it240+    // here — the runner still needs to record the outcome after we return.209241     timer.cancel();210-    await session.close().catch(() => undefined);211242   }212243 }213244 

view file

src/background/chirp-sync-runner.test.ts+7 −0
@@ -115,6 +115,8 @@115115     type: "SYNC_HELLO",116116     vaultId: opts.vaultId ?? hello.vaultId,117117     deviceId: "peer-device",118+    version: 0,119+    deviceTime: Date.now(),118120   });119121   const manifest = await peer.receive();120122   if (manifest.type !== "SYNC_MANIFEST") throw new Error("expected MANIFEST");@@ -158,6 +160,9 @@158160   return {159161     async computeVaultId() { return "vault-abc"; },160162     async getOurDeviceId() { return "device-init"; },163+    // Non-zero so the equal-version short circuit doesn't fire in runner tests164+    // that exercise the full manifest-diff flow.165+    async getLocalVersion() { return 5; },161166     async buildEntryManifest() { return {}; },162167     async buildTombstoneManifest() { return {}; },163168     async getEncryptedEntries() { return {}; },@@ -357,6 +362,8 @@357362           type: "SYNC_HELLO",358363           vaultId: "wrong-vault-id",359364           deviceId: "peer-device",365+          version: 0,366+          deviceTime: Date.now(),360367         });361368       }362369     })().catch(() => undefined);

view file

src/background/chirp-sync-vault-adapter.ts+15 −0
@@ -71,6 +71,21 @@7171       return getOrCreateDeviceId();7272     },7373 74+    async getLocalVersion() {75+      try {76+        const raw = await chrome.storage.local.get("peach_last_modified_at");77+        const v = (raw as Record<string, unknown>).peach_last_modified_at;78+        if (typeof v === "number") return v;79+        if (typeof v === "string" && v !== "") {80+          const n = Number.parseInt(v, 10);81+          return Number.isFinite(n) ? n : 0;82+        }83+        return 0;84+      } catch {85+        return 0;86+      }87+    },88+7489     async buildEntryManifest() {7590       requireUnlocked();7691       const manifest: Record<string, number> = {};

view file

src/shared/sync-types.ts+4 −4
@@ -71,8 +71,8 @@7171 export type TrustSource = "direct" | "vouched";7272 7373 /**74- * v3 paired-device record. Stored in `chrome.storage.sync` so the registry75- * roams across browser profiles signed into Chrome/Firefox sync.74+ * v3 paired-device record. Stored in `chrome.storage.local` — cryptographic75+ * keys must not leave the device via a third-party cloud service.7676  *7777  * `longTermPublicKeyHex` is the counterpart's vault-derived X25519 sync7878  * identity static key — see `deriveSyncIdentityKeypair`. Mutual Noise XX@@ -147,13 +147,13 @@147147  * encrypted blobs — the sync layer never decrypts vault data.148148  */149149 export type ChirpSyncMessage =150-  | { type: "SYNC_HELLO"; vaultId: string; deviceId: string }150+  | { type: "SYNC_HELLO"; vaultId: string; deviceId: string; version: number; deviceTime: number }151151   | { type: "SYNC_MANIFEST"; entries: Record<string, number>; tombstones: Record<string, number> }152152   | { type: "SYNC_REQUEST"; entryIds: string[] }153153   | { type: "SYNC_ENTRIES"; entries: Record<string, string> }154154   | { type: "SYNC_TOMBSTONES"; tombstones: Record<string, number> }155155   | { type: "SYNC_DEVICE_MANIFEST"; manifest: SignedDeviceManifest }156-  | { type: "SYNC_DONE" }156+  | { type: "SYNC_DONE"; entriesReceived?: number; entriesSent?: number }157157   | { type: "SYNC_ERROR"; error: string };158158 159159 // ---------------------------------------------------------------------------

view file