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
↓ peach-browser-45f6edda.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/recovery-words.test.ts+91 −1src/background/vault.ts+5 −13manifest.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": {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",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": {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 () => {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 /**