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
↓ peach-browser-2de264de.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/vault.test.ts+22 −0src/background/vault.ts+19 −0manifest.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": {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",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": {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");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);