157f3326d8f1

fix: bind sync manifests to authenticated peer identity (v4.0.38)

time
2026-07-26 02:13 UTC · 3 months ago
machine
koh2
agent
gpt-5
session
peach relay sync implementation
parent
f92006bbfad8
10 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-coordinator.test.ts+38 −2src/background/sync-coordinator.ts+17 −28src/background/sync-vault-adapter.test.ts+26 −5src/background/sync-vault-adapter.ts+12 −3src/shared/sync-identity.test.ts+5 −4src/shared/sync-identity.ts+2 −2src/shared/sync-types.ts+1 −1
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.0.37",4+  "version": "4.0.38",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.0.37",4+  "version": "4.0.38",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

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

view file

src/background/sync-coordinator.test.ts+38 −2
@@ -255,7 +255,10 @@255255     const vault: SyncVaultAdapter = {256256       ...base,257257       async buildOutgoingDeviceManifest() { return outgoing; },258-      async applyIncomingDeviceManifest(m) { applied.push(m); },258+      async applyIncomingDeviceManifest(m, authenticatedKey) {259+        expect(authenticatedKey).toEqual(session.remoteStaticPublicKey);260+        applied.push(m);261+      },259262     };260263 261264     const initPromise = runSyncInitiator(session, vault);@@ -272,7 +275,7 @@272275       const ourManifest = await peer.receive();273276       expect(ourManifest.type).toBe("SYNC_DEVICE_MANIFEST");274277       if (ourManifest.type === "SYNC_DEVICE_MANIFEST") {275-        expect(ourManifest.manifest.body.issuerDeviceId).toBe("us");278+        expect(ourManifest.manifest?.body.issuerDeviceId).toBe("us");276279       }277280       await peer.send({ type: "SYNC_DEVICE_MANIFEST", manifest: incomingFromPeer });278281       await peer.receive(); // done@@ -284,6 +287,39 @@284287     expect(outcome.status).toBe("completed");285288     expect(applied).toHaveLength(1);286289     expect(applied[0].body.issuerDeviceId).toBe("them");290+  });291+292+  it("uses null to represent an absent device manifest", async () => {293+    const [session, peer] = inMemoryPair();294+    const applyIncoming = vi.fn();295+    const vault: SyncVaultAdapter = {296+      ...makeVaultAdapter({}),297+      async buildOutgoingDeviceManifest() { return null; },298+      applyIncomingDeviceManifest: applyIncoming,299+    };300+301+    const initPromise = runSyncInitiator(session, vault);302+    const peerProtocol = (async () => {303+      await peer.receive();304+      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now() });305+      await peer.receive();306+      await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });307+      await peer.receive();308+      await peer.send({ type: "SYNC_REQUEST", entryIds: [] });309+      await peer.receive();310+      await peer.receive();311+      await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });312+      const manifest = await peer.receive();313+      expect(manifest).toEqual({ type: "SYNC_DEVICE_MANIFEST", manifest: null });314+      await peer.send({ type: "SYNC_DEVICE_MANIFEST", manifest: null });315+      await peer.receive();316+      await peer.send({ type: "SYNC_DONE" });317+    })();318+319+    const outcome = await initPromise;320+    await peerProtocol;321+    expect(outcome.status).toBe("completed");322+    expect(applyIncoming).not.toHaveBeenCalled();287323   });288324 289325   it("returns errored when peer sends a wrong frame", async () => {

view file

src/background/sync-coordinator.ts+17 −28
@@ -24,16 +24,6 @@2424 2525 const SESSION_TIMEOUT_MS = 30_000;2626 27-// Sentinel signature used when an adapter can't build a real manifest (e.g.28-// the master key isn't accessible from the coordinator context). The peer29-// recognises this exact value and skips signature verification. 64 hex30-// chars of zero is impossible to produce as a real Ed25519 signature for a31-// fresh body, so collisions are negligible.32-const EMPTY_MANIFEST_PLACEHOLDER: SignedDeviceManifest = {33-  body: { manifestVersion: 1, issuerDeviceId: "", issuedAt: 0, devices: [] },34-  signatureHex: "0".repeat(128),35-};36-3727 /**3828  * Vault-side adapter — the coordinator's dependency on the vault layer.3929  * The vault holds the canonical entry/tombstone tables; the coordinator@@ -66,8 +56,15 @@6656    * step entirely. Both ends must opt in for the exchange to occur.6757    */6858   buildOutgoingDeviceManifest?(): Promise<SignedDeviceManifest | null>;69-  /** v2 brief §4.3 — handle a manifest received from the peer. */70-  applyIncomingDeviceManifest?(manifest: SignedDeviceManifest): Promise<void>;59+  /**60+   * Handle a manifest received from the peer. The transport-authenticated61+   * Noise static key is supplied separately so a self-asserted issuer id62+   * cannot select a different paired-device verification key.63+   */64+  applyIncomingDeviceManifest?(65+    manifest: SignedDeviceManifest,66+    authenticatedRemoteStaticPublicKey: Uint8Array,67+  ): Promise<void>;7168 }7269 7370 export interface SyncOutcome {@@ -192,28 +189,20 @@192189     // v2 brief §5.6 / §4.3 — device manifest exchange. Optional: both ends193190     // must expose the adapter methods. Disabling on either side cleanly194191     // bypasses the step. The exchange is symmetric on the wire; the195-    // production adapter still treats the extension as the trust anchor and196-    // only applies extension-issued manifests.192+    // production adapter accepts only a directly paired issuer whose stored193+    // long-term key matches the Noise-authenticated remote static key.197194     if (vault.buildOutgoingDeviceManifest && vault.applyIncomingDeviceManifest) {198195       const ours = await vault.buildOutgoingDeviceManifest();199-      if (ours) {200-        await sendMessage(session, { type: "SYNC_DEVICE_MANIFEST", manifest: ours });201-      } else {202-        // No manifest to send (e.g. master key unavailable) — still emit a203-        // protocol frame so the peer's recv doesn't hang. Empty body is204-        // serialised by the adapter when present; if it returned null we205-        // synthesise a no-op shape the receiver can ignore.206-        await sendMessage(session, {207-          type: "SYNC_DEVICE_MANIFEST",208-          manifest: EMPTY_MANIFEST_PLACEHOLDER,209-        });210-      }196+      await sendMessage(session, { type: "SYNC_DEVICE_MANIFEST", manifest: ours });211197       const manifestIn = await receiveMessage(session, timer);212198       if (manifestIn.type !== "SYNC_DEVICE_MANIFEST") {213199         return errorOutcome(result, `expected SYNC_DEVICE_MANIFEST, got ${manifestIn.type}`);214200       }215-      if (manifestIn.manifest && manifestIn.manifest.signatureHex !== EMPTY_MANIFEST_PLACEHOLDER.signatureHex) {216-        await vault.applyIncomingDeviceManifest(manifestIn.manifest);201+      if (manifestIn.manifest) {202+        await vault.applyIncomingDeviceManifest(203+          manifestIn.manifest,204+          session.remoteStaticPublicKey,205+        );217206       }218207     }219208 

view file

src/background/sync-vault-adapter.test.ts+26 −5
@@ -389,7 +389,10 @@389389       issuerKp.privateKey,390390     );391391 392-    await adapter.applyIncomingDeviceManifest?.(manifest);392+    await adapter.applyIncomingDeviceManifest?.(393+      manifest,394+      new Uint8Array(32).fill(0xcc),395+    );393396 394397     // MV-7: the never-seen device is NOT auto-paired; it waits in the pending395398     // queue for explicit user confirmation.@@ -460,7 +463,10 @@460463       issuerKp.privateKey,461464     );462465 463-    await adapter.applyIncomingDeviceManifest?.(manifest);466+    await adapter.applyIncomingDeviceManifest?.(467+      manifest,468+      new Uint8Array(32).fill(0xcc),469+    );464470     // recordSyncAlerts is fire-and-forget — flush microtasks.465471     for (let i = 0; i < 25; i++) await Promise.resolve();466472 @@ -506,7 +512,10 @@506512       }],507513       mobileIssuerKp.privateKey,508514     );509-    await adapter.applyIncomingDeviceManifest?.(mobileManifest);515+    await adapter.applyIncomingDeviceManifest?.(516+      mobileManifest,517+      new Uint8Array(32).fill(0x11),518+    );510519 511520     expect(await findPairedDeviceById("mesh-peer")).toBeNull();512521     expect((await listPendingVouchedDevices()).map((d) => d.deviceId)).toEqual(["mesh-peer"]);@@ -544,7 +553,10 @@544553       }],545554       vouchedKp.privateKey,546555     );547-    await adapter.applyIncomingDeviceManifest?.(onward);556+    await adapter.applyIncomingDeviceManifest?.(557+      onward,558+      new Uint8Array(32).fill(0x11),559+    );548560     expect(await findPairedDeviceById("should-not-add")).toBeNull();549561     expect(await listPendingVouchedDevices()).toEqual([]);550562 @@ -571,6 +583,12 @@571583       }],572584       extensionKp.privateKey,573585     );586+    await adapter.applyIncomingDeviceManifest?.(587+      good,588+      new Uint8Array(32).fill(0x44),589+    );590+    expect(await findPairedDeviceById("forged-add")).toBeNull();591+574592     const forged = {575593       ...good,576594       signatureHex:@@ -578,7 +596,10 @@578596         ((parseInt(good.signatureHex.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) +579597         good.signatureHex.slice(4),580598     };581-    await adapter.applyIncomingDeviceManifest?.(forged);599+    await adapter.applyIncomingDeviceManifest?.(600+      forged,601+      new Uint8Array(32).fill(0x33),602+    );582603     expect(await findPairedDeviceById("forged-add")).toBeNull();583604   });584605 

view file

src/background/sync-vault-adapter.ts+12 −3
@@ -28,6 +28,7 @@2828   computeVaultId,2929   deriveSyncSigningKeypair,3030   hexToPubkey,31+  pubkeyToHex,3132 } from "../shared/sync-identity";3233 import {3334   buildSignedDeviceManifest,@@ -44,6 +45,7 @@4445 } from "./vault";4546 import {4647   findPairedDeviceById,48+  findPairedDeviceByPubkey,4749   listPairedDevices,4850   upsertPairedDevice,4951 } from "./paired-devices";@@ -198,9 +200,12 @@198200       );199201     },200202 201-    async applyIncomingDeviceManifest(manifest) {203+    async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {202204       requireUnlocked();203-      await applyVerifiedPeerManifest(manifest);205+      await applyVerifiedPeerManifest(206+        manifest,207+        authenticatedRemoteStaticPublicKey,208+      );204209     },205210   };206211 }@@ -245,13 +250,17 @@245250  */246251 async function applyVerifiedPeerManifest(247252   manifest: SignedDeviceManifest,253+  authenticatedRemoteStaticPublicKey: Uint8Array,248254 ): Promise<void> {249255   const issuerId = manifest?.body?.issuerDeviceId;250256   if (typeof issuerId !== "string" || !issuerId) return;251257 252-  const issuer = await findPairedDeviceById(issuerId);258+  if (authenticatedRemoteStaticPublicKey.length !== 32) return;259+  const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);260+  const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);253261   if (254262     !issuer ||263+    issuer.deviceId !== issuerId ||255264     issuer.trustSource !== "direct" ||256265     !issuer.signingPublicKeyHex ||257266     !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)

view file

src/shared/sync-identity.test.ts+5 −4
@@ -74,17 +74,18 @@7474 7575 describe("pubkeyToHex / hexToPubkey", () => {7676   it("round-trips", () => {77-    const pk = Uint8Array.from([0x00, 0xff, 0xa5, 0x10, 0x42]);77+    const pk = Uint8Array.from(Array.from({ length: 32 }, (_, i) => i));7878     const hex = pubkeyToHex(pk);79-    expect(hex).toBe("00ffa51042");8079     expect(hexToPubkey(hex)).toEqual(pk);8180   });8281 83-  it("rejects odd-length hex", () => {82+  it("rejects keys that are not exactly 32 bytes", () => {8483     expect(() => hexToPubkey("abc")).toThrow();84+    expect(() => hexToPubkey("00".repeat(31))).toThrow();85+    expect(() => hexToPubkey("00".repeat(33))).toThrow();8586   });8687 8788   it("rejects invalid hex chars", () => {88-    expect(() => hexToPubkey("zz")).toThrow();89+    expect(() => hexToPubkey("zz".repeat(32))).toThrow();8990   });9091 });

view file

src/shared/sync-identity.ts+2 −2
@@ -185,8 +185,8 @@185185 }186186 187187 export function hexToPubkey(hex: string): Uint8Array {188-  if (hex.length % 2 !== 0) throw new Error("hex length must be even");189-  const out = new Uint8Array(hex.length / 2);188+  if (hex.length !== 64) throw new Error("public key must be exactly 32 bytes");189+  const out = new Uint8Array(32);190190   for (let i = 0; i < out.length; i++) {191191     const byte = parseInt(hex.slice(i * 2, i * 2 + 2), 16);192192     if (Number.isNaN(byte)) throw new Error(`invalid hex at offset ${i * 2}`);

view file

src/shared/sync-types.ts+1 −1
@@ -98,7 +98,7 @@9898   | { type: "SYNC_REQUEST"; entryIds: string[] }9999   | { type: "SYNC_ENTRIES"; entries: Record<string, string> }100100   | { type: "SYNC_TOMBSTONES"; tombstones: Record<string, number> }101-  | { type: "SYNC_DEVICE_MANIFEST"; manifest: SignedDeviceManifest }101+  | { type: "SYNC_DEVICE_MANIFEST"; manifest: SignedDeviceManifest | null }102102   | { type: "SYNC_DONE"; entriesReceived?: number; entriesSent?: number }103103   | { type: "SYNC_ERROR"; error: string };104104 

view file