6cd9c0819d09

accept a device manifest from any directly-paired peer, not only an extension, and sync user-confirmed vouched hosts — mesh introduction reaches the browser through the phone (v4.0.26)

time
2026-07-24 21:50 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
pairing code transport
parent
e94f79e21806
7 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/chirp-sync-vault-adapter.test.ts+47 −3src/background/chirp-sync-vault-adapter.ts+18 −3src/background/sync-trigger.test.ts+19 −1src/background/sync-trigger.ts+9 −1
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach Password Manager",5-  "version": "4.0.25",5+  "version": "4.0.26",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "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.25",4+  "version": "4.0.26",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.25",3+  "version": "4.0.26",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/chirp-sync-vault-adapter.test.ts+47 −3
@@ -472,11 +472,16 @@472472     ).toBe(true);473473   });474474 475-  it("applyIncomingDeviceManifest ignores non-extension issuers and bad signatures", async () => {475+  it("accepts a manifest from the phone it paired with — mesh introduction runs in that direction", async () => {476+    // The browser can never listen, so it joins the mesh THROUGH a native peer;477+    // that peer is the one holding the peer list. A phone-issued manifest must478+    // therefore be honoured. MV-7 still applies: the introduced device lands in479+    // the pending queue, not the registry.476480     const { adapter } = await setupVault();477481     const { buildSignedDeviceManifest } = await import("../shared/device-manifest");478482     const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");479483     const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");484+    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");480485 481486     const mobileIssuerId = "android-issuer";482487     const mobileIssuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), mobileIssuerId);@@ -492,17 +497,56 @@492497     const mobileManifest = await buildSignedDeviceManifest(493498       mobileIssuerId,494499       [{495-        deviceId: "should-not-add",500+        deviceId: "mesh-peer",496501         deviceName: "Other Pixel",497502         deviceType: "android",498503         longTermPublicKeyHex: "2".repeat(64),499-        signingPublicKeyHex: await signingPubkeyHex("should-not-add"),504+        signingPublicKeyHex: await signingPubkeyHex("mesh-peer"),500505         pairedAt: 1_700_000_000_010,501506       }],502507       mobileIssuerKp.privateKey,503508     );504509     await adapter.applyIncomingDeviceManifest?.(mobileManifest);510+511+    expect(await findPairedDeviceById("mesh-peer")).toBeNull();512+    expect((await listPendingVouchedDevices()).map((d) => d.deviceId)).toEqual(["mesh-peer"]);513+  });514+515+  it("applyIncomingDeviceManifest ignores vouched issuers and bad signatures", async () => {516+    const { adapter } = await setupVault();517+    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");518+    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");519+    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");520+    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");521+522+    // Trust does not travel two hops: a device we only know because someone523+    // else vouched for it cannot go on to vouch for further devices.524+    const vouchedIssuerId = "android-vouched-issuer";525+    const vouchedKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), vouchedIssuerId);526+    await upsertPairedDevice({527+      deviceId: vouchedIssuerId,528+      deviceName: "Second-hand Pixel",529+      deviceType: "android",530+      longTermPublicKeyHex: "1".repeat(64),531+      signingPublicKeyHex: pubkeyToHex(vouchedKp.publicKeyRaw),532+      pairedAt: 1_700_000_000_000,533+      trustSource: "vouched",534+    });535+    const onward = await buildSignedDeviceManifest(536+      vouchedIssuerId,537+      [{538+        deviceId: "should-not-add",539+        deviceName: "Other Pixel",540+        deviceType: "android",541+        longTermPublicKeyHex: "2".repeat(64),542+        signingPublicKeyHex: await signingPubkeyHex("should-not-add"),543+        pairedAt: 1_700_000_000_010,544+      }],545+      vouchedKp.privateKey,546+    );547+    await adapter.applyIncomingDeviceManifest?.(onward);505548     expect(await findPairedDeviceById("should-not-add")).toBeNull();549+    expect(await listPendingVouchedDevices()).toEqual([]);506550 507551     const extensionIssuerId = "extension-bad-sig";508552     const extensionKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), extensionIssuerId);

view file

src/background/chirp-sync-vault-adapter.ts+18 −3
@@ -200,7 +200,7 @@200200 201201     async applyIncomingDeviceManifest(manifest) {202202       requireUnlocked();203-      await applyVerifiedExtensionManifest(manifest);203+      await applyVerifiedPeerManifest(manifest);204204     },205205   };206206 }@@ -228,7 +228,22 @@228228   };229229 }230230 231-async function applyVerifiedExtensionManifest(231+/**232+ * Apply a device manifest issued by a peer we pair with directly.233+ *234+ * The issuer used to be required to be an "extension", on the assumption that235+ * the extension was the mesh's trust anchor. It is not: the browser can never236+ * listen for a connection, so the device it joins the mesh THROUGH is a native237+ * peer (a phone), and that peer is the one holding the peer list to hand over.238+ * Rejecting its manifest made mesh introduction impossible in the direction it239+ * actually happens.240+ *241+ * Trust is not widened by that change. The issuer must still be a DIRECTLY242+ * paired device — a vouched peer cannot vouch onward — and the MV-7 gate below243+ * still routes any device we have never seen into the pending queue for244+ * explicit user confirmation rather than trusting it on the signature alone.245+ */246+async function applyVerifiedPeerManifest(232247   manifest: SignedDeviceManifest,233248 ): Promise<void> {234249   const issuerId = manifest?.body?.issuerDeviceId;@@ -237,7 +252,7 @@237252   const issuer = await findPairedDeviceById(issuerId);238253   if (239254     !issuer ||240-    issuer.deviceType !== "extension" ||255+    issuer.trustSource !== "direct" ||241256     !issuer.signingPublicKeyHex ||242257     !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)243258   ) {

view file

src/background/sync-trigger.test.ts+19 −1
@@ -163,13 +163,31 @@163163     expect(ran).toBe(true);164164   });165165 166-  it("does not call runner when only vouched devices are in registry", async () => {166+  it("syncs a vouched device — mesh introduction is the point of vouching", async () => {167+    // A device only reaches the registry as "vouched" after the user confirms168+    // it out of the MV-7 pending queue (CONFIRM_VOUCHED_DEVICE). Skipping it169+    // here made that confirmation a no-op: the device was listed but never170+    // synced. An UNconfirmed device is still never a target — it is absent171+    // from the registry entirely, which the vault-adapter specs cover.167172     const vouched: PairedDevice = {168173       ...ANDROID,169174       deviceId: "VVV-vouched",170175       trustSource: "vouched",171176     };172177     await upsertPairedDevice(vouched);178+    const seen: string[] = [];179+    setSyncRunner(async (d) => { seen.push(d.deviceId); return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });180+    await triggerSyncIfReachable({ reason: "foreground" });181+    expect(seen).toEqual(["VVV-vouched"]);182+  });183+184+  it("never treats another extension as a sync host — it cannot listen", async () => {185+    await upsertPairedDevice({186+      ...ANDROID,187+      deviceId: "EXT-other",188+      deviceType: "extension",189+      trustSource: "direct",190+    });173191     let ran = false;174192     setSyncRunner(async () => { ran = true; return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });175193     await triggerSyncIfReachable({ reason: "foreground" });

view file

src/background/sync-trigger.ts+9 −1
@@ -101,12 +101,20 @@101101 102102   try {103103     const devices = await listPairedDevices();104+    // Only native peers can accept a connection, so only they are sync hosts;105+    // another extension is reachable solely via a relay, which is the relaying106+    // device's job, not ours.107+    //108+    // "vouched" belongs here alongside "direct": a vouched device is one the109+    // user has already confirmed out of the pending queue (MV-7), and the whole110+    // point of mesh introduction is that it becomes reachable without its own111+    // pairing code. Excluding it left confirmed devices permanently unsynced.104112     const hosts = devices.filter(105113       (d) =>106114         (d.deviceType === "android" ||107115           d.deviceType === "ios" ||108116           d.deviceType === "desktop") &&109-        d.trustSource === "direct",117+        (d.trustSource === "direct" || d.trustSource === "vouched"),110118     );111119     if (hosts.length === 0) return;112120     if (!state.runner) return;

view file