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
5 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 −5
manifest.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": {

view file

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",

view file

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",

view file

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 () => {

view file

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(),

view file