2cba0b06e237
H4/COR-002: changePassword re-wraps existing root key instead of generating new one
- time
- 2026-05-16 02:23 UTC · 5 months ago
- machine
- koh2
- parent
- f9d291f0ea22
↓ peach-browser-2cba0b06.tar.gzatlas at this save
files changed3 files
src/background/recovery-words.test.ts+81 −2src/background/recovery.adversarial.test.ts+9 −10src/background/vault.ts+5 −20src/background/recovery-words.test.ts+81 −2
@@ -340,7 +340,10 @@340340 expect(entries.some((entry) => entry.title === 'Check me')).toBe(true);341341 });342342 343- it('fresh recovery words are generated after password change', async () => {343+ // H4/COR-002: changePassword MUST preserve the root key so the user's344+ // existing recovery words continue to work. Rotating the root key on345+ // every password change would silently brick recovery.346+ it('recovery words are preserved across CHANGE_PASSWORD', async () => {344347 await setupUnlockedVault();345348 const before = (await dispatch({ type: 'GET_RECOVERY_WORDS' }) as { data: string[] }).data;346349 await dispatch({@@ -348,7 +351,7 @@348351 payload: { currentPassword: 'test-pass-12345', newPassword: 'new-pass-67890' },349352 });350353 const after = (await dispatch({ type: 'GET_RECOVERY_WORDS' }) as { data: string[] }).data;351- expect(after).not.toEqual(before);354+ expect(after).toEqual(before);352355 });353356 354357 it('recovery is not Pro-gated', async () => {@@ -362,6 +365,82 @@362365 payload: { words, newPassword: 'new-pass' },363366 });364367 expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/);368+ });369+ });370+371+ // H4/COR-002 — changePassword must re-wrap the existing root key, not mint372+ // a new one. The recovery words derived from rootKey must remain valid373+ // after any password change.374+ describe('changePassword preserves root key and recovery words', () => {375+ it('recovery words work to unlock after password change', async () => {376+ await setupUnlockedVault();377+ const words = (await dispatch({ type: 'GET_RECOVERY_WORDS' }) as { data: string[] }).data;378+ const ch = await dispatch({379+ type: 'CHANGE_PASSWORD',380+ payload: { currentPassword: 'test-pass-12345', newPassword: 'new-pass-aaa' },381+ });382+ expect(ch.ok).toBe(true);383+ await lockVault();384+ const r = await dispatch({385+ type: 'RECOVER_WITH_WORDS',386+ payload: { words, newPassword: 'recovered-pass' },387+ });388+ expect(r.ok).toBe(true);389+ });390+391+ it('recovery words are identical before and after password change', async () => {392+ await setupUnlockedVault();393+ const before = (await dispatch({ type: 'GET_RECOVERY_WORDS' }) as { data: string[] }).data;394+ await dispatch({395+ type: 'CHANGE_PASSWORD',396+ payload: { currentPassword: 'test-pass-12345', newPassword: 'another-pass-bbb' },397+ });398+ const after = (await dispatch({ type: 'GET_RECOVERY_WORDS' }) as { data: string[] }).data;399+ expect(after).toEqual(before);400+ });401+402+ it('vault unlocks with the new password after change', async () => {403+ await setupUnlockedVault();404+ await dispatch({405+ type: 'CHANGE_PASSWORD',406+ payload: { currentPassword: 'test-pass-12345', newPassword: 'brand-new-pass' },407+ });408+ await lockVault();409+ const r = await dispatch({410+ type: 'UNLOCK',411+ payload: { password: 'brand-new-pass' },412+ });413+ expect(r.ok).toBe(true);414+ });415+416+ it('the old password no longer unlocks after change', async () => {417+ await setupUnlockedVault();418+ await dispatch({419+ type: 'CHANGE_PASSWORD',420+ payload: { currentPassword: 'test-pass-12345', newPassword: 'brand-new-pass' },421+ });422+ await lockVault();423+ const r = await dispatch({424+ type: 'UNLOCK',425+ payload: { password: 'test-pass-12345' },426+ });427+ expect(r.ok).toBe(false);428+ });429+430+ it('GET_RECOVERY_WORDS still works after a password change + lock + unlock', async () => {431+ await setupUnlockedVault();432+ const before = (await dispatch({ type: 'GET_RECOVERY_WORDS' }) as { data: string[] }).data;433+ await dispatch({434+ type: 'CHANGE_PASSWORD',435+ payload: { currentPassword: 'test-pass-12345', newPassword: 'pass-after-cycle' },436+ });437+ await lockVault();438+ await dispatch({439+ type: 'UNLOCK',440+ payload: { password: 'pass-after-cycle' },441+ });442+ const after = (await dispatch({ type: 'GET_RECOVERY_WORDS' }) as { data: string[] }).data;443+ expect(after).toEqual(before);365444 });366445 });367446 src/background/recovery.adversarial.test.ts+9 −10
@@ -543,11 +543,11 @@543543 // ── Recovery survives password change cycle ─────────────────────────────544544 545545 describe("recovery interaction with password change cycle", () => {546- // NOTE: Peach rotates the root key on every changePassword(), so the OLD547- // recovery words become invalid and the LATEST recovery words are the548- // only ones that work. These tests pin that behaviour.546+ // H4/COR-002: changePassword re-wraps the EXISTING root key instead of547+ // minting a new one, so the original recovery words remain valid across548+ // any number of password rotations.549549 550- it("old recovery words are invalid after a password change (root key rotated)", async () => {550+ it("original recovery words still work after a password change", async () => {551551 await setupUnlockedVault();552552 const oldWords = await getWords();553553 const vault = await import("./vault");@@ -555,14 +555,15 @@555555 vault.lock();556556 const r = await dispatch({557557 type: "RECOVER_WITH_WORDS",558- payload: { words: oldWords, newPassword: "should-not-work" },558+ payload: { words: oldWords, newPassword: "post-change-pass" },559559 });560- expect(r.ok).toBe(false);560+ expect(r.ok).toBe(true);561561 });562562 563563 for (const cycles of [1, 2, 3]) {564- it(`latest recovery words work after ${cycles} password change(s)`, async () => {564+ it(`original recovery words still work after ${cycles} password change(s)`, async () => {565565 await setupUnlockedVault();566+ const originalWords = await getWords();566567 const vault = await import("./vault");567568 let cur = "test-pass-12345";568569 for (let i = 0; i < cycles; i++) {@@ -570,12 +571,10 @@570571 await vault.changePassword(cur, next);571572 cur = next;572573 }573- // Fetch the most recent recovery words.574- const latest = await getWords();575574 vault.lock();576575 const r = await dispatch({577576 type: "RECOVER_WITH_WORDS",578- payload: { words: latest, newPassword: "after-cycles" },577+ payload: { words: originalWords, newPassword: "after-cycles" },579578 });580579 expect(r.ok).toBe(true);581580 });src/background/vault.ts+5 −20
@@ -2101,26 +2101,11 @@21012101 zeroKey(derivedKey);21022102 derivedKey = newKey;21032103 2104- // Rotate the root key so the old recovery words are immediately invalidated.2105- // After this point getRecoveryWords() returns a fresh 12-word set.2106- const {2107- rawRootKey: freshRawKey,2108- recoverySalt: freshSalt,2109- wrappedRootKey: freshWrapped,2110- wrappedNonce: freshNonce,2111- wrappedTag: freshTag,2112- } = await generateAndWrapRootKey();2113- rootKey = unhex(freshRawKey);2114- const slotAfterRekey = await loadSlot(SLOT_A);2115- if (slotAfterRekey) {2116- await writeSlot(SLOT_A, {2117- ...slotAfterRekey,2118- recovery_salt: freshSalt,2119- recovery_wrapped_root_key: freshWrapped,2120- recovery_nonce: freshNonce,2121- recovery_tag: freshTag,2122- });2123- }2104+ // Preserve the existing root key: re-wrap pw_root_* under the new derived2105+ // key so the same recovery words continue to decrypt the vault after a2106+ // password change (H4/COR-002). Rotating the root key here would silently2107+ // invalidate every previously-saved recovery phrase.2108+ await rewrapRootKeyForPassword(newKey, newSalt);21242109 21252110 // Regenerate noise for slot B when duress is not in use. Runs after the21262111 // key swap so a failure here is a disposable noise regeneration, not a