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
3 files src/background/recovery-words.test.ts+81 −2src/background/recovery.adversarial.test.ts+9 −10src/background/vault.ts+5 −20
src/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 

view file

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       });

view file

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

view file