2de264de1091

feat: expose getRootKeyHex — stable per-vault root key, invariant across password change (already escrowed via recovery words + pw_root_*); foundation for password-independent sync identity (v3.6.118)

time
2026-05-28 20:18 UTC · 5 months ago
machine
koh2
parent
13728285815b
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/vault.test.ts+22 −0src/background/vault.ts+19 −0
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.117",5+  "version": "3.6.118",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.117",4+  "version": "3.6.118",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.117",3+  "version": "3.6.118",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/vault.test.ts+22 −0
@@ -1013,6 +1013,28 @@10131013     expect(vault.getEntryById(e.id)!.password).toBe("original");10141014   });10151015 1016+  it("getRootKeyHex is stable across a password change (SEV-2-B)", async () => {1017+    const vault = await import("./vault");1018+    await vault.createVault("rootkey-pass-aaaaa");1019+    const before = vault.getRootKeyHex();1020+    expect(before).toMatch(/^[0-9a-f]{64}$/);1021+1022+    expect(await vault.changePassword("rootkey-pass-aaaaa", "rootkey-pass-bbbbb")).toBe(true);1023+    expect(vault.getRootKeyHex()).toBe(before);1024+1025+    // …and it survives a lock/unlock with the new password (rehydrated from pw_root_*).1026+    vault.lock();1027+    expect(await vault.unlock("rootkey-pass-bbbbb")).toBe(true);1028+    expect(vault.getRootKeyHex()).toBe(before);1029+  });1030+1031+  it("getRootKeyHex returns null when the vault is locked", async () => {1032+    const vault = await import("./vault");1033+    await vault.createVault("rootkey-pass-ccccc");1034+    vault.lock();1035+    expect(vault.getRootKeyHex()).toBeNull();1036+  });1037+10161038   it("deleteEntry rolls back the tombstone when the disk write fails (SEV-3-A)", async () => {10171039     const vault = await import("./vault");10181040     await vault.createVault("rollback-pass-bbbbb");

view file

src/background/vault.ts+19 −0
@@ -1352,6 +1352,25 @@13521352   return hex(derivedKey);13531353 }13541354 1355+/**1356+ * Returns the vault's stable root key as hex, or null when locked / not yet1357+ * populated. The root key is random per-vault, generated at creation, escrowed1358+ * under the recovery words, and re-wrapped (not regenerated) on every password1359+ * change — so it is INVARIANT across password rotation, unlike getDerivedKeyHex.1360+ *1361+ * This is the correct input for sync identity derivation (vault_id, the Noise1362+ * XX static key, the Ed25519 manifest signing key, and per-entry envelope1363+ * keys): paired devices keep the same identity and can still decrypt each1364+ * other's entries after either side rotates its master password (SEV-2-B).1365+ *1366+ * Returns null rather than throwing so sync callers degrade to VAULT_LOCKED1367+ * instead of crashing; rootKey is populated on every real-mode unlock via1368+ * deriveRootKeyFromDerivedKey / migrateToRecovery.1369+ */1370+export function getRootKeyHex(): string | null {1371+  return rootKey ? hex(rootKey) : null;1372+}1373+13551374 export function getEntries(): VaultEntry[] {13561375   if (!derivedKey) throw new Error("vault_locked");13571376   return entries.filter((entry) => !entry.deletedAt);

view file