57cc863ec76c

fix: Decentralize mesh graph convergence (v4.4.6)

- Exchange signed graph state even when vault entry versions match - Let every admitted Noise peer propagate introductions and revocations - Preserve the legacy equal-version fast path for older peers - Document and test hub-independent mesh convergence

time
2026-07-29 02:11 UTC · 2 months ago
machine
koh2
parent
06339055866c
9 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-coordinator.test.ts+46 −0src/background/sync-coordinator.ts+74 −4src/background/sync-vault-adapter.test.ts+14 −8src/background/sync-vault-adapter.ts+18 −13src/shared/device-manifest.ts+4 −4src/shared/sync-types.ts+12 −1
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.4.5",4+  "version": "4.4.6",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": "4.4.5",5+  "version": "4.4.6",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": "4.4.5",3+  "version": "4.4.6",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/sync-coordinator.test.ts+46 −0
@@ -164,6 +164,52 @@164164     expect(outcome.entriesSent).toBe(0);165165   });166166 167+  it("exchanges device graphs at equal entry versions when both peers advertise support", async () => {168+    const [dialerSession, waiterSession] = symmetricSessionPair();169+    const dialerManifest = {170+      body: {171+        manifestVersion: 3,172+        issuerDeviceId: "dialer",173+        issuedAt: 1,174+        devices: [],175+        revoked: [],176+      },177+      signatureHex: "11".repeat(64),178+    } satisfies SignedDeviceManifest;179+    const waiterManifest = {180+      body: {181+        manifestVersion: 3,182+        issuerDeviceId: "waiter",183+        issuedAt: 1,184+        devices: [],185+        revoked: [],186+      },187+      signatureHex: "22".repeat(64),188+    } satisfies SignedDeviceManifest;189+    const dialerApplied: SignedDeviceManifest[] = [];190+    const waiterApplied: SignedDeviceManifest[] = [];191+    const dialer: SyncVaultAdapter = {192+      ...makeVaultAdapter({ deviceId: "dialer", localVersion: 7 }),193+      async buildOutgoingDeviceManifest() { return dialerManifest; },194+      async applyIncomingDeviceManifest(manifest) { dialerApplied.push(manifest); },195+    };196+    const waiter: SyncVaultAdapter = {197+      ...makeVaultAdapter({ deviceId: "waiter", localVersion: 7 }),198+      async buildOutgoingDeviceManifest() { return waiterManifest; },199+      async applyIncomingDeviceManifest(manifest) { waiterApplied.push(manifest); },200+    };201+202+    const [dialerOutcome, waiterOutcome] = await Promise.all([203+      runSyncInitiator(dialerSession, dialer),204+      runSyncResponder(waiterSession, waiter),205+    ]);206+207+    expect(dialerOutcome.status).toBe("completed");208+    expect(waiterOutcome.status).toBe("completed");209+    expect(dialerApplied).toEqual([waiterManifest]);210+    expect(waiterApplied).toEqual([dialerManifest]);211+  });212+167213   it("logs a clock-skew warning but does not abort when clocks differ by > 120s", async () => {168214     const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);169215     const [session, peer] = inMemoryPair();

view file

src/background/sync-coordinator.ts+74 −4
@@ -3,21 +3,27 @@33 // After reconnect Noise KK is open and PAIR_IDENTITY has been exchanged44 // (`shared/sync-transport.ts`), this coordinator drives the manifest exchange:55 //6-//   ->  SYNC_HELLO { vaultId, deviceId }7-//   <-  SYNC_HELLO { vaultId, deviceId }       (mismatch → SYNC_ERROR)6+//   ->  SYNC_HELLO { vaultId, deviceId, deviceManifestVersion? }7+//   <-  SYNC_HELLO { vaultId, deviceId, deviceManifestVersion? }8+//                                                (mismatch → SYNC_ERROR)89 //   <-> SYNC_MANIFEST { entries, tombstones }  (each side sends one)910 //   ->  SYNC_REQUEST { entryIds }              (entries newer on the peer)1011 //   <-  SYNC_ENTRIES { entries }1112 //   <-  SYNC_REQUEST { entryIds }              (entries newer on us)1213 //   ->  SYNC_ENTRIES { entries }1314 //   <-> SYNC_TOMBSTONES { tombstones }15+//   <-> SYNC_DEVICE_MANIFEST                    (also runs on equal versions)1416 //   <-> SYNC_DONE1517 //1618 // Entries are opaque base64 ciphertext — the coordinator never decrypts.1719 // Last-write-wins at the entry level by mtime; tombstones override updates1820 // with a later deletion timestamp.1921 20-import type { SyncMessage, SignedDeviceManifest } from "../shared/sync-types";22+import {23+  DEVICE_MANIFEST_VERSION,24+  type SyncMessage,25+  type SignedDeviceManifest,26+} from "../shared/sync-types";2127 import type { SyncSession } from "../shared/sync-transport";2228 import { SYNC_LIMITS } from "../shared/sync-limits";2329 import { isConnectionClosedError } from "../shared/sync-outcome";@@ -116,6 +122,9 @@116122     const ourDeviceId = await vault.getOurDeviceId();117123     const localVersion = await vault.getLocalVersion();118124     const localTime = Date.now();125+    const supportsDeviceManifest =126+      vault.buildOutgoingDeviceManifest !== undefined &&127+      vault.applyIncomingDeviceManifest !== undefined;119128 120129     await sendMessage(session, {121130       type: "SYNC_HELLO",@@ -123,6 +132,9 @@123132       deviceId: ourDeviceId,124133       version: localVersion,125134       deviceTime: localTime,135+      deviceManifestVersion: supportsDeviceManifest136+        ? DEVICE_MANIFEST_VERSION137+        : undefined,126138     });127139     stage = "waiting_for_device_hello";128140     const helloIn = await receiveMessage(session, timer);@@ -139,8 +151,36 @@139151       console.warn(`[sync] clock skew detected: local=${localTime} remote=${helloIn.deviceTime ?? "unknown"}`);140152     }141153 142-    // Equal versions — nothing to exchange.154+    // Equal entry versions do not imply equal device graphs: paired-device155+    // introductions and revocations live outside the vault version counter.156+    // Exchange the signed graph when both peers advertise support, while an157+    // older peer that omits the capability retains the legacy DONE fast path.143158     if (typeof helloIn.version === "number" && helloIn.version === localVersion) {159+      if (160+        supportsDeviceManifest &&161+        helloIn.deviceManifestVersion === DEVICE_MANIFEST_VERSION162+      ) {163+        stage = "building_device_manifest";164+        const ours = await vault.buildOutgoingDeviceManifest!();165+        await sendMessage(session, {166+          type: "SYNC_DEVICE_MANIFEST",167+          manifest: ours,168+        });169+        stage = "waiting_for_device_graph";170+        const graphIn = await receiveMessage(session, timer);171+        if (graphIn.type !== "SYNC_DEVICE_MANIFEST") {172+          return errorOutcome(173+            result,174+            `expected SYNC_DEVICE_MANIFEST, got ${graphIn.type}`,175+          );176+        }177+        if (graphIn.manifest) {178+          await vault.applyIncomingDeviceManifest!(179+            graphIn.manifest,180+            session.remoteStaticPublicKey,181+          );182+        }183+      }144184       await sendMessage(session, { type: "SYNC_DONE", entriesReceived: 0, entriesSent: 0 });145185       stage = "waiting_for_device_done";146186       const closing = await awaitClosingDone(session, timer);@@ -278,6 +318,9 @@278318     const ourDeviceId = await vault.getOurDeviceId();279319     const localVersion = await vault.getLocalVersion();280320     const localTime = Date.now();321+    const supportsDeviceManifest =322+      vault.buildOutgoingDeviceManifest !== undefined &&323+      vault.applyIncomingDeviceManifest !== undefined;281324 282325     const helloIn = await receiveMessage(session, timer);283326     if (helloIn.type !== "SYNC_HELLO") {@@ -289,6 +332,9 @@289332       deviceId: ourDeviceId,290333       version: localVersion,291334       deviceTime: localTime,335+      deviceManifestVersion: supportsDeviceManifest336+        ? DEVICE_MANIFEST_VERSION337+        : undefined,292338     });293339     if (helloIn.vaultId !== ourVaultId) {294340       result.status = "vault_mismatch";@@ -301,6 +347,30 @@301347     }302348 303349     if (helloIn.version === localVersion) {350+      if (351+        supportsDeviceManifest &&352+        helloIn.deviceManifestVersion === DEVICE_MANIFEST_VERSION353+      ) {354+        stage = "waiting_for_device_graph";355+        const graphIn = await receiveMessage(session, timer);356+        if (graphIn.type !== "SYNC_DEVICE_MANIFEST") {357+          return errorOutcome(358+            result,359+            `expected SYNC_DEVICE_MANIFEST, got ${graphIn.type}`,360+          );361+        }362+        const ours = await vault.buildOutgoingDeviceManifest!();363+        await sendMessage(session, {364+          type: "SYNC_DEVICE_MANIFEST",365+          manifest: ours,366+        });367+        if (graphIn.manifest) {368+          await vault.applyIncomingDeviceManifest!(369+            graphIn.manifest,370+            session.remoteStaticPublicKey,371+          );372+        }373+      }304374       stage = "waiting_for_device_done";305375       const done = await receiveMessage(session, timer);306376       if (done.type !== "SYNC_DONE") {

view file

src/background/sync-vault-adapter.test.ts+14 −8
@@ -552,7 +552,7 @@552552     });553553   });554554 555-  it("§C: a peer's revocation evicts the device and permanently blocks re-vouching", async () => {555+  it("§C: a vouched peer's revocation evicts the device and permanently blocks re-vouching", async () => {556556     const { adapter } = await setupVault();557557     const { buildSignedDeviceManifest } = await import("../shared/device-manifest");558558     const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");@@ -568,7 +568,7 @@568568       longTermPublicKeyHex: "c".repeat(64),569569       signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),570570       pairedAt: 1,571-      trustSource: "direct",571+      trustSource: "vouched",572572     });573573     await upsertPairedDevice({574574       deviceId: "stolen-laptop",@@ -742,15 +742,16 @@742742     expect(await listPendingVouchedDevices()).toEqual([]);743743   });744744 745-  it("applyIncomingDeviceManifest ignores vouched issuers and bad signatures", async () => {745+  it("applyIncomingDeviceManifest accepts vouched issuers but rejects unauthenticated and bad signatures", async () => {746746     const { adapter } = await setupVault();747747     const { buildSignedDeviceManifest } = await import("../shared/device-manifest");748748     const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");749749     const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");750750     const { listPendingVouchedDevices } = await import("./pending-vouched-devices");751751 752-    // Trust does not travel two hops: a device we only know because someone753-    // else vouched for it cannot go on to vouch for further devices.752+    // Once admitted, a peer propagates the pool without relying on the device753+    // that introduced it. The Noise-authenticated key and stored signing key754+    // still bind the manifest to this known mesh member.754755     const vouchedIssuerId = "android-vouched-issuer";755756     const vouchedKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), vouchedIssuerId);756757     await upsertPairedDevice({@@ -765,11 +766,12 @@765766     const onward = await buildSignedDeviceManifest(766767       vouchedIssuerId,767768       [{768-        deviceId: "should-not-add",769+        deviceId: "onward-mesh-peer",769770         deviceName: "Other Pixel",770771         deviceType: "android",771772         longTermPublicKeyHex: "2".repeat(64),772-        signingPublicKeyHex: await signingPubkeyHex("should-not-add"),773+        signingPublicKeyHex: await signingPubkeyHex("onward-mesh-peer"),774+        relayUrl: MESH_RELAY,773775         pairedAt: 1_700_000_000_010,774776       }],775777       vouchedKp.privateKey,@@ -778,7 +780,11 @@778780       onward,779781       new Uint8Array(32).fill(0x11),780782     );781-    expect(await findPairedDeviceById("should-not-add")).toBeNull();783+    expect(await findPairedDeviceById("onward-mesh-peer")).toMatchObject({784+      trustSource: "vouched",785+      relayUrl: MESH_RELAY,786+      rendezvousSecretHex: expect.stringMatching(/^[0-9a-f]{64}$/),787+    });782788     expect(await listPendingVouchedDevices()).toEqual([]);783789 784790     const extensionIssuerId = "extension-bad-sig";

view file

src/background/sync-vault-adapter.ts+18 −13
@@ -16,9 +16,9 @@1616 // Noise KK reconnect session encrypts the transport on top — the envelope is1717 // belt-and-braces so a transport-layer leak doesn't expose plaintext entries.1818 //19-// Device-manifest exchange (v2 brief §4.3) is plumbed here. Any DIRECTLY20-// paired device may issue one — see applyVerifiedPeerManifest for why the21-// issuer is not restricted to the extension, and for what the19+// Device-manifest exchange (v2 brief §4.3) is plumbed here. Any admitted20+// mesh peer may issue one — see applyVerifiedPeerManifest for why the issuer21+// is not restricted by device type or first-pair topology, and for what the2222 // Noise-authenticated static key gates. docs/mesh-vouching-authority.md is the2323 // end-to-end trace.2424 @@ -260,10 +260,12 @@260260  * the extension was the mesh's trust anchor. It is not: the device a fresh261261  * install joins through is the one holding the peer list to hand over.262262  *263- * Trust is not widened by that change. The issuer must still be a DIRECTLY264- * paired device and a vouched peer cannot vouch onward. A v3 entry is admitted265- * only when it also carries a valid relay route; both endpoints independently266- * derive the same pair-only capability from their vault root and public keys.263+ * The issuer must already be admitted locally as either direct or vouched.264+ * That is the decentralisation boundary: an unknown key has no session and no265+ * authority, while every authenticated member can propagate the same signed266+ * pool without the original hub online. A v3 entry is admitted only when it267+ * carries a valid relay route; both endpoints independently derive the same268+ * pair-only capability from their vault root and public keys.267269  */268270 async function applyVerifiedPeerManifest(269271   manifest: SignedDeviceManifest,@@ -278,7 +280,10 @@278280   if (279281     !issuer ||280282     issuer.deviceId !== issuerId ||281-    issuer.trustSource !== "direct" ||283+    (284+      issuer.trustSource !== "direct" &&285+      issuer.trustSource !== "vouched"286+    ) ||282287     !issuer.signingPublicKeyHex ||283288     !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)284289   ) {@@ -341,11 +346,11 @@341346         entry,342347       );343348 344-      // A v3 manifest from a DIRECT, KK-authenticated issuer proves same-vault345-      // membership and carries enough route material for the two introduced346-      // endpoints to derive their own capability. Admit it immediately so a347-      // six-device vault converges to one graph instead of requiring ten manual348-      // confirmations on every fresh install.349+      // A v3 manifest from an admitted, KK-authenticated issuer proves350+      // same-vault membership and carries enough route material for the two351+      // introduced endpoints to derive their own capability. Admit it352+      // immediately so a six-device vault converges without keeping the353+      // original pairing hub online.349354       if (!existing) {350355         if (!meshRoute) continue;351356         await upsertPairedDevice({

view file

src/shared/device-manifest.ts+4 −4
@@ -6,12 +6,12 @@66 // `SyncSession`, encrypted under reconnect Noise KK along with everything else.77 //88 // Purpose: propagate trust between devices without repeating first-pair9-// confirmation. Any DIRECTLY paired device may issue a manifest — the10-// browser cannot listen for connections, so the peer it joins the mesh through11-// is a phone, and that phone is the one holding the peer list to hand over.9+// confirmation. Any already-admitted, Noise-authenticated mesh peer may issue10+// a manifest. That makes membership a convergent pool instead of a graph whose11+// updates still depend on the device that performed the original pairing.1212 // The receiver verifies the signature against that issuer's known Ed255191313 // public key. A v3 entry that carries a valid relay route is auto-admitted as14-// same-vault "vouched" state; it still cannot vouch for another device.14+// same-vault "vouched" state and may propagate that signed graph onward.1515 // See docs/mesh-vouching-authority.md for the full authority trace.1616 //1717 // Security properties:

view file

src/shared/sync-types.ts+12 −1
@@ -92,7 +92,18 @@9292  * encrypted blobs — the sync layer never decrypts vault data.9393  */9494 export type SyncMessage =95-  | { type: "SYNC_HELLO"; vaultId: string; deviceId: string; version: number; deviceTime: number }95+  | {96+      type: "SYNC_HELLO";97+      vaultId: string;98+      deviceId: string;99+      version: number;100+      deviceTime: number;101+      /**102+       * Advertises equal-version device-graph exchange support. Older peers103+       * omit this field and retain the legacy SYNC_DONE fast path.104+       */105+      deviceManifestVersion?: number;106+    }96107   | { type: "SYNC_MANIFEST"; entries: Record<string, number>; tombstones: Record<string, number> }97108   | { type: "SYNC_REQUEST"; entryIds: string[] }98109   | { type: "SYNC_ENTRIES"; entries: Record<string, string> }

view file