47f10bb9ac6a

fix: fall back to derived signing key when stored issuer key is stale (v4.6.7)

time
2026-07-31 18:17 UTC · 2 months ago
machine
koh2
parent
b56583e1a012
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-vault-adapter.test.ts+53 −0src/background/sync-vault-adapter.ts+17 −1
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.6.6",4+  "version": "4.6.7",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -2,7 +2,7 @@22   "manifest_version": 3,33   "minimum_chrome_version": "116",44   "name": "Peach Password Manager",5-  "version": "4.6.6",5+  "version": "4.6.7",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "4.6.6",3+  "version": "4.6.7",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/sync-vault-adapter.test.ts+53 −0
@@ -830,6 +830,59 @@830830     expect(await findPairedDeviceById("forged-add")).toBeNull();831831   });832832 833+  it("falls back to derived signing key when stored issuer key is stale", async () => {834+    const { vault, adapter } = await setupVault();835+    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");836+    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");837+    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");838+    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");839+840+    const issuerId = "stale-key-android";841+    const rootHex = vault.getRootKeyHex()!;842+    const root = Uint8Array.from(rootHex.match(/../g)!.map((b) => parseInt(b, 16)));843+    const issuerKp = await deriveSyncSigningKeypair(root, issuerId);844+845+    await upsertPairedDevice({846+      deviceId: issuerId,847+      deviceName: "Pixel",848+      deviceType: "android",849+      longTermPublicKeyHex: "c".repeat(64),850+      // Stale: an older build persisted this at first-pair under a now-bad851+      // seed. The fallback must recover from this without trusting the row.852+      signingPublicKeyHex: "ff".repeat(32),853+      pairedAt: 1_700_000_000_000,854+      trustSource: "direct",855+    });856+857+    const manifest = await buildSignedDeviceManifest(858+      issuerId,859+      [{860+        deviceId: "onward-peer",861+        deviceName: "Forwarded iPad",862+        deviceType: "extension",863+        longTermPublicKeyHex: "e".repeat(64),864+        signingPublicKeyHex: pubkeyToHex(865+          (await deriveSyncSigningKeypair(root, "onward-peer")).publicKeyRaw,866+        ),867+        relayUrl: MESH_RELAY,868+        pairedAt: 1_700_000_000_010,869+      }],870+      issuerKp.privateKey,871+    );872+873+    await adapter.applyIncomingDeviceManifest?.(874+      manifest,875+      new Uint8Array(32).fill(0xcc),876+    );877+878+    expect(await findPairedDeviceById("onward-peer")).toMatchObject({879+      trustSource: "vouched",880+      relayUrl: MESH_RELAY,881+      rendezvousSecretHex: expect.stringMatching(/^[0-9a-f]{64}$/),882+    });883+    expect(await listPendingVouchedDevices()).toEqual([]);884+  });885+833886   it("S1: a manifest signed by one paired peer is refused over a session authenticated as another", async () => {834887     const { adapter } = await setupVault();835888     const { buildSignedDeviceManifest } = await import("../shared/device-manifest");

view file

src/background/sync-vault-adapter.ts+17 −1
@@ -300,7 +300,23 @@300300       hexToPubkey(issuer.signingPublicKeyHex),301301     );302302   } catch {303-    return;303+    // Stored key may be stale (older builds persisted the key derived at304+    // first-pair and never refreshed it). The signing keypair is deterministic305+    // from (masterKey, deviceId) on both sides, so retry once with the locally306+    // re-derived pubkey. A genuinely forged signature throws here and is307+    // silently rejected — only a stale-but-correct key survives the retry.308+    const masterForIssuer = requireMasterKey();309+    try {310+      const derived = await deriveSyncSigningKeypair(311+        masterForIssuer,312+        issuer.deviceId,313+      );314+      body = await verifyDeviceManifest(manifest, derived.publicKeyRaw);315+    } catch {316+      return;317+    } finally {318+      masterForIssuer.fill(0);319+    }304320   }305321   if (body.issuerDeviceId !== issuer.deviceId) return;306322 

view file