57cc863ec76c
fix: Decentralize mesh graph convergence (v4.4.6)
- time
- 2026-07-29 02:11 UTC · 2 months ago
- machine
- koh2
- parent
- 06339055866c
↓ peach-browser-57cc863e.tar.gzatlas at this save
files changed9 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 −1manifest.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": {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",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",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();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") {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";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({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: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> }