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
↓ peach-browser-aa05b62e.tar.gzatlas at this save
files changed7 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 −2manifest.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": {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",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",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();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");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 }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 }