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
↓ peach-browser-35336be9.tar.gzatlas at this save
files changed3 files
src/background/recovery-words.test.ts+72 −0src/background/vault.ts+92 −36src/shared/types.ts+7 −0src/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 });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 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,