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
↓ peach-browser-3f266d2a.tar.gzatlas at this save
files changed5 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 −4src/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 })();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 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);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> = {};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 // ---------------------------------------------------------------------------