7e3d43b00009

match device revocation on the revoked long-term key, not just the self-asserted deviceId, so a revoked device cannot return under a fresh id (v4.2.9)

time
2026-07-26 18:33 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
S1 mesh vouching authority trace
parent
602463675e06
10 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.test.ts+32 −0src/background/messages.ts+8 −0src/background/paired-devices.ts+26 −5src/background/sync-vault-adapter.test.ts+190 −0src/background/sync-vault-adapter.ts+14 −9src/shared/device-manifest.ts+7 −6src/shared/sync-types.ts+6 −3
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.2.8",4+  "version": "4.2.9",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.2.8",4+  "version": "4.2.9",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.2.8",3+  "version": "4.2.9",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/messages.test.ts+32 −0
@@ -1062,6 +1062,38 @@10621062     expect(await listPendingVouchedDevices()).toHaveLength(0);10631063   });10641064 1065+  it("S1: CONFIRM_VOUCHED_DEVICE refuses a pending device whose key is revoked", async () => {1066+    const vault = await import("./vault");1067+    await vault.createVault("correct-pass-12345");1068+    await seedPendingVouched("android-relabelled");1069+    const { findPairedDeviceById, revokeDeviceById, upsertPairedDevice } =1070+      await import("./paired-devices");1071+    const { listPendingVouchedDevices } = await import(1072+      "./pending-vouched-devices"1073+    );1074+1075+    // The queue entry predates the revocation, and it carries a fresh id, so1076+    // sweeping the queue by id at revocation time would not have caught it.1077+    await upsertPairedDevice({1078+      deviceId: "android-old",1079+      deviceName: "New Pixel",1080+      deviceType: "android",1081+      longTermPublicKeyHex: "e".repeat(64),1082+      signingPublicKeyHex: "a".repeat(64),1083+      pairedAt: 1_700_000_000_000,1084+      trustSource: "vouched",1085+    });1086+    await revokeDeviceById("android-old", "self");1087+1088+    const res = await dispatch({1089+      type: "CONFIRM_VOUCHED_DEVICE",1090+      payload: { deviceId: "android-relabelled" },1091+    });1092+    expect(res).toMatchObject({ ok: false, error: "device_revoked" });1093+    expect(await findPairedDeviceById("android-relabelled")).toBeNull();1094+    expect(await listPendingVouchedDevices()).toHaveLength(0);1095+  });1096+10651097   it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => {10661098     const vault = await import("./vault");10671099     await vault.createVault("correct-pass-12345");

view file

src/background/messages.ts+8 −0
@@ -73,6 +73,7 @@7373   setDeviceName as setDeviceNameV3,7474 } from "./device-identity";7575 import {76+  isDeviceRevoked,7677   listPairedDevices as listPairedDevicesV3,7778   revokeDeviceById as revokeDeviceByIdV3,7879   upsertPairedDevice as upsertPairedDeviceV3,@@ -1537,6 +1538,13 @@15371538         const { deviceId } = msg.payload as { deviceId: string };15381539         const pending = await getPendingVouchedDevice(deviceId);15391540         if (!pending) return { ok: false, error: "not_pending" };1541+        // Last gate before a vouched device becomes a sync peer. A queue entry1542+        // can predate the revocation that names it, so revocation is re-checked1543+        // here rather than trusted to have swept the queue.1544+        if (await isDeviceRevoked(pending.deviceId, pending.longTermPublicKeyHex)) {1545+          await removePendingVouchedDevice(deviceId);1546+          return { ok: false, error: "device_revoked" };1547+        }15401548         await upsertPairedDeviceV3({15411549           deviceId: pending.deviceId,15421550           deviceName: pending.deviceName,

view file

src/background/paired-devices.ts+26 −5
@@ -136,14 +136,35 @@136136   }137137 }138138 139-export async function isDeviceRevoked(deviceId: string): Promise<boolean> {140-  return (await listDeviceRevocations()).some((r) => r.deviceId === deviceId);139+/**140+ * True when either the id or the long-term key is under a terminal revocation.141+ *142+ * `deviceId` is a self-asserted string in a manifest, so keying terminality on143+ * it alone is bypassed by re-vouching a revoked device's key under a fresh id.144+ * The key is the thing a revoked device actually still holds, so it is the145+ * thing terminality has to follow.146+ */147+export async function isDeviceRevoked(148+  deviceId: string,149+  longTermPublicKeyHex?: string,150+): Promise<boolean> {151+  // The all-zero sentinel stands for "we never stored a key for this device"152+  // (see revokeDeviceById) and must not match a real one.153+  const key = longTermPublicKeyHex?.toLowerCase();154+  const keyIsRevocable = key !== undefined && key !== "0".repeat(64);155+  return (await listDeviceRevocations()).some(156+    (r) =>157+      r.deviceId === deviceId ||158+      (keyIsRevocable && r.longTermPublicKeyHex.toLowerCase() === key),159+  );141160 }142161 143162 /**144- * Revoke a device: record it locally, then drop it from the registry and the145- * pending-vouch queue. The record rides out in every manifest this device146- * signs from now on, and blocks the device being vouched back in.163+ * Revoke a device: record it locally, then drop it from the registry. The164+ * record rides out in every manifest this device signs from now on, and blocks165+ * the device being vouched back in. A stale entry may survive in the166+ * pending-vouch queue; the confirm handler re-checks revocation before167+ * promoting anything out of it.147168  */148169 export async function revokeDeviceById(149170   deviceId: string,

view file

src/background/sync-vault-adapter.test.ts+190 −0
@@ -815,6 +815,196 @@815815     expect(await findPairedDeviceById("forged-add")).toBeNull();816816   });817817 818+  it("S1: a manifest signed by one paired peer is refused over a session authenticated as another", async () => {819+    const { adapter } = await setupVault();820+    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");821+    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");822+    const { upsertPairedDevice } = await import("./paired-devices");823+    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");824+825+    // Both peers are DIRECTLY paired, so neither the trustSource gate nor an826+    // unknown-key lookup can be what rejects this — only the binding between827+    // the Noise-authenticated static key and the manifest's claimed issuer.828+    const alphaKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x51), "peer-alpha");829+    const betaKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x52), "peer-beta");830+    await upsertPairedDevice({831+      deviceId: "peer-alpha",832+      deviceName: "Alpha",833+      deviceType: "android",834+      longTermPublicKeyHex: "a".repeat(64),835+      signingPublicKeyHex: pubkeyToHex(alphaKp.publicKeyRaw),836+      pairedAt: 1,837+      trustSource: "direct",838+    });839+    await upsertPairedDevice({840+      deviceId: "peer-beta",841+      deviceName: "Beta",842+      deviceType: "android",843+      longTermPublicKeyHex: "b".repeat(64),844+      signingPublicKeyHex: pubkeyToHex(betaKp.publicKeyRaw),845+      pairedAt: 2,846+      trustSource: "direct",847+    });848+849+    const fromBeta = await buildSignedDeviceManifest(850+      "peer-beta",851+      [{852+        deviceId: "smuggled",853+        deviceName: "Smuggled",854+        deviceType: "android",855+        longTermPublicKeyHex: "5".repeat(64),856+        signingPublicKeyHex: await signingPubkeyHex("smuggled"),857+        pairedAt: 3,858+      }],859+      betaKp.privateKey,860+    );861+862+    // Delivered over a session Noise-authenticated as ALPHA.863+    await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xaa));864+    expect(await listPendingVouchedDevices()).toEqual([]);865+866+    // Same manifest, same devices, delivered over BETA's session: it must land.867+    // Without this control the assertion above could pass for any reason.868+    await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xbb));869+    expect((await listPendingVouchedDevices()).map((d) => d.deviceId)).toEqual(["smuggled"]);870+  });871+872+  it("S1: a revoked long-term key cannot walk back in under a fresh deviceId", async () => {873+    const { adapter } = await setupVault();874+    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");875+    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");876+    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");877+    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");878+879+    const issuerId = "phone-issuer";880+    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x53), issuerId);881+    const stolenKey = "d".repeat(64);882+    await upsertPairedDevice({883+      deviceId: issuerId,884+      deviceName: "Pixel",885+      deviceType: "android",886+      longTermPublicKeyHex: "c".repeat(64),887+      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),888+      pairedAt: 1,889+      trustSource: "direct",890+    });891+    await upsertPairedDevice({892+      deviceId: "stolen-laptop",893+      deviceName: "Old Laptop",894+      deviceType: "extension",895+      longTermPublicKeyHex: stolenKey,896+      signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),897+      pairedAt: 2,898+      trustSource: "direct",899+    });900+901+    const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, {902+      revoked: [{903+        deviceId: "stolen-laptop",904+        longTermPublicKeyHex: stolenKey,905+        revokedAt: Date.now(),906+        revokedByDeviceId: issuerId,907+      }],908+    });909+    await adapter.applyIncomingDeviceManifest?.(revoke, new Uint8Array(32).fill(0xcc));910+    expect(await findPairedDeviceById("stolen-laptop")).toBeNull();911+912+    // deviceId is the issuer's to choose, so re-vouching the same key under a913+    // new one is free. Terminality has to follow the key, not the label.914+    const renamed = await buildSignedDeviceManifest(915+      issuerId,916+      [{917+        deviceId: "stolen-laptop-mk2",918+        deviceName: "New Laptop",919+        deviceType: "extension",920+        longTermPublicKeyHex: stolenKey,921+        signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),922+        pairedAt: Date.now(),923+      }],924+      issuerKp.privateKey,925+    );926+    await adapter.applyIncomingDeviceManifest?.(renamed, new Uint8Array(32).fill(0xcc));927+    expect(await listPendingVouchedDevices()).toEqual([]);928+    expect(await findPairedDeviceById("stolen-laptop-mk2")).toBeNull();929+930+    // Control: the same fresh id carrying a key that was never revoked queues931+    // normally, so the rejection above is the key check and not the id.932+    const clean = await buildSignedDeviceManifest(933+      issuerId,934+      [{935+        deviceId: "stolen-laptop-mk2",936+        deviceName: "New Laptop",937+        deviceType: "extension",938+        longTermPublicKeyHex: "9".repeat(64),939+        signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),940+        pairedAt: Date.now(),941+      }],942+      issuerKp.privateKey,943+    );944+    await adapter.applyIncomingDeviceManifest?.(clean, new Uint8Array(32).fill(0xcc));945+    expect((await listPendingVouchedDevices()).map((d) => d.deviceId)).toEqual([946+      "stolen-laptop-mk2",947+    ]);948+  });949+950+  it("S1: a revoked key cannot be written onto a device we still trust", async () => {951+    const { adapter } = await setupVault();952+    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");953+    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");954+    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");955+956+    const issuerId = "phone-issuer";957+    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x54), issuerId);958+    const revokedKey = "d".repeat(64);959+    await upsertPairedDevice({960+      deviceId: issuerId,961+      deviceName: "Pixel",962+      deviceType: "android",963+      longTermPublicKeyHex: "c".repeat(64),964+      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),965+      pairedAt: 1,966+      trustSource: "direct",967+    });968+    await upsertPairedDevice({969+      deviceId: "trusted-tablet",970+      deviceName: "Tablet",971+      deviceType: "android",972+      longTermPublicKeyHex: "7".repeat(64),973+      signingPublicKeyHex: await signingPubkeyHex("trusted-tablet"),974+      pairedAt: 2,975+      trustSource: "direct",976+    });977+978+    const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, {979+      revoked: [{980+        deviceId: "stolen-laptop",981+        longTermPublicKeyHex: revokedKey,982+        revokedAt: Date.now(),983+        revokedByDeviceId: issuerId,984+      }],985+    });986+    await adapter.applyIncomingDeviceManifest?.(revoke, new Uint8Array(32).fill(0xcc));987+988+    // The manifest rotates a device we already trust onto the revoked key —989+    // the same re-admission dressed as a key rotation.990+    const rotate = await buildSignedDeviceManifest(991+      issuerId,992+      [{993+        deviceId: "trusted-tablet",994+        deviceName: "Tablet",995+        deviceType: "android",996+        longTermPublicKeyHex: revokedKey,997+        signingPublicKeyHex: await signingPubkeyHex("trusted-tablet"),998+        pairedAt: 2,999+      }],1000+      issuerKp.privateKey,1001+    );1002+    await adapter.applyIncomingDeviceManifest?.(rotate, new Uint8Array(32).fill(0xcc));1003+    expect(await findPairedDeviceById("trusted-tablet")).toMatchObject({1004+      longTermPublicKeyHex: "7".repeat(64),1005+    });1006+  });1007+8181008   it("methods throw VaultLockedError when the vault is locked", async () => {8191009     const { vault, adapter } = await setupVault();8201010     vault.lock();

view file

src/background/sync-vault-adapter.ts+14 −9
@@ -16,9 +16,11 @@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. The extension is20-// the trust anchor: manifests are signed with its vault-derived Ed25519 key,21-// and receivers only apply manifests whose issuer is a paired extension.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 the22+// Noise-authenticated static key gates. docs/mesh-vouching-authority.md is the23+// end-to-end trace.2224 2325 import { HKDF } from "@stablelib/hkdf";2426 import { SHA256 } from "@stablelib/sha256";@@ -47,6 +49,7 @@4749 import {4850   findPairedDeviceById,4951   findPairedDeviceByPubkey,52+  isDeviceRevoked,5053   listDeviceRevocations,5154   listPairedDevices,5255   mergeDeviceRevocations,@@ -313,16 +316,18 @@313316     ]);314317   }315318 316-  const revokedIds = new Set(317-    (await listDeviceRevocations()).map((r) => r.deviceId),318-  );319-320319   for (const entry of body.devices) {321320     if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {322321       continue;323322     }324-    // Terminal: no manifest re-admits a revoked device.325-    if (revokedIds.has(entry.deviceId)) continue;323+    // Terminal: no manifest re-admits a revoked device. Matched on the key as324+    // well as the id, because the id is the issuer's to choose — checking only325+    // the id let a revoked key walk back in under a fresh one. The check sits326+    // ahead of the existing-device lookup so it also refuses rotating a device327+    // we still trust onto a revoked key.328+    if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) {329+      continue;330+    }326331     const existing = await findPairedDeviceById(entry.deviceId);327332 328333     // MV-7: a device we have NEVER seen does not become a sync target on

view file

src/shared/device-manifest.ts+7 −6
@@ -5,12 +5,13 @@55 // Manifests are exchanged after the entry sync completes during a normal66 // `SyncSession`, encrypted under reconnect Noise KK along with everything else.77 //8-// Purpose: propagate trust between mobile devices without requiring9-// additional audio pairing sessions. The extension is the trust anchor and10-// always the issuer; mobile peers receive the manifest, verify the11-// signature against the extension's known Ed25519 public key, and upsert12-// any newly-vouched entries into their own paired-device registry. From13-// then on those mobiles can sync directly with each other.8+// Purpose: propagate trust between devices without requiring additional audio9+// pairing sessions. 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.12+// The receiver verifies the signature against that issuer's known Ed2551913+// public key and queues any newly-vouched entries for user confirmation.14+// See docs/mesh-vouching-authority.md for the full authority trace.1415 //1516 // Security properties:1617 //   - Authenticity: Ed25519 signature over a canonicalised JSON body.

view file

src/shared/sync-types.ts+6 −3
@@ -157,7 +157,8 @@157157 158158 /**159159  * A device that must no longer be synced with. Revocation is TERMINAL: once a160- * deviceId appears here, no manifest may re-vouch it. That is deliberate —160+ * deviceId OR its long-term key appears here, no manifest may re-vouch it and161+ * no pending entry carrying it may be confirmed. That is deliberate —161162  * tombstone-style "newest timestamp wins" ordering would let a peer resurrect a162163  * revoked device just by issuing a manifest with a fresh `pairedAt`, and every163164  * timestamp in a manifest is peer-supplied. Re-admitting a device requires a@@ -173,8 +174,10 @@173174  */174175 export interface DeviceRevocation {175176   deviceId: string;176-  /** X25519 sync identity key at the moment of revocation, so the record names177-   *  the exact key that was cut off rather than just an id. */177+  /** X25519 sync identity key at the moment of revocation. Load-bearing, not178+   *  descriptive: `deviceId` is whatever an issuing manifest says it is, so the179+   *  key is what re-vouching is actually matched against. `0`×64 means no key180+   *  was ever stored for the device and matches nothing. */178181   longTermPublicKeyHex: string;179182   revokedAt: number;180183   /** deviceId that issued the revocation, for display only. */

view file