aa05b62ef5a3

fix: gate GET_CODEX_VAULT_DATA behind privileged-page check and fresh master-password re-auth (PB-26-001) — recoveryWords only returned with verifyMainPassword proof, popup asks for password on the words step, ESTIMATE_CODEX_PAGES privileged for symmetry, regression tests; bump 5.0.0→5.0.1

time
2026-08-29 23:50 UTC · 1 month ago
machine
koh2
agent
glm-5.3-flash
session
pb-26 audit fixes
parent
f14867aea6ff
7 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/codex-handlers.ts+22 −8src/background/messages.ts+11 −2src/popup/codex/codex.test.ts+49 −2src/popup/views/CodexGenerateView.tsx+68 −2
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.0.0",4+  "version": "5.0.1",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -2,7 +2,7 @@22   "manifest_version": 3,33   "minimum_chrome_version": "116",44   "name": "Peach Password Manager",5-  "version": "5.0.0",5+  "version": "5.0.1",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "5.0.0",3+  "version": "5.0.1",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/codex-handlers.ts+22 −8
@@ -83,8 +83,13 @@8383  * Read everything the popup needs to build a CodexPayload. Pro-gated:8484  * generating a Codex is a Pro feature (restoring one is not — see8585  * `handleRestoreFromCodex`).86+ *87+ * The recovery words re-derive the ROOT KEY, so they are only returned when a88+ * fresh master-password proof accompanies the request (PB-26-001) — the same89+ * re-auth rule GET_RECOVERY_WORDS enforces. Without a proof the snapshot90+ * still carries the encrypted slot blobs; only `recoveryWords` is withheld.8691  */87-export async function handleGetCodexVaultData(): Promise<{92+export async function handleGetCodexVaultData(password?: string): Promise<{8893   ok: boolean;8994   error?: string;9095   data?: CodexVaultSnapshot;@@ -96,6 +101,14 @@96101     return { ok: false, error: "VAULT_LOCKED: Unlock your vault first." };97102   }98103 104+  let passwordProof = false;105+  if (typeof password === "string" && password) {106+    if (!(await vault.verifyMainPassword(password))) {107+      return { ok: false, error: "wrong_password" };108+    }109+    passwordProof = true;110+  }111+99112   const raw = await chrome.storage.local.get([100113     "peach_vault_a",101114     "peach_vault_b",@@ -115,14 +128,15 @@115128   const slotB = slotBRaw ? JSON.stringify(slotBRaw) : null;116129   const lastModified = typeof lastModRaw === "string" ? lastModRaw : null;117130 118-  // Recovery words are present on every supported vault.119-  // A missing root key throws; treat that as "no words available" rather than120-  // a hard error so the Codex can still print without them.131+  // Recovery words are present on every supported vault, but revealing them132+  // requires the fresh master-password proof above (PB-26-001).121133   let recoveryWords: string[] | null = null;122-  try {123-    recoveryWords = await vault.getRecoveryWords();124-  } catch {125-    recoveryWords = null;134+  if (passwordProof) {135+    try {136+      recoveryWords = await vault.getRecoveryWords();137+    } catch {138+      recoveryWords = null;139+    }126140   }127141 128142   const verifyCode = await getOrCreateVerifyCode();

view file

src/background/messages.ts+11 −2
@@ -162,7 +162,11 @@162162 // COMPLETE_PAIRING were gated here, then deleted outright — they had no163163 // legitimate senders; adoption runs in-background via relay-adoption-bg.ts.164164 // Audit 2026-08-20 #4: GET_LOCAL_IDENTITY gated here — it returns the Pro165-// license key, which a compromised content script could exfiltrate.)165+// license key, which a compromised content script could exfiltrate.166+// PB-26-001: GET_CODEX_VAULT_DATA gated here — it returns the encrypted vault167+// slots and (with a fresh password proof) the recovery words, i.e. everything168+// needed to reconstruct the root key. ESTIMATE_CODEX_PAGES is gated for169+// symmetry (it is popup-only and reveals vault size).166170 const PRIVILEGED_MESSAGE_TYPES: ReadonlySet<string> = new Set([167171   "DESTROY_VAULT",168172   "CLEAR_VAULT_ENTRIES",@@ -173,6 +177,8 @@173177   "SETUP_PIN",174178   "DISABLE_PIN",175179   "GET_LOCAL_IDENTITY",180+  "GET_CODEX_VAULT_DATA",181+  "ESTIMATE_CODEX_PAGES",176182 ]);177183 178184 // LV-3: minimum interval between non-test canary pings per webhook+entry.@@ -1023,9 +1029,12 @@10231029       case "GET_CODEX_VAULT_DATA": {10241030         // Pro-gated. requirePro broadcasts PRO_GATE_TRIGGERED on miss so the10251031         // popup navigates to the License section without further coordination.1032+        // Also privileged-page-gated (PB-26-001), and recoveryWords inside the1033+        // snapshot additionally require a fresh master-password proof.10261034         const gate = await requirePro("codex");10271035         if (!gate.allowed) return { ok: false, error: "pro_required" };1028-        return await handleGetCodexVaultData();1036+        const { password } = (msg.payload ?? {}) as { password?: string };1037+        return await handleGetCodexVaultData(password);10291038       }10301039       case "ESTIMATE_CODEX_PAGES": {10311040         const gate = await requirePro("codex");

view file

src/popup/codex/codex.test.ts+49 −2
@@ -235,6 +235,7 @@235235     const chromeMock = {236236       runtime: {237237         id: 'peach-extension',238+        getURL: (path = '') => `chrome-extension://peach-extension/${path.replace(/^\//, '')}`,238239         onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) },239240         sendMessage: vi.fn(async () => ({ ok: true })),240241       },@@ -287,7 +288,11 @@287288 288289   async function dispatch(message: Message): Promise<MessageResponse> {289290     return new Promise<MessageResponse>((resolve) => {290-      const keepOpen = listener(message, { id: 'peach-extension' }, resolve);291+      const keepOpen = listener(292+        message,293+        { id: 'peach-extension', url: 'chrome-extension://peach-extension/popup.html' },294+        resolve295+      );291296       expect(keepOpen).toBe(true);292297     });293298   }@@ -346,6 +351,43 @@346351     const data = (result as { data: { verifyCode: string } }).data;347352     expect(data.verifyCode).toMatch(/^PX-[A-Z0-9]{4}-[A-Z0-9]{4}$/);348353   });354+355+  // PB-26-001: recovery words (root-key-equivalent) require a fresh356+  // master-password proof, matching GET_RECOVERY_WORDS.357+  it('withholds recoveryWords without a password proof', async () => {358+    await mockPro();359+    const vault = await import('../../background/vault');360+    await vault.createVault('test-pass-12345');361+    const result = await dispatch({ type: 'GET_CODEX_VAULT_DATA' });362+    expect(result.ok).toBe(true);363+    const data = (result as { data: { recoveryWords: unknown } }).data;364+    expect(data.recoveryWords).toBeNull();365+  });366+367+  it('rejects a wrong password proof', async () => {368+    await mockPro();369+    const vault = await import('../../background/vault');370+    await vault.createVault('test-pass-12345');371+    const result = await dispatch({372+      type: 'GET_CODEX_VAULT_DATA',373+      payload: { password: 'not-the-password' },374+    });375+    expect(result.ok).toBe(false);376+    expect((result as { error: string }).error).toBe('wrong_password');377+  });378+379+  it('returns 12 recovery words with a correct password proof', async () => {380+    await mockPro();381+    const vault = await import('../../background/vault');382+    await vault.createVault('test-pass-12345');383+    const result = await dispatch({384+      type: 'GET_CODEX_VAULT_DATA',385+      payload: { password: 'test-pass-12345' },386+    });387+    expect(result.ok).toBe(true);388+    const data = (result as { data: { recoveryWords: string[] } }).data;389+    expect(data.recoveryWords).toHaveLength(12);390+  });349391 });350392 351393 // ── ESTIMATE_CODEX_PAGES handler ──────────────────────────────────────────────@@ -367,6 +409,7 @@367409     const chromeMock = {368410       runtime: {369411         id: 'peach-extension',412+        getURL: (path = '') => `chrome-extension://peach-extension/${path.replace(/^\//, '')}`,370413         onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) },371414         sendMessage: vi.fn(async () => ({ ok: true })),372415       },@@ -419,7 +462,11 @@419462 420463   async function dispatch(message: Message): Promise<MessageResponse> {421464     return new Promise<MessageResponse>((resolve) => {422-      const keepOpen = listener(message, { id: 'peach-extension' }, resolve);465+      const keepOpen = listener(466+        message,467+        { id: 'peach-extension', url: 'chrome-extension://peach-extension/popup.html' },468+        resolve469+      );423470       expect(keepOpen).toBe(true);424471     });425472   }

view file

src/popup/views/CodexGenerateView.tsx+68 −2
@@ -57,6 +57,11 @@5757   const [libraryReady, setLibraryReady] = createSignal(false);5858   const [libraryError, setLibraryError] = createSignal<string | null>(null);5959   const [missingRecoveryWords, setMissingRecoveryWords] = createSignal(false);60+  // PB-26-001: the background only releases recoveryWords with a fresh61+  // master-password proof, so the words step re-asks for it here.62+  const [needsPassword, setNeedsPassword] = createSignal(false);63+  const [password, setPassword] = createSignal("");64+  const [passwordError, setPasswordError] = createSignal<string | null>(null);6065 6166   const [step, setStep] = createSignal<Step>("words");6267   const [confirmPositions, setConfirmPositions] = createSignal<@@ -86,9 +91,13 @@8691       props.onUpgradePro();8792       return;8893     }94+    await loadSnapshot();95+  });8996 97+  async function loadSnapshot(password?: string): Promise<void> {9098     const res = (await sendMessage({9199       type: "GET_CODEX_VAULT_DATA",100+      ...(password ? { payload: { password } } : {}),92101     })) as { ok: boolean; data?: CodexVaultSnapshot; error?: string };93102 94103     if (!res.ok || !res.data) {@@ -98,6 +107,11 @@98107         props.onVaultLocked();99108         return;100109       }110+      // Wrong re-auth password is a form error, not a load failure.111+      if (res.error === "wrong_password") {112+        setPasswordError("wrong password. try again.");113+        return;114+      }101115       setLoadError(res.error ?? "could not load vault data");102116       setLoading(false);103117       return;@@ -107,16 +121,31 @@107121 108122     const words = res.data.recoveryWords;109123     if (!words || words.length !== 12) {110-      setMissingRecoveryWords(true);124+      // No proof supplied yet → ask for it; proof supplied but still no125+      // words → the vault genuinely has no recovery escrow.126+      if (!password) {127+        setNeedsPassword(true);128+      } else {129+        setMissingRecoveryWords(true);130+      }111131       setLoading(false);112132       return;113133     }114134 135+    setNeedsPassword(false);136+    setPassword("");137+    setPasswordError(null);115138     setConfirmPositions(pickConfirmationPositions());116139     setLoading(false);117140     void warmCodexLibrary(res.data);118-  });141+  }119142 143+  async function submitPassword(): Promise<void> {144+    if (!password()) return;145+    setPasswordError(null);146+    await loadSnapshot(password());147+  }148+120149   const allCorrect = createMemo(() => {121150     const snap = snapshot();122151     const words = snap?.recoveryWords;@@ -386,11 +415,48 @@386415           </button>387416         </Show>388417 418+        <Show when={props.isPro && !loading() && !loadError() && needsPassword()}>419+          <div class="peach-codex-recovery-headline">420+            <h2 class="peach-codex-recovery-title">confirm it's you.</h2>421+            <p class="peach-codex-recovery-body">422+              enter your master password to show your recovery words.423+            </p>424+          </div>425+426+          <div class="peach-codex-confirm-section">427+            <input428+              class="peach-codex-confirm-input"429+              type="password"430+              autocomplete="current-password"431+              placeholder="master password"432+              value={password()}433+              onInput={(e) => setPassword((e.target as HTMLInputElement).value)}434+              onKeyDown={(e) => {435+                if (e.key === "Enter") void submitPassword();436+              }}437+            />438+            <Show when={passwordError()}>439+              <p class="peach-hint peach-hint-error peach-codex-error">440+                {passwordError()}441+              </p>442+            </Show>443+          </div>444+445+          <button446+            class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"447+            disabled={!password()}448+            onClick={() => void submitPassword()}449+          >450+            continue451+          </button>452+        </Show>453+389454         <Show390455           when={391456             props.isPro &&392457             !loading() &&393458             !loadError() &&459+            !needsPassword() &&394460             !missingRecoveryWords() &&395461             snapshot()396462           }

view file