cdac7526ea77

fix: PCH-01-009 rehydrate rootKey in unlockWithKey biometric path via deriveRootKeyFromDerivedKey — sync + recovery-word access work after biometric unlock (v3.8.1)

time
2026-06-10 19:49 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
pch-01 fix integration
parent
14cea33605e5
2 files src/background/vault.test.ts+31 −0src/background/vault.ts+11 −0
src/background/vault.test.ts+31 −0
@@ -1035,6 +1035,37 @@10351035     expect(vault.getRootKeyHex()).toBeNull();10361036   });10371037 1038+  it("unlock_with_key_rehydrates_root_key (PCH-01-009)", async () => {1039+    const vault = await import("./vault");1040+    await vault.createVault("biometric-pass-12345");1041+    const rootBefore = vault.getRootKeyHex();1042+    const keyHex = vault.getDerivedKeyHex();1043+    expect(rootBefore).not.toBeNull();1044+    vault.lock();1045+    // The biometric path passes the pre-derived AES key; rootKey must rehydrate.1046+    const r = await vault.unlockWithKey(keyHex);1047+    expect(r.ok).toBe(true);1048+    expect(vault.getRootKeyHex()).toBe(rootBefore);1049+  });1050+1051+  it("unlock_with_key_succeeds_without_escrow (PCH-01-009)", async () => {1052+    const vault = await import("./vault");1053+    await vault.createVault("legacy-pass-12345");1054+    const keyHex = vault.getDerivedKeyHex();1055+    vault.lock();1056+    // Strip the recovery escrow to mimic a pre-v2.12 slot — unlock must still1057+    // succeed (non-fatal), leaving rootKey null.1058+    const slot = storage.peach_vault_a as Record<string, unknown>;1059+    delete slot.recovery_salt;1060+    delete slot.recovery_wrapped_root_key;1061+    delete slot.pw_root_ciphertext;1062+    delete slot.pw_root_nonce;1063+    delete slot.pw_root_tag;1064+    const r = await vault.unlockWithKey(keyHex);1065+    expect(r.ok).toBe(true);1066+    expect(vault.getRootKeyHex()).toBeNull();1067+  });1068+10381069   it("duress keeps both slots equal length at rest, even as the real vault grows (audit MEDIUM)", async () => {10391070     const vault = await import("./vault");10401071     await vault.createVault("duress-real-pass-aaaa");

view file

src/background/vault.ts+11 −0
@@ -1312,6 +1312,17 @@13121312     buildDomainIndexSync(entries);13131313     await loadSettings();13141314     touchAutoLock();1315+1316+    // PCH-01-009: the biometric path skips Argon2id but must still rehydrate the1317+    // root key, exactly like attemptUnlockWithDerive and restoreFromSession.1318+    // Without it, rootKey stays null after a biometric unlock and chirp sync +1319+    // GET_RECOVERY_WORDS report the open vault as locked. Guarded and non-fatal:1320+    // a missing/legacy escrow must not fail the unlock itself.1321+    if (stored.recovery_salt && stored.recovery_wrapped_root_key) {1322+      try {1323+        rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1324+      } catch { /* non-fatal — unlock still succeeds without rootKey */ }1325+    }13151326     return { ok: true, entries };13161327   } catch {13171328     return { ok: false, error: "invalid_key" };

view file