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
↓ peach-browser-dd13df9e.tar.gzatlas at this save
files changed7 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 −1manifest.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": {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",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": {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();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();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 }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);