45f6eddadb59

fix: COR-001 preserve recovery escrow during password rewrap (v2.45.3)

time
2026-05-15 20:59 UTC · 5 months ago
machine
koh2
parent
72b453994169
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/recovery-words.test.ts+91 −1src/background/vault.ts+5 −13
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.45.2",4+  "version": "2.45.3",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

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

view file

src/background/recovery-words.test.ts+91 −1
@@ -1,7 +1,7 @@11 import { describe, it, expect, beforeEach } from 'vitest';22 import { vi } from 'vitest';33 4-import type { Message, MessageResponse } from '../shared/types';4+import type { Message, MessageResponse, StoredVault } from '../shared/types';55 import { WORD_LIST } from '../shared/recovery-word-list';66 77 type StorageShape = Record<string, unknown>;@@ -101,6 +101,12 @@101101     vault.lock();102102   }103103 104+  function rawSlotA(): StoredVault {105+    const slot = storage.peach_vault_a as StoredVault | undefined;106+    if (!slot) throw new Error('missing slot A');107+    return slot;108+  }109+104110   async function mockNoPro(): Promise<void> {105111     const license = await import('./license');106112     vi.spyOn(license, 'isProUser').mockResolvedValue(false);@@ -248,6 +254,90 @@248254         payload: { password: 'test-pass-12345' },249255       });250256       expect(unlock.ok).toBe(false);257+    });258+259+    it('preserves recovery escrow fields after recovery password rewrap', async () => {260+      await setupUnlockedVault();261+      const r = await dispatch({ type: 'GET_RECOVERY_WORDS' });262+      const words = (r as { data: string[] }).data;263+      const before = { ...rawSlotA() };264+      await lockVault();265+266+      const result = await dispatch({267+        type: 'RECOVER_WITH_WORDS',268+        payload: { words, newPassword: 'new-passphrase' },269+      });270+      expect(result.ok).toBe(true);271+272+      const after = rawSlotA();273+      expect(after.recovery_wrapped_root_key).toBe(before.recovery_wrapped_root_key);274+      expect(after.recovery_nonce).toBe(before.recovery_nonce);275+      expect(after.recovery_tag).toBe(before.recovery_tag);276+      expect(after.recovery_salt).toBe(before.recovery_salt);277+      expect(after.ciphertext).not.toBe(before.ciphertext);278+      expect(after.salt).not.toBe(before.salt);279+    });280+281+    it('same recovery words succeed on a second recovery', async () => {282+      await setupUnlockedVault();283+      const r = await dispatch({ type: 'GET_RECOVERY_WORDS' });284+      const words = (r as { data: string[] }).data;285+286+      await lockVault();287+      const first = await dispatch({288+        type: 'RECOVER_WITH_WORDS',289+        payload: { words, newPassword: 'pass-1' },290+      });291+      expect(first.ok).toBe(true);292+293+      await lockVault();294+      const second = await dispatch({295+        type: 'RECOVER_WITH_WORDS',296+        payload: { words, newPassword: 'pass-2' },297+      });298+      expect(second.ok).toBe(true);299+300+      await lockVault();301+      const unlock = await dispatch({302+        type: 'UNLOCK',303+        payload: { password: 'pass-2' },304+      });305+      expect(unlock.ok).toBe(true);306+    });307+308+    it('same recovery words succeed on a third recovery', async () => {309+      await setupUnlockedVault();310+      const r = await dispatch({ type: 'GET_RECOVERY_WORDS' });311+      const words = (r as { data: string[] }).data;312+313+      for (let i = 0; i < 3; i += 1) {314+        await lockVault();315+        const result = await dispatch({316+          type: 'RECOVER_WITH_WORDS',317+          payload: { words, newPassword: `pass-${i}` },318+        });319+        expect(result.ok).toBe(true);320+      }321+    });322+323+    it('vault remains usable after recovery', async () => {324+      await setupUnlockedVault();325+      const r = await dispatch({ type: 'GET_RECOVERY_WORDS' });326+      const words = (r as { data: string[] }).data;327+328+      await lockVault();329+      const recovered = await dispatch({330+        type: 'RECOVER_WITH_WORDS',331+        payload: { words, newPassword: 'new-passphrase' },332+      });333+      expect(recovered.ok).toBe(true);334+335+      await dispatch({336+        type: 'ADD_ENTRY',337+        payload: { title: 'Check me', username: '', password: 'p', url: '' },338+      });339+      const entries = (await dispatch({ type: 'GET_ENTRIES' }) as { data: Array<{ title: string }> }).data;340+      expect(entries.some((entry) => entry.title === 'Check me')).toBe(true);251341     });252342 253343     it('fresh recovery words are generated after password change', async () => {

view file

src/background/vault.ts+5 −13
@@ -317,24 +317,16 @@317317  * same recovery words continue to work with the updated password.318318  */319319 export async function rewrapRootKeyForPassword(320-  newDerivedKey: Uint8Array,320+  _newDerivedKey: Uint8Array,321321   _newSalt: Uint8Array322322 ): Promise<void> {323323   if (!rootKey) throw new Error("root key not set");324-  const { nonce, ciphertext, tag } = await encrypt(325-    newDerivedKey,326-    hex(rootKey)327-  );328324   const slotA = await loadSlot(SLOT_A);329325   if (!slotA) throw new Error("missing slot A");330-  await writeSlot(SLOT_A, {331-    ...slotA,332-    recovery_wrapped_root_key: ciphertext,333-    recovery_nonce: nonce,334-    recovery_tag: tag,335-    // recovery_salt does NOT change when password changes — the recovery336-    // words always derive the same key; only the wrapping key changes.337-  });326+  // This build has only recovery-word escrow fields for rootKey. Do not write327+  // password-derived wrapping material into recovery_* fields; the vault328+  // payload itself is re-encrypted under the new password by the caller.329+  // Future pw_root_* fields should be updated here without touching recovery_*.338330 }339331 340332 /**

view file