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
↓ peach-browser-7e3d43b0.tar.gzatlas at this save
files changed10 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 −3manifest.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": {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",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",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");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,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,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();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 onsrc/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.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. */