f9fa8bc4f93a
name a rotating device from local state instead of the vouching peer's manifest claim, and stop a third-party issuer renaming a paired record (v4.1.3)
- time
- 2026-07-26 08:04 UTC · 3 months ago
- machine
- koh2
- agent
- claude-opus-5
- session
- sync campaign remaining items
- parent
- 28e4cc72b69c
↓ peach-browser-f9fa8bc4.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-vault-adapter.test.ts+72 −2src/background/sync-vault-adapter.ts+17 −5manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.1.2",4+ "version": "4.1.3",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.1.2",4+ "version": "4.1.3",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.1.2",3+ "version": "4.1.3",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/sync-vault-adapter.test.ts+72 −2
@@ -405,10 +405,12 @@405405 vouchedByDeviceId: "extension-issuer",406406 });407407 408- // The already-paired device is still updated in place.408+ // The already-paired device is still updated in place — but S5: the409+ // third-party issuer does NOT get to rename it. Keys and ports propagate;410+ // the locally-known name is what the user recognises and it stands.409411 const existing = await findPairedDeviceById("android-existing");410412 expect(existing).toMatchObject({411- deviceName: "Existing Pixel renamed",413+ deviceName: "Existing Pixel",412414 longTermPublicKeyHex: "f".repeat(64),413415 trustSource: "direct",414416 wsPort: 52345,@@ -476,6 +478,74 @@476478 (a) => a.type === "DEVICE_KEY_ROTATED" && a.deviceName === "Pixel",477479 ),478480 ).toBe(true);481+ });482+483+ it("S5: a rotation alert names the device from local state, not from the manifest", async () => {484+ // A paired-but-hostile issuer relabels a device it is vouching for, hoping485+ // the rotation alert reads as coming from a device the user trusts more.486+ // The alert must show the name the user already knows, and the relabel487+ // must not stick in the registry either.488+ const { adapter } = await setupVault();489+ const { buildSignedDeviceManifest } = await import("../shared/device-manifest");490+ const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");491+ const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");492+ const { getPendingSyncAlerts } = await import("./sync-alerts");493+494+ const issuerId = "hostile-issuer";495+ const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), issuerId);496+ await upsertPairedDevice({497+ deviceId: issuerId,498+ deviceName: "Old Laptop",499+ deviceType: "extension",500+ longTermPublicKeyHex: "c".repeat(64),501+ signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),502+ pairedAt: 1,503+ trustSource: "direct",504+ });505+ await upsertPairedDevice({506+ deviceId: "android-existing",507+ deviceName: "Spare Tablet",508+ deviceType: "android",509+ longTermPublicKeyHex: "d".repeat(64),510+ signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),511+ pairedAt: 2,512+ trustSource: "direct",513+ });514+515+ const manifest = await buildSignedDeviceManifest(516+ issuerId,517+ [518+ {519+ deviceId: "android-existing",520+ deviceName: "June's Primary Phone",521+ deviceType: "android",522+ longTermPublicKeyHex: "f".repeat(64),523+ signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),524+ wsPort: 52345,525+ pairedAt: 3,526+ },527+ ],528+ issuerKp.privateKey,529+ );530+531+ await adapter.applyIncomingDeviceManifest?.(532+ manifest,533+ new Uint8Array(32).fill(0xcc),534+ );535+ for (let i = 0; i < 25; i++) await Promise.resolve();536+537+ const alerts = await getPendingSyncAlerts();538+ const rotation = alerts.find((a) => a.type === "DEVICE_KEY_ROTATED");539+ expect(rotation?.deviceName).toBe("Spare Tablet");540+ expect(rotation?.entryTitle).toBe("Spare Tablet");541+ expect(alerts.some((a) => a.deviceName === "June's Primary Phone")).toBe(false);542+543+ // The key rotation itself still lands; only the name is refused.544+ const stored = await findPairedDeviceById("android-existing");545+ expect(stored).toMatchObject({546+ deviceName: "Spare Tablet",547+ longTermPublicKeyHex: "f".repeat(64),548+ });479549 });480550 481551 it("accepts a manifest from the phone it paired with — mesh introduction runs in that direction", async () => {src/background/sync-vault-adapter.ts+17 −5
@@ -304,6 +304,9 @@304304 receivedAt: Date.now(),305305 vouchedByDeviceId: issuer.deviceId,306306 });307+ // No local record exists yet, so this name is the issuer's unverified308+ // claim and nothing better is available. It reaches a screen the user309+ // must explicitly confirm — treat it as untrusted display text there.307310 notifyPopup({308311 type: "VOUCHED_DEVICE_PENDING",309312 payload: { deviceName: entry.deviceName, deviceId: entry.deviceId },@@ -322,26 +325,35 @@322325 entry.longTermPublicKeyHex,323326 )324327 ) {328+ // S5: name the device from the LOCAL record, never from the manifest.329+ // The issuer is a third party here (its own entry is skipped above), so330+ // `entry.deviceName` is an unverified claim about someone else — an331+ // attacker who could put it on screen could make its own key rotation332+ // read as a rotation on a device the user trusts more.333+ const knownName = existing.deviceName ?? entry.deviceName;325334 notifyPopup({326335 type: "DEVICE_KEY_ROTATED",327- payload: { deviceName: entry.deviceName, deviceId: entry.deviceId },336+ payload: { deviceName: knownName, deviceId: entry.deviceId },328337 });329338 void recordSyncAlerts([330339 {331340 type: "DEVICE_KEY_ROTATED",332341 entryId: "",333- entryTitle: entry.deviceName,334- deviceName: entry.deviceName,342+ entryTitle: knownName,343+ deviceName: knownName,335344 timestamp: Date.now(),336345 },337346 ]);338347 }339348 340- // Existing device — update in place as before.349+ // Existing device — update in place as before. The name is deliberately350+ // NOT taken from the manifest: a device's name is set by that device at351+ // pairing, and letting a third-party issuer rewrite it is the rename-then-352+ // impersonate half of S5. Self-renames never arrive here anyway.341353 await upsertPairedDevice({342354 ...existing,343355 deviceId: entry.deviceId,344- deviceName: entry.deviceName,356+ deviceName: existing.deviceName ?? entry.deviceName,345357 deviceType: entry.deviceType,346358 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),347359 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),