35336be9b375

H2/COR-006/SEC-006/CRYPTO-11: implement deriveRootKeyFromDerivedKey via pw_root_* wrapping; call migrateToRecovery on unlock

time
2026-05-16 02:10 UTC · 5 months ago
machine
koh2
parent
f3db4b95c247
3 files src/background/recovery-words.test.ts+72 −0src/background/vault.ts+92 −36src/shared/types.ts+7 −0
src/background/recovery-words.test.ts+72 −0
@@ -364,4 +364,76 @@364364       expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/);365365     });366366   });367+368+  // H2/COR-006/SEC-006/CRYPTO-11 — rootKey must survive lock/unlock cycles369+  // so the user's 12 recovery words are stable across sessions. The fix is370+  // a pw_root_* second wrapping under the password-derived key.371+  describe('deriveRootKeyFromDerivedKey and recovery persistence', () => {372+    it('GET_RECOVERY_WORDS works after lock and re-unlock', async () => {373+      await setupUnlockedVault();374+      const r1 = await dispatch({ type: 'GET_RECOVERY_WORDS' });375+      const words1 = (r1 as { data: string[] }).data;376+      expect(words1).toHaveLength(12);377+      await lockVault();378+      const unlocked = await dispatch({379+        type: 'UNLOCK',380+        payload: { password: 'test-pass-12345' },381+      });382+      expect(unlocked.ok).toBe(true);383+      const r2 = await dispatch({ type: 'GET_RECOVERY_WORDS' });384+      const words2 = (r2 as { data: string[] }).data;385+      expect(words2).toEqual(words1);386+    });387+388+    it('GET_RECOVERY_WORDS works after multiple lock/unlock cycles', async () => {389+      await setupUnlockedVault();390+      const original = (await dispatch({ type: 'GET_RECOVERY_WORDS' }) as { data: string[] }).data;391+      for (let i = 0; i < 5; i++) {392+        await lockVault();393+        await dispatch({394+          type: 'UNLOCK',395+          payload: { password: 'test-pass-12345' },396+        });397+      }398+      const after = (await dispatch({ type: 'GET_RECOVERY_WORDS' }) as { data: string[] }).data;399+      expect(after).toEqual(original);400+    });401+402+    it('pre-v2.12 vault (no recovery escrow) gets recovery words after first unlock', async () => {403+      // Build a slot A that has *no* recovery_* fields and *no* pw_root_*.404+      // The migration path in unlock() should populate both.405+      await setupUnlockedVault();406+      const slot = rawSlotA();407+      const legacy: StoredVault = {408+        version: slot.version,409+        salt: slot.salt,410+        nonce: slot.nonce,411+        ciphertext: slot.ciphertext,412+        tag: slot.tag,413+      };414+      storage.peach_vault_a = legacy;415+      await lockVault();416+417+      const unlocked = await dispatch({418+        type: 'UNLOCK',419+        payload: { password: 'test-pass-12345' },420+      });421+      expect(unlocked.ok).toBe(true);422+423+      const result = await dispatch({ type: 'GET_RECOVERY_WORDS' });424+      expect(result.ok).toBe(true);425+      const words = (result as { data: string[] }).data;426+      expect(words).toHaveLength(12);427+428+      // Migration must surface the recovery-pending flag so the popup429+      // re-presents the words for confirmation.430+      expect(storage.peach_recovery_pending).toBe('1');431+432+      // Slot A must now have both wrappings persisted.433+      const slotAfter = storage.peach_vault_a as StoredVault;434+      expect(slotAfter.recovery_salt).toBeTruthy();435+      expect(slotAfter.recovery_wrapped_root_key).toBeTruthy();436+      expect(slotAfter.pw_root_ciphertext).toBeTruthy();437+    });438+  });367439 });

view file

src/background/vault.ts+92 −36
@@ -314,19 +314,21 @@314314 /**315315  * Re-wrap the cached root key under a new derived key (from the user's new316316  * password). Called after password change and after recovery re-key so the317- * same recovery words continue to work with the updated password.317+ * same recovery words continue to work with the updated password — and so318+ * subsequent unlocks can rehydrate rootKey via deriveRootKeyFromDerivedKey.319+ *320+ * recovery_* fields are intentionally not touched: those wrap rootKey under321+ * the recovery words, which do not change when the password changes.318322  */319323 export async function rewrapRootKeyForPassword(320-  _newDerivedKey: Uint8Array,324+  newDerivedKey: Uint8Array,321325   _newSalt: Uint8Array322326 ): Promise<void> {323327   if (!rootKey) throw new Error("root key not set");324328   const slotA = await loadSlot(SLOT_A);325329   if (!slotA) throw new Error("missing slot A");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_*.330+  const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);331+  await writeSlot(SLOT_A, { ...slotA, ...pwRoot });330332 }331333 332334 /**@@ -364,6 +366,17 @@364366   await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });365367   if (derivedKey) zeroKey(derivedKey);366368   derivedKey = newKey;369+  // Refresh pw_root_* under the new key so deriveRootKeyFromDerivedKey works370+  // on the next unlock (H2/COR-006). If rootKey is missing — recovery flow371+  // should have populated it — fall back silently rather than blowing up the372+  // recovery path; migrateToRecovery will rehydrate on the next unlock.373+  if (rootKey) {374+    const slotAfter = await loadSlot(SLOT_A);375+    if (slotAfter) {376+      const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);377+      await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });378+    }379+  }367380   if (!duressConfigured) {368381     await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));369382   }@@ -382,36 +395,51 @@382395 }383396 384397 /**385- * Re-derive the session root key from the already-derived AES key (keyA from386- * unlock) and the recovery salt stored in slot A. This is the inverse of387- * generateAndWrapRootKey() — both operations use the same HKDF with the same388- * salt and info string, so they cancel out.398+ * Recover the session root key by AES-GCM-decrypting the pw_root_* fields399+ * (a second wrapping of the root key under the password-derived key). This400+ * is what makes GET_RECOVERY_WORDS work across lock/unlock cycles without401+ * the user re-entering their recovery phrase: the same root key bytes —402+ * and therefore the same 12 words — survive every unlock.389403  *390- * The derivedKey and rootKey are related by:391- *   derivedKey = Argon2id(password, slotA.salt)   → AES key for vault payload392- *   rootKey    = HKDF-SHA256(recovery_words, recovery_salt)  → wrapped by HKDF393- *394- * Since we only store the wrapped root key and the recovery salt, we can395- * only re-derive rootKey during an active (unlocked) session where we have396- * the derivedKey already. On lock, rootKey is zeroed.397- *398- * For pre-v2.12 vaults that have no recovery_salt, this returns null and399- * the caller must invoke migrateToRecovery() to generate the recovery escrow.404+ * Returns null when the pw_root fields are absent (pre-v2.12 vault that405+ * hasn't been migrated yet) or when decryption fails (wrong derived key,406+ * tampered ciphertext). On null the caller should fall through to407+ * migrateToRecovery() to populate the fields.400408  */401409 async function deriveRootKeyFromDerivedKey(402-  _derivedKey: Uint8Array,403-  _recoverySaltHex: string410+  derivedKeyBytes: Uint8Array,411+  slotA: StoredVault,404412 ): Promise<Uint8Array | null> {405-  // The root key is stored wrapped under the recovery key derived from the406-  // 12-word phrase. It cannot be re-derived from derivedKey alone — that would407-  // require knowing the original recovery words. The in-memory rootKey is set408-  // during unlock if recovery data is present.409-  // This function exists as a potential future path if we ever store an410-  // additional derived-key → root-key relationship. Currently rootKey is411-  // cached directly during unlock.412-  return null;413+  if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {414+    return null;415+  }416+  try {417+    const rootHex = await decrypt(418+      derivedKeyBytes,419+      slotA.pw_root_nonce,420+      slotA.pw_root_ciphertext,421+      slotA.pw_root_tag,422+    );423+    return unhex(rootHex);424+  } catch {425+    return null;426+  }413427 }414428 429+// AES-GCM-wrap rootKey under derivedKey for the pw_root_* fields. Returns the430+// hex-encoded ciphertext/nonce/tag triple ready to embed in StoredVault.431+async function wrapRootKeyForDerivedKey(432+  derivedKeyBytes: Uint8Array,433+  rootKeyBytes: Uint8Array,434+): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {435+  const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes));436+  return {437+    pw_root_ciphertext: ciphertext,438+    pw_root_nonce: nonce,439+    pw_root_tag: tag,440+  };441+}442+415443 /**416444  * For vaults created before v2.12 (no recovery_salt/recovery_wrapped_root_key),417445  * generate the root key, wrap it under the recovery words, and persist the@@ -425,19 +453,28 @@425453   }426454   const slotA = await loadSlot(SLOT_A);427455   if (!slotA) throw new Error("missing slot A");428-  if (slotA.recovery_salt && slotA.recovery_wrapped_root_key) return; // already done456+  const hasRecoveryEscrow =457+    !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;458+  const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;459+  if (hasRecoveryEscrow && hasPwRoot) return; // already done429460 461+  // If recovery escrow already exists but pw_root doesn't, we cannot recover462+  // the original rootKey without the user's words. The least-surprising path463+  // is to rotate to a fresh root key — the user's old recovery words become464+  // invalid but we surface peach_recovery_pending so the popup re-presents465+  // the words. The unlock-path callers all set that flag.430466   const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =431467     await generateAndWrapRootKey();432-433468   rootKey = unhex(rawRootKey);434469 470+  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);435471   const nextSlotA: StoredVault = {436472     ...slotA,437473     recovery_salt: recoverySalt,438474     recovery_wrapped_root_key: wrappedRootKey,439475     recovery_nonce: wrappedNonce,440476     recovery_tag: wrappedTag,477+    ...pwRoot,441478   };442479   await writeSlot(SLOT_A, nextSlotA);443480   await stampLocalVaultMutation();@@ -793,6 +830,10 @@793830   const plaintext = encodePayload(entries, duressConfigured);794831   const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);795832 833+  // Second wrapping of rootKey under derivedKey so future unlocks can rehydrate834+  // it without recovery words (H2/COR-006).835+  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);836+796837   const slotA: StoredVault = {797838     version: 1,798839     salt: hex(salt),@@ -803,6 +844,7 @@803844     recovery_wrapped_root_key: wrappedRootKey,804845     recovery_nonce: wrappedNonce,805846     recovery_tag: wrappedTag,847+    ...pwRoot,806848   };807849   await writeSlot(SLOT_A, slotA);808850 @@ -939,10 +981,24 @@939981       const needsRecoverySetup =940982         !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;941983       if (!needsRecoverySetup) {942-        rootKey = await deriveRootKeyFromDerivedKey(943-          keyA,944-          slotA.recovery_salt!945-        );984+        rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);985+      }986+      // Pre-v2.12 vault, or a vault whose pw_root_* fields haven't been written987+      // yet: generate the recovery escrow and seed pw_root_* so subsequent988+      // unlocks can rehydrate rootKey without the user's recovery words.989+      // Best-effort — a failure here must not block unlock.990+      if (needsRecoverySetup || !rootKey) {991+        try {992+          await migrateToRecovery();993+          if (needsRecoverySetup) {994+            // String "1" matches the popup's existing peach_recovery_pending995+            // check; surfaced so the user re-confirms recovery words after a996+            // legacy-vault unlock (H2/COR-006).997+            await chrome.storage.local.set({ peach_recovery_pending: '1' });998+          }999+        } catch (migrationErr) {1000+          console.warn('[Peach] recovery migration failed:', migrationErr);1001+        }9461002       }9471003     }9481004 

view file

src/shared/types.ts+7 −0
@@ -209,6 +209,13 @@209209   recovery_wrapped_root_key?: string; // hex --- AES-256-GCM ciphertext of 32-byte root key210210   recovery_nonce?: string; // hex --- 12 bytes, fresh nonce for the above211211   recovery_tag?: string; // hex --- 16-byte GCM auth tag for the above212+  // Second wrapping of the same root key under the password-derived key.213+  // Lets unlock() recover the rootKey bytes without the user's recovery214+  // words, so GET_RECOVERY_WORDS works across lock/unlock cycles. Refreshed215+  // on every password change so the words stay valid (H2/COR-006).216+  pw_root_ciphertext?: string; // hex --- AES-256-GCM ciphertext of 32-byte root key (hex-encoded)217+  pw_root_nonce?: string; // hex --- 12-byte nonce218+  pw_root_tag?: string; // hex --- 16-byte GCM auth tag212219 }213220 214221 // Background-bound message types — sent from popup/content scripts,

view file