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
↓ peach-browser-6cd9c081.tar.gzatlas at this save
files changed7 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 −1manifest.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": {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",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",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);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 ) {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" });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;