e1bf91ef908d

fix: LV-4 GET_RECOVERY_WORDS requires fresh password re-auth (verifyMainPassword), exempt during recovery-pending confirmation window; change-password flow sets pending

time
2026-06-10 02:43 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
audit fix integration
parent
0c436fd73d57
3 files src/background/messages.test.ts+34 −0src/background/messages.ts+18 −0src/popup/views/Settings.tsx+5 −0
src/background/messages.test.ts+34 −0
@@ -1112,4 +1112,38 @@11121112     expect(await findPairedDeviceById("android-new")).toBeNull();11131113     expect(await listPendingVouchedDevices()).toHaveLength(0);11141114   });1115+1116+  // --- LV-4: GET_RECOVERY_WORDS requires a fresh password proof ---1117+1118+  it("LV-4: GET_RECOVERY_WORDS without a password returns password_required", async () => {1119+    const vault = await import("./vault");1120+    await vault.createVault("correct-pass-12345");1121+1122+    const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: {} });1123+    expect(res).toMatchObject({ ok: false, error: "password_required" });1124+  });1125+1126+  it("LV-4: GET_RECOVERY_WORDS with a wrong password returns wrong_password and no words", async () => {1127+    const vault = await import("./vault");1128+    await vault.createVault("correct-pass-12345");1129+1130+    const res = await dispatch({1131+      type: "GET_RECOVERY_WORDS",1132+      payload: { password: "not-the-password" },1133+    });1134+    expect(res).toMatchObject({ ok: false, error: "wrong_password" });1135+    expect(res.data).toBeUndefined();1136+  });1137+1138+  it("LV-4: GET_RECOVERY_WORDS with the correct password returns 12 words", async () => {1139+    const vault = await import("./vault");1140+    await vault.createVault("correct-pass-12345");1141+1142+    const res = await dispatch({1143+      type: "GET_RECOVERY_WORDS",1144+      payload: { password: "correct-pass-12345" },1145+    });1146+    expect(res.ok).toBe(true);1147+    expect((res.data as string[]).length).toBe(12);1148+  });11151149 });

view file

src/background/messages.ts+18 −0
@@ -17,6 +17,7 @@1717   listPendingVouchedDevices,1818   removePendingVouchedDevice,1919 } from "./pending-vouched-devices";20+import { isRecoveryConfirmPending } from "./recovery-pending";2021 import { setupAutoLock, touchAutoLock } from "./alarms";2122 import { runSelfTest } from "./self-test";2223 import * as vault from "./vault";@@ -311,6 +312,23 @@311312       case "GET_RECOVERY_WORDS": {312313         // Must be unlocked and have recovery set up (vault must be v2.12+).313314         if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };315+        // LV-4: the recovery words re-derive the ROOT KEY — the highest-316+        // value secret in the vault. A session unlock (which may be hours317+        // old on a shared machine) is not enough; require a fresh password318+        // proof, matching the 1Password/Bitwarden pattern for revealing319+        // escrow material. EXEMPTION: the create-vault / rotation confirmation320+        // window (peach_recovery_pending) shows the words immediately after321+        // the user authenticated, and that confirmation UI has no password to322+        // resend — skip the re-auth there only.323+        if (!(await isRecoveryConfirmPending())) {324+          const { password } = (msg.payload ?? {}) as { password?: string };325+          if (typeof password !== "string" || !password) {326+            return { ok: false, error: "password_required" };327+          }328+          if (!(await vault.verifyMainPassword(password))) {329+            return { ok: false, error: "wrong_password" };330+          }331+        }314332         try {315333           const words = await vault.getRecoveryWords();316334           return { ok: true, data: words };

view file

src/popup/views/Settings.tsx+5 −0
@@ -481,6 +481,11 @@481481     if (res.ok) {482482       setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");483483       setShowChangePw(false);484+      // LV-4: the user just authenticated (current password), so the recovery485+      // words reveal that follows is exempt from the re-auth gate. Set the486+      // pending flag the same way the create/recovery flow does; the display487+      // clears it on confirm.488+      await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });484489       setShowNewRecoveryWords(true);485490     } else {486491       setChangePwError(res.error ?? "failed to change password");

view file