eeb12f15f2c4

add protocolVersion to SYNC_HELLO, reject stale peers with update-required error

time
2026-09-08 22:08 UTC · 1 month ago
machine
koh2
agent
hf:zai-org/GLM-5.2
session
sync protocol version
parent
2c0d1e890d44
11 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-coordinator.test.ts+39 −10src/background/sync-coordinator.ts+11 −0src/background/sync-runner.test.ts+2 −0src/background/sync-trigger.test.ts+1 −0src/background/sync-trigger.ts+1 −0src/shared/sync-outcome.test.ts+1 −0src/shared/sync-outcome.ts+3 −0src/shared/sync-types.ts+9 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.2.21",4+  "version": "5.2.23",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -2,7 +2,7 @@22   "manifest_version": 3,33   "minimum_chrome_version": "116",44   "name": "Peach Password Manager",5-  "version": "5.2.21",5+  "version": "5.2.23",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "5.2.22",3+  "version": "5.2.23",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/sync-coordinator.test.ts+39 −10
@@ -149,6 +149,7 @@149149         deviceId: "p",150150         version: 0,151151         deviceTime: Date.now(),152+        protocolVersion: 2,152153       });153154       await peer.receive(); // our manifest154155       await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });@@ -188,6 +189,7 @@188189         deviceId: "p",189190         version: 0,190191         deviceTime: Date.now(),192+        protocolVersion: 2,191193       });192194       await peer.receive(); // our manifest193195       await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });@@ -219,6 +221,7 @@219221         deviceId: "p",220222         version: 0,221223         deviceTime: Date.now(),224+        protocolVersion: 2,222225       });223226       await peer.receive();224227       const entries = Object.fromEntries(@@ -241,6 +244,7 @@241244         deviceId: "p",242245         version: 0,243246         deviceTime: Date.now(),247+        protocolVersion: 2,244248       });245249       await tombstonePeer.receive();246250       const tombstones = Object.fromEntries(@@ -270,7 +274,7 @@270274       expect(sentHello.deviceTime).toBeGreaterThan(0);271275     }272276     // Send a mismatched vaultId to abort early so the test doesn't hang.273-    await peer.send({ type: "SYNC_HELLO", vaultId: "different-vault", deviceId: "p", version: 7, deviceTime: Date.now() });277+    await peer.send({ type: "SYNC_HELLO", vaultId: "different-vault", deviceId: "p", version: 7, deviceTime: Date.now(), protocolVersion: 2 });274278     await peer.receive(); // SYNC_ERROR275279     await initPromise;276280   });@@ -282,7 +286,7 @@282286     const initPromise = runSyncInitiator(session, vault);283287 284288     await peer.receive(); // SYNC_HELLO285-    await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 10, deviceTime: Date.now() });289+    await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 10, deviceTime: Date.now(), protocolVersion: 2 });286290 287291     // Coordinator should send SYNC_DONE without any manifest exchange.288292     const done = await peer.receive();@@ -357,8 +361,9 @@357361       vaultId: "vault-abc",358362       deviceId: "p",359363       version: 3, // different version so exchange proceeds360-      deviceTime: Date.now() - 200_000,361-    });364+        deviceTime: Date.now() - 200_000,365+        protocolVersion: 2,366+      });362367     // Yield so the coordinator's async continuation can run the clock-skew check.363368     await new Promise((r) => setTimeout(r, 0));364369     expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("clock skew"));@@ -440,7 +445,7 @@440445     const peerProtocol = (async () => {441446       const hello = await peer.receive();442447       expect(hello.type).toBe("SYNC_HELLO");443-      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "device-peer", version: 3, deviceTime: Date.now() });448+      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "device-peer", version: 3, deviceTime: Date.now(), protocolVersion: 2 });444449 445450       const ourManifest = await peer.receive();446451       expect(ourManifest.type).toBe("SYNC_MANIFEST");@@ -499,7 +504,7 @@499504     const peerProtocol = (async () => {500505       const hello = await peer.receive();501506       expect(hello.type).toBe("SYNC_HELLO");502-      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-B", deviceId: "evil", version: 3, deviceTime: Date.now() });507+      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-B", deviceId: "evil", version: 3, deviceTime: Date.now(), protocolVersion: 2 });503508       // Peer should receive a SYNC_ERROR and we should stop.504509       const err = await peer.receive();505510       expect(err.type).toBe("SYNC_ERROR");@@ -510,6 +515,30 @@510515     expect(outcome.status).toBe("vault_mismatch");511516   });512517 518+  it("rejects a stale peer with PEER_PROTOCOL_OUTDATED", async () => {519+    const [session, peer] = inMemoryPair();520+    const vault = makeVaultAdapter({});521+522+    const initPromise = runSyncInitiator(session, vault);523+    const peerProtocol = (async () => {524+      await peer.receive(); // hello525+      await peer.send({526+        type: "SYNC_HELLO",527+        vaultId: "vault-abc",528+        deviceId: "p",529+        version: 3,530+        deviceTime: Date.now(),531+      });532+      const err = await peer.receive();533+      expect(err.type).toBe("SYNC_ERROR");534+    })();535+536+    const outcome = await initPromise;537+    await peerProtocol;538+    expect(outcome.status).toBe("errored");539+    expect(outcome.error).toBe("PEER_PROTOCOL_OUTDATED");540+  });541+513542   it("exchanges device manifests when both adapters opt in", async () => {514543     const [session, peer] = inMemoryPair();515544     const ourKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x11), "us");@@ -532,7 +561,7 @@532561     const initPromise = runSyncInitiator(session, vault);533562     const peerProtocol = (async () => {534563       await peer.receive(); // hello535-      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now() });564+      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now(), protocolVersion: 2 });536565       await peer.receive(); // manifest537566       await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });538567       await peer.receive(); // request@@ -569,7 +598,7 @@569598     const initPromise = runSyncInitiator(session, vault);570599     const peerProtocol = (async () => {571600       await peer.receive();572-      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now() });601+      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now(), protocolVersion: 2 });573602       await peer.receive();574603       await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });575604       await peer.receive();@@ -597,7 +626,7 @@597626     const initPromise = runSyncInitiator(session, vault);598627     void (async () => {599628       await peer.receive(); // hello600-      await peer.send({ type: "SYNC_HELLO", vaultId: "v", deviceId: "p", version: 3, deviceTime: Date.now() });629+      await peer.send({ type: "SYNC_HELLO", vaultId: "v", deviceId: "p", version: 3, deviceTime: Date.now(), protocolVersion: 2 });601630       await peer.receive(); // our manifest602631       await peer.send({ type: "SYNC_DONE" }); // wrong frame603632     })();@@ -620,7 +649,7 @@620649     const initPromise = runSyncInitiator(session, vault);621650     void (async () => {622651       await peer.receive(); // hello623-      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 5, deviceTime: Date.now() });652+      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 5, deviceTime: Date.now(), protocolVersion: 2 });624653       await peer.receive(); // our done625654       // No SYNC_DONE echo — Android just hangs up.626655       await peer.closeInitiatorReceive();

view file

src/background/sync-coordinator.ts+11 −0
@@ -21,6 +21,7 @@2121 2222 import {2323   DEVICE_MANIFEST_VERSION,24+  SYNC_PROTOCOL_VERSION,2425   type SyncMessage,2526   type SignedDeviceManifest,2627 } from "../shared/sync-types";@@ -135,6 +136,7 @@135136       deviceId: ourDeviceId,136137       version: localVersion,137138       deviceTime: localTime,139+      protocolVersion: SYNC_PROTOCOL_VERSION,138140       deviceManifestVersion: supportsDeviceManifest139141         ? DEVICE_MANIFEST_VERSION140142         : undefined,@@ -144,6 +146,10 @@144146     if (helloIn.type !== "SYNC_HELLO") {145147       return errorOutcome(result, `expected SYNC_HELLO, got ${helloIn.type}`);146148     }149+    if ((helloIn.protocolVersion ?? 1) < SYNC_PROTOCOL_VERSION) {150+      await sendMessage(session, { type: "SYNC_ERROR", error: "PEER_PROTOCOL_OUTDATED" });151+      return errorOutcome(result, "PEER_PROTOCOL_OUTDATED");152+    }147153     if (helloIn.vaultId !== ourVaultId) {148154       await sendMessage(session, { type: "SYNC_ERROR", error: "vault_id_mismatch" });149155       result.status = "vault_mismatch";@@ -333,12 +339,17 @@333339     if (helloIn.type !== "SYNC_HELLO") {334340       return errorOutcome(result, `expected SYNC_HELLO, got ${helloIn.type}`);335341     }342+    if ((helloIn.protocolVersion ?? 1) < SYNC_PROTOCOL_VERSION) {343+      await sendMessage(session, { type: "SYNC_ERROR", error: "PEER_PROTOCOL_OUTDATED" });344+      return errorOutcome(result, "PEER_PROTOCOL_OUTDATED");345+    }336346     await sendMessage(session, {337347       type: "SYNC_HELLO",338348       vaultId: ourVaultId,339349       deviceId: ourDeviceId,340350       version: localVersion,341351       deviceTime: localTime,352+      protocolVersion: SYNC_PROTOCOL_VERSION,342353       deviceManifestVersion: supportsDeviceManifest343354         ? DEVICE_MANIFEST_VERSION344355         : undefined,

view file

src/background/sync-runner.test.ts+2 −0
@@ -121,6 +121,7 @@121121     deviceId: "peer-device",122122     version: 0,123123     deviceTime: Date.now(),124+    protocolVersion: 2,124125   });125126   const manifest = await peer.receive();126127   if (manifest.type !== "SYNC_MANIFEST") throw new Error("expected MANIFEST");@@ -354,6 +355,7 @@354355           deviceId: "peer-device",355356           version: 0,356357           deviceTime: Date.now(),358+          protocolVersion: 2,357359         });358360       }359361     })().catch(() => undefined);

view file

src/background/sync-trigger.test.ts+1 −0
@@ -315,6 +315,7 @@315315       ["sync_disabled", "sync_disabled"],316316       ["private_relay_unset", "private_relay_unset"],317317       ["vault_id_mismatch", "vault_id_mismatch"],318+      ["PEER_PROTOCOL_OUTDATED", "peer_protocol_outdated"],318319       ["sync_timeout", "sync_timeout"],319320       ["VAULT_LOCKED", "vault_locked"],320321       ["relay_sync_failed:sync_handshake_timeout", "handshake_failed"],

view file

src/background/sync-trigger.ts+1 −0
@@ -272,6 +272,7 @@272272   if (error.includes("sync_disabled")) return "sync_disabled";273273   if (error.includes("private_relay_unset")) return "private_relay_unset";274274   if (error.includes("vault_id_mismatch")) return "vault_id_mismatch";275+  if (error.includes("PEER_PROTOCOL_OUTDATED")) return "peer_protocol_outdated";275276   if (error.includes("sync_timeout")) return "sync_timeout";276277   // A transport that hung up mid-protocol. The alarm retries, and the peer may277278   // well have applied what we already sent, so this is network-class (yellow)

view file

src/shared/sync-outcome.test.ts+1 −0
@@ -93,6 +93,7 @@9393       relay_unreachable: "warn",9494       handshake_failed: "warn",9595       vault_id_mismatch: "error",96+      peer_protocol_outdated: "error",9697       sync_timeout: "warn",9798       connection_lost: "warn",9899       run_failed: "error",

view file

src/shared/sync-outcome.ts+3 −0
@@ -17,6 +17,7 @@1717   | "relay_unreachable"1818   | "handshake_failed"1919   | "vault_id_mismatch"20+  | "peer_protocol_outdated"2021   | "sync_timeout"2122   | "connection_lost"2223   | "run_failed";@@ -35,6 +36,7 @@3536   "runner_unavailable",3637   "private_relay_unset",3738   "vault_id_mismatch",39+  "peer_protocol_outdated",3840   "run_failed",3941 ]);4042 @@ -141,6 +143,7 @@141143   relay_unreachable: "Couldn't reach the relay",142144   handshake_failed: "Reached the relay, but the device didn't answer",143145   vault_id_mismatch: "That device holds a different vault",146+  peer_protocol_outdated: "Your other device needs to update before it can sync",144147   sync_timeout: "The device stopped responding mid-sync",145148   connection_lost: "The connection dropped mid-sync — will retry",146149   run_failed: "Sync failed",

view file

src/shared/sync-types.ts+9 −0
@@ -89,6 +89,13 @@8989       version: number;9090       deviceTime: number;9191       /**92+       * Wire protocol version, incremented when the sync sequence itself93+       * changes. Distinct from `version` (vault data version) and94+       * `deviceManifestVersion`. Old clients omit this field; treat missing95+       * as 1.96+       */97+      protocolVersion?: number;98+      /**9299        * Advertises equal-version device-graph exchange support. Older peers93100        * omit this field and retain the legacy SYNC_DONE fast path.94101        */@@ -109,6 +116,8 @@109116 /** 0x03 adds each peer edge's relay URL so a newly introduced pair can agree110117  *  on a route without depending on the introducing device. Coordinated wire111118  *  change with peach-android. */119+export const SYNC_PROTOCOL_VERSION = 2 as const;120+112121 export const DEVICE_MANIFEST_VERSION = 0x04 as const;113122 export const MAX_VOUCH_DEPTH = 3;114123 /** Manifests older than this are silently rejected (v2 brief §4.3). */

view file