dd13df9e1692

fix(sync): derive sync identity from rootKey not derivedKey — vault_id, Noise XX static key, Ed25519 signing key and per-entry envelope keys stay stable across password rotation (v3.6.119)

time
2026-05-28 20:20 UTC · 5 months ago
machine
koh2
parent
2de264de1091
7 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/chirp-sync-runner.ts+4 −2src/background/chirp-sync-vault-adapter.test.ts+26 −0src/background/chirp-sync-vault-adapter.ts+8 −2src/background/messages.ts+4 −1
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach Password Manager",5-  "version": "3.6.118",5+  "version": "3.6.119",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "3.6.118",4+  "version": "3.6.119",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": "3.6.118",3+  "version": "3.6.119",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/chirp-sync-runner.ts+4 −2
@@ -33,7 +33,7 @@3333 import { deriveSyncIdentityKeypair, hexToPubkey } from "../shared/sync-identity";3434 import { getLocalSubnet } from "../shared/local-network";3535 import { getOrCreateDeviceId } from "./chirp-identity";36-import { getDerivedKeyHex, isUnlocked } from "./vault";36+import { getRootKeyHex, isUnlocked } from "./vault";3737 import type { PairedDevice } from "../shared/sync-types";3838 import type { SyncRunResult } from "./sync-trigger";3939 @@ -88,7 +88,9 @@8888   const expectedRemoteHex = device.longTermPublicKeyHex;8989   const port = device.wsPort ?? DEFAULT_PORT;9090 91-  const masterKeyHex = getDerivedKeyHex();91+  // Sync identity derives from the stable root key, not the password-derived92+  // key — keeps the Noise XX static key constant across password changes (SEV-2-B).93+  const masterKeyHex = getRootKeyHex();9294   if (!masterKeyHex) return { ok: false, error: "VAULT_LOCKED" };9395   const masterKey = hexToBytesLocal(masterKeyHex);9496   const ourDeviceId = await getOrCreateDeviceId();

view file

src/background/chirp-sync-vault-adapter.test.ts+26 −0
@@ -61,6 +61,32 @@6161     expect(id1).toMatch(/^[0-9a-f]{64}$/);6262   });6363 64+  it("vault_id and sync signing key are unchanged after a password change (SEV-2-B)", async () => {65+    const { vault, adapter } = await setupVault();66+    const deviceId = await adapter.getOurDeviceId();67+    const vaultIdBefore = await adapter.computeVaultId();68+69+    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");70+    const rootBefore = vault.getRootKeyHex()!;71+    const unhex = (h: string) => Uint8Array.from(h.match(/../g)!.map((b) => parseInt(b, 16)));72+    const signBefore = pubkeyToHex(73+      (await deriveSyncSigningKeypair(unhex(rootBefore), deviceId)).publicKeyRaw,74+    );75+76+    expect(await vault.changePassword("correct horse battery staple", "a different passphrase")).toBe(true);77+    vault.lock();78+    expect(await vault.unlock("a different passphrase")).toBe(true);79+80+    // vault_id derives from the stable root key, so it must not move.81+    expect(await adapter.computeVaultId()).toBe(vaultIdBefore);82+    const rootAfter = vault.getRootKeyHex()!;83+    expect(rootAfter).toBe(rootBefore);84+    const signAfter = pubkeyToHex(85+      (await deriveSyncSigningKeypair(unhex(rootAfter), deviceId)).publicKeyRaw,86+    );87+    expect(signAfter).toBe(signBefore);88+  });89+6490   it("getOurDeviceId is stable across calls and a fresh UUID v4 on first call", async () => {6591     const { adapter } = await setupVault();6692     const id1 = await adapter.getOurDeviceId();

view file

src/background/chirp-sync-vault-adapter.ts+8 −2
@@ -36,7 +36,7 @@3636 import {3737   applyMergedRemoteEntries,3838   getDeletedEntries,39-  getDerivedKeyHex,39+  getRootKeyHex,4040   getEntries,4141   getEntryById,4242   getVaultVersion,@@ -249,9 +249,15 @@249249   if (!isUnlocked()) throw new VaultLockedError();250250 }251251 252+// Sync derives everything — vault_id, the Noise XX static key, the Ed25519253+// manifest signing key, and the per-entry envelope keys — from the vault's254+// stable root key, NOT the password-derived key. This keeps paired devices'255+// identities and envelope keys constant across a master-password change on256+// either side (SEV-2-B); two paired devices with independently-rotated257+// passwords still share this key and can decrypt each other's entries.252258 function requireMasterKey(): Uint8Array {253259   if (!isUnlocked()) throw new VaultLockedError();254-  const hex = getDerivedKeyHex();260+  const hex = getRootKeyHex();255261   if (!hex) throw new VaultLockedError();256262   return hexToBytes(hex);257263 }

view file

src/background/messages.ts+4 −1
@@ -1086,7 +1086,10 @@10861086         if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };10871087         const proCheck = await requirePro("sync");10881088         if (!proCheck.allowed) return { ok: false, error: "pro_required" };1089-        const masterKeyHex = vault.getDerivedKeyHex();1089+        // Identity derives from the stable root key, not the password-derived1090+        // key, so pairing survives a master-password change (SEV-2-B).1091+        const masterKeyHex = vault.getRootKeyHex();1092+        if (!masterKeyHex) return { ok: false, error: "vault_locked" };10901093         const masterKey = unhexBytes(masterKeyHex);10911094         try {10921095           const identity = await buildLocalIdentity(masterKey);

view file