8da868d4f0e1
fix: fail closed message authorization and recovery state (v5.0.19)
- time
- 2026-08-30 11:41 UTC · 1 month ago
- machine
- koh2
- parent
- 271b44b1633f
↓ peach-browser-8da868d4.tar.gzatlas at this save
files changed13 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/message-policy.test.ts+59 −0src/background/message-policy.ts+148 −0src/background/messages.ts+54 −43src/background/recovery-pending.test.ts+56 −0src/background/recovery-pending.ts+31 −13src/popup/App.tsx+7 −8src/popup/views/Lock.tsx+4 −8src/popup/views/RecoveryWordDisplay.tsx+4 −5src/popup/views/Settings.tsx+3 −4src/shared/types.ts+3 −0manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "5.0.17",4+ "version": "5.0.19",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.17",5+ "version": "5.0.19",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.17",3+ "version": "5.0.19",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/message-policy.test.ts+59 −0
@@ -0,0 +1,59 @@1+import { describe, expect, it } from "vitest";2+3+import { getMessageContext, MESSAGE_CONTEXT_POLICY } from "./message-policy";4+import type { MessageType } from "../shared/types";5+6+describe("message context policy", () => {7+ it("classifies every declared message type", () => {8+ const policy = MESSAGE_CONTEXT_POLICY as Record<string, string>;9+ const declaredTypes: MessageType[] = [10+ "GET_SYNC_ALERTS", "CLEAR_CLIPBOARD", "GET_PENDING_VOUCHED_DEVICES", "CONFIRM_VOUCHED_DEVICE",11+ "REJECT_VOUCHED_DEVICE", "VOUCHED_DEVICE_PENDING", "ROTATE_SYNC_IDENTITY", "DEVICE_KEY_ROTATED",12+ "UNLOCK", "UNLOCK_WITH_PIN", "LOCK", "IS_UNLOCKED", "HAS_VAULT", "GET_ENTRIES", "ADD_ENTRY",13+ "BATCH_ADD_ENTRIES", "RESTORE_ENTRIES", "CLEAR_VAULT_ENTRIES", "UPDATE_ENTRY", "ADD_ADDITIONAL_URL",14+ "DELETE_ENTRY", "AUTOFILL_QUERY", "COPY", "CREATE_VAULT", "CREATE_VAULT_FROM_ROOT_KEY",15+ "GET_ENTRY_FOR_FILL", "GET_ENTRY_FOR_EDIT", "CHECK_ALL_BREACHES", "GET_SETTINGS", "UPDATE_SETTINGS",16+ "SETUP_PIN", "DISABLE_PIN", "FIRE_CANARY_PING", "ACTIVATE_LICENSE", "GET_LICENSE", "GET_LICENSE_KEY",17+ "IS_DURESS_ENABLED", "SETUP_DURESS", "DISABLE_DURESS", "VERIFY_MAIN_PASSWORD", "CHANGE_PASSWORD",18+ "GET_SITE_PASSWORD_POLICY", "GET_LOCAL_IDENTITY", "LIST_PAIRED_DEVICES", "RENAME_PAIRED_DEVICE",19+ "UNPAIR_DEVICE", "RENAME_LOCAL_DEVICE", "START_ADOPTION_SOURCE", "START_ADOPTION_RECEIVER",20+ "CANCEL_ADOPTION_SOURCE", "SOURCE_SAS_DECISION", "ADOPTION_SOURCE_SAS", "ADOPTION_SOURCE_RESULT",21+ "TRIGGER_SYNC", "GET_SYNC_STATE", "GET_SYNC_SETTINGS", "SET_SYNC_SETTINGS", "GET_PENDING_PASSKEY",22+ "APPROVE_PASSKEY", "DENY_PASSKEY", "GET_PASSKEY_PROVIDER_STATE", "SET_PASSKEY_PROVIDER_ENABLED",23+ "GET_DELETED_ENTRIES", "RESTORE_ENTRY", "PURGE_ENTRY", "PURGE_EXPIRED_TRASH", "GET_VAULT_HISTORY",24+ "RESTORE_VAULT_SNAPSHOT", "COUNT_DUPLICATES", "DEDUPLICATE_ENTRIES", "RECORD_MANUAL_SUMMON",25+ "DECLINE_CARD_SAVE", "IS_CARD_DECLINED", "GET_VAULT_STATUS", "GET_ADVANCED_HEALTH_REPORT",26+ "DEACTIVATE_LICENSE", "GET_IDENTITIES", "DESTROY_VAULT", "INITIATE_FIREFOX_PASSKEY_CREATE",27+ "COMPLETE_FIREFOX_PASSKEY_CREATE", "PASSKEY_OFFER_DECLINE", "GET_PASSKEY_OFFER_DECLINED",28+ "GET_PASSKEY_FOR_DOMAIN", "PASSKEY_HAS_CREDENTIALS", "PASSKEY_CREATE", "PASSKEY_GET",29+ "PASSKEY_AUTH_RESPONSE", "VALIDATE_RECOVERY_WORDS", "RECOVER_WITH_WORDS", "GET_RECOVERY_WORDS",30+ "GET_RECOVERY_CONFIRM_PENDING", "SET_RECOVERY_CONFIRM_PENDING", "CLEAR_RECOVERY_CONFIRM_PENDING",31+ "GET_IDENTITY_SUGGESTIONS", "TOUCH_AUTO_LOCK", "SAVE_NOTE", "DELETE_NOTE", "GET_NOTES", "SEARCH_VAULT",32+ "ML_INIT", "ML_PREDICT_BATCH", "PING", "SUMMON_AUTOFILL", "VAULT_LOCKED", "VAULT_UNLOCKED",33+ "PRO_GATE_TRIGGERED", "PASSKEY_PRO_GATE", "OPEN_PRO_GATE", "OPEN_POPUP", "OPEN_DETACHED_POPUP",34+ "SHOW_PASSKEY_TOAST", "DISMISS_PASSKEY_TOAST", "SHOULD_PLAY_CELEBRATION", "MARK_CELEBRATION_PLAYED",35+ "GET_CODEX_VAULT_DATA", "ESTIMATE_CODEX_PAGES", "GENERATE_CODEX", "RESTORE_FROM_CODEX",36+ ];37+38+ expect(Object.keys(policy).sort()).toEqual([...new Set(declaredTypes)].sort());39+ for (const type of declaredTypes) expect(getMessageContext(type)).toBeDefined();40+ });41+42+ it("fails closed for unknown runtime message types", () => {43+ expect(getMessageContext("TOTALLY_UNKNOWN_MESSAGE")).toBeUndefined();44+ expect(getMessageContext(undefined)).toBeUndefined();45+ });46+47+ it("marks the bulk vault and device-authority handlers extension-page-only", () => {48+ for (const type of [49+ "GET_ENTRIES",50+ "GET_RECOVERY_WORDS",51+ "RESTORE_ENTRIES",52+ "UNPAIR_DEVICE",53+ "CONFIRM_VOUCHED_DEVICE",54+ "ROTATE_SYNC_IDENTITY",55+ ] as const) {56+ expect(getMessageContext(type)).toBe("extension-page-only");57+ }58+ });59+});src/background/message-policy.ts+148 −0
@@ -0,0 +1,148 @@1+import type { MessageType } from "../shared/types";2+3+export type MessageContext =4+ | "extension-page-only"5+ | "content-script-allowed"6+ | "background-internal"7+ | "page-relay";8+9+/**10+ * Every message type has an explicit trust boundary. Keep this record11+ * exhaustive: adding a MessageType without choosing its sender context must12+ * fail type-checking instead of silently becoming trusted.13+ */14+export const MESSAGE_CONTEXT_POLICY = {15+ // Popup/options operations and sensitive reads/writes.16+ GET_SYNC_ALERTS: "extension-page-only",17+ GET_PENDING_VOUCHED_DEVICES: "extension-page-only",18+ CONFIRM_VOUCHED_DEVICE: "extension-page-only",19+ REJECT_VOUCHED_DEVICE: "extension-page-only",20+ ROTATE_SYNC_IDENTITY: "extension-page-only",21+ UNLOCK: "extension-page-only",22+ UNLOCK_WITH_PIN: "extension-page-only",23+ LOCK: "extension-page-only",24+ IS_UNLOCKED: "extension-page-only",25+ HAS_VAULT: "extension-page-only",26+ GET_ENTRIES: "extension-page-only",27+ BATCH_ADD_ENTRIES: "extension-page-only",28+ RESTORE_ENTRIES: "extension-page-only",29+ CLEAR_VAULT_ENTRIES: "extension-page-only",30+ DELETE_ENTRY: "extension-page-only",31+ CREATE_VAULT: "extension-page-only",32+ CREATE_VAULT_FROM_ROOT_KEY: "extension-page-only",33+ CHECK_ALL_BREACHES: "extension-page-only",34+ UPDATE_SETTINGS: "extension-page-only",35+ SETUP_PIN: "extension-page-only",36+ DISABLE_PIN: "extension-page-only",37+ ACTIVATE_LICENSE: "extension-page-only",38+ GET_LICENSE: "extension-page-only",39+ GET_LICENSE_KEY: "extension-page-only",40+ IS_DURESS_ENABLED: "extension-page-only",41+ SETUP_DURESS: "extension-page-only",42+ DISABLE_DURESS: "extension-page-only",43+ VERIFY_MAIN_PASSWORD: "extension-page-only",44+ CHANGE_PASSWORD: "extension-page-only",45+ GET_LOCAL_IDENTITY: "extension-page-only",46+ LIST_PAIRED_DEVICES: "extension-page-only",47+ RENAME_PAIRED_DEVICE: "extension-page-only",48+ UNPAIR_DEVICE: "extension-page-only",49+ RENAME_LOCAL_DEVICE: "extension-page-only",50+ START_ADOPTION_SOURCE: "extension-page-only",51+ START_ADOPTION_RECEIVER: "extension-page-only",52+ CANCEL_ADOPTION_SOURCE: "extension-page-only",53+ SOURCE_SAS_DECISION: "extension-page-only",54+ TRIGGER_SYNC: "extension-page-only",55+ GET_SYNC_STATE: "extension-page-only",56+ GET_SYNC_SETTINGS: "extension-page-only",57+ SET_SYNC_SETTINGS: "extension-page-only",58+ GET_PENDING_PASSKEY: "extension-page-only",59+ APPROVE_PASSKEY: "extension-page-only",60+ DENY_PASSKEY: "extension-page-only",61+ GET_PASSKEY_PROVIDER_STATE: "extension-page-only",62+ SET_PASSKEY_PROVIDER_ENABLED: "extension-page-only",63+ GET_DELETED_ENTRIES: "extension-page-only",64+ RESTORE_ENTRY: "extension-page-only",65+ PURGE_ENTRY: "extension-page-only",66+ PURGE_EXPIRED_TRASH: "extension-page-only",67+ GET_VAULT_HISTORY: "extension-page-only",68+ RESTORE_VAULT_SNAPSHOT: "extension-page-only",69+ COUNT_DUPLICATES: "extension-page-only",70+ DEDUPLICATE_ENTRIES: "extension-page-only",71+ GET_VAULT_STATUS: "extension-page-only",72+ GET_ADVANCED_HEALTH_REPORT: "extension-page-only",73+ DEACTIVATE_LICENSE: "extension-page-only",74+ GET_IDENTITIES: "content-script-allowed",75+ DESTROY_VAULT: "extension-page-only",76+ COMPLETE_FIREFOX_PASSKEY_CREATE: "extension-page-only",77+ VALIDATE_RECOVERY_WORDS: "extension-page-only",78+ RECOVER_WITH_WORDS: "extension-page-only",79+ GET_RECOVERY_WORDS: "extension-page-only",80+ GET_RECOVERY_CONFIRM_PENDING: "extension-page-only",81+ SET_RECOVERY_CONFIRM_PENDING: "extension-page-only",82+ CLEAR_RECOVERY_CONFIRM_PENDING: "extension-page-only",83+ GET_IDENTITY_SUGGESTIONS: "extension-page-only",84+ SAVE_NOTE: "extension-page-only",85+ DELETE_NOTE: "extension-page-only",86+ GET_NOTES: "extension-page-only",87+ SEARCH_VAULT: "extension-page-only",88+ GET_CODEX_VAULT_DATA: "extension-page-only",89+ ESTIMATE_CODEX_PAGES: "extension-page-only",90+ GENERATE_CODEX: "extension-page-only",91+ RESTORE_FROM_CODEX: "extension-page-only",92+ OPEN_DETACHED_POPUP: "extension-page-only",93+94+ // Content-script requests that are intentionally narrow or origin-bound in95+ // their handlers. These are not bulk vault export or device-authority APIs.96+ ADD_ENTRY: "content-script-allowed",97+ UPDATE_ENTRY: "content-script-allowed",98+ ADD_ADDITIONAL_URL: "content-script-allowed",99+ AUTOFILL_QUERY: "content-script-allowed",100+ COPY: "content-script-allowed",101+ FIRE_CANARY_PING: "content-script-allowed",102+ GET_ENTRY_FOR_FILL: "content-script-allowed",103+ GET_ENTRY_FOR_EDIT: "content-script-allowed",104+ GET_SETTINGS: "content-script-allowed",105+ GET_SITE_PASSWORD_POLICY: "content-script-allowed",106+ RECORD_MANUAL_SUMMON: "content-script-allowed",107+ DECLINE_CARD_SAVE: "content-script-allowed",108+ IS_CARD_DECLINED: "content-script-allowed",109+ PASSKEY_AUTH_RESPONSE: "content-script-allowed",110+ ML_INIT: "content-script-allowed",111+ ML_PREDICT_BATCH: "content-script-allowed",112+ OPEN_PRO_GATE: "content-script-allowed",113+ OPEN_POPUP: "content-script-allowed",114+ TOUCH_AUTO_LOCK: "content-script-allowed",115+116+ // These seven are the explicit Firefox MAIN-world passkey relay surface.117+ INITIATE_FIREFOX_PASSKEY_CREATE: "page-relay",118+ PASSKEY_OFFER_DECLINE: "page-relay",119+ GET_PASSKEY_OFFER_DECLINED: "page-relay",120+ GET_PASSKEY_FOR_DOMAIN: "page-relay",121+ PASSKEY_HAS_CREDENTIALS: "page-relay",122+ PASSKEY_CREATE: "page-relay",123+ PASSKEY_GET: "page-relay",124+125+ // Messages produced by the background or handled outside messages.ts.126+ CLEAR_CLIPBOARD: "background-internal",127+ VOUCHED_DEVICE_PENDING: "background-internal",128+ DEVICE_KEY_ROTATED: "background-internal",129+ ADOPTION_SOURCE_SAS: "background-internal",130+ ADOPTION_SOURCE_RESULT: "background-internal",131+ PING: "background-internal",132+ SUMMON_AUTOFILL: "background-internal",133+ VAULT_LOCKED: "background-internal",134+ VAULT_UNLOCKED: "background-internal",135+ PRO_GATE_TRIGGERED: "background-internal",136+ PASSKEY_PRO_GATE: "background-internal",137+ SHOW_PASSKEY_TOAST: "background-internal",138+ DISMISS_PASSKEY_TOAST: "background-internal",139+ SHOULD_PLAY_CELEBRATION: "extension-page-only",140+ MARK_CELEBRATION_PLAYED: "extension-page-only",141+} satisfies Record<MessageType, MessageContext>;142+143+export function getMessageContext(type: unknown): MessageContext | undefined {144+ if (typeof type !== "string") return undefined;145+ return Object.hasOwn(MESSAGE_CONTEXT_POLICY, type)146+ ? MESSAGE_CONTEXT_POLICY[type as MessageType]147+ : undefined;148+}src/background/messages.ts+54 −43
@@ -17,7 +17,11 @@1717 listPendingVouchedDevices,1818 removePendingVouchedDevice,1919 } from "./pending-vouched-devices";20-import { isRecoveryConfirmPending } from "./recovery-pending";20+import {21+ clearRecoveryConfirmPending,22+ isRecoveryConfirmPending,23+ setRecoveryConfirmPending,24+} from "./recovery-pending";2125 import { setupAutoLock, touchAutoLock } from "./alarms";2226 import { runSelfTest } from "./self-test";2327 import * as vault from "./vault";@@ -101,6 +105,7 @@101105 getSyncState,102106 triggerSyncIfReachable,103107 } from "./sync-trigger";108+import { getMessageContext } from "./message-policy";104109 105110 // Compile-time E2E flag, folded to a literal by Vite's `define` (false in106111 // every production build — verified in the bundled output). Mirrors the@@ -152,35 +157,6 @@152157 return { allowed: false };153158 }154159 155-// ---------------------------------------------------------------------------156-// PCH-01-018: operations that irreversibly destroy or replace vault contents,157-// handlers that store passkey material, and handlers that return sensitive158-// credential material (e.g. the Pro license key). These are gated to trusted159-// extension-page senders in handleMessage; the confirmation UX otherwise160-// lives only in the popup. Keep this in sync when adding new destructive or161-// sensitive handlers. (Codex Security 2026-08-04: GET_ADOPTION_MATERIAL and162-// COMPLETE_PAIRING were gated here, then deleted outright — they had no163-// legitimate senders; adoption runs in-background via relay-adoption-bg.ts.164-// Audit 2026-08-20 #4: GET_LOCAL_IDENTITY gated here — it returns the Pro165-// 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).170-const PRIVILEGED_MESSAGE_TYPES: ReadonlySet<string> = new Set([171- "DESTROY_VAULT",172- "CLEAR_VAULT_ENTRIES",173- "RESTORE_VAULT_SNAPSHOT",174- "RESTORE_FROM_CODEX",175- "COMPLETE_FIREFOX_PASSKEY_CREATE",176- "UNLOCK_WITH_PIN",177- "SETUP_PIN",178- "DISABLE_PIN",179- "GET_LOCAL_IDENTITY",180- "GET_CODEX_VAULT_DATA",181- "ESTIMATE_CODEX_PAGES",182-]);183-184160 // LV-3: minimum interval between non-test canary pings per webhook+entry.185161 const CANARY_MIN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour186162 @@ -231,6 +207,39 @@231207 return undefined;232208 }233209 210+function isExtensionPageSender(sender: Partial<chrome.runtime.MessageSender>): boolean {211+ // A missing URL is the sender shape used by extension UI in unit tests and212+ // by some browser APIs. A real content-script sender has a browser-attested213+ // page URL, so it cannot use this compatibility path.214+ if (!sender.url) return true;215+ try {216+ return new URL(sender.url).origin === new URL(chrome.runtime.getURL("")).origin;217+ } catch {218+ return false;219+ }220+}221+222+function isPageSender(sender: Partial<chrome.runtime.MessageSender>): boolean {223+ return Boolean(sender.url) && !isExtensionPageSender(sender);224+}225+226+function isSenderAllowedForContext(227+ context: ReturnType<typeof getMessageContext>,228+ sender: Partial<chrome.runtime.MessageSender>,229+): boolean {230+ switch (context) {231+ case "extension-page-only":232+ return isExtensionPageSender(sender);233+ case "content-script-allowed":234+ return true;235+ case "page-relay":236+ return isPageSender(sender);237+ case "background-internal":238+ case undefined:239+ return false;240+ }241+}242+234243 // Resolves once restoreFromSession() has run, so handlers see the correct235244 // post-restore unlock state. entry.ts registers this BEFORE the message236245 // handler so the onMessage listener is added synchronously at SW startup@@ -265,19 +274,12 @@265274 return { ok: false, error: "invalid_sender" };266275 }267276 268- // PCH-01-018: destructive and sensitive operations must originate from a269- // trusted extension page (popup/options), never from a content script. The270- // sender.id check above only proves the message came from *our* extension —271- // a content script injected into an attacker page passes it. A single272- // XSS-class bug in the injected UI would otherwise escalate to vault273- // destruction, root-key exfiltration, or attacker device registration.274- // Extension pages carry an extension-origin sender.url; content scripts275- // carry a page URL.276- if (PRIVILEGED_MESSAGE_TYPES.has(msg.type)) {277- const extensionOrigin = chrome.runtime.getURL("");278- if (!sender.url?.startsWith(extensionOrigin)) {279- return { ok: false, error: "invalid_sender" };280- }277+ // PB-26-016: sender authorization is an exhaustive policy, not a278+ // hand-maintained privileged denylist. A missing/unknown policy entry or a279+ // background-only message fails closed before any handler runs.280+ const messageContext = getMessageContext(msg.type);281+ if (!isSenderAllowedForContext(messageContext, sender)) {282+ return { ok: false, error: "invalid_sender" };281283 }282284 283285 if (shouldRefreshAutoLock(msg.type) && vault.isUnlocked()) {@@ -398,6 +400,14 @@398400 return { ok: false, error: "recovery_not_setup" };399401 }400402 }403+ case "GET_RECOVERY_CONFIRM_PENDING":404+ return { ok: true, data: await isRecoveryConfirmPending() };405+ case "SET_RECOVERY_CONFIRM_PENDING":406+ await setRecoveryConfirmPending();407+ return { ok: true };408+ case "CLEAR_RECOVERY_CONFIRM_PENDING":409+ await clearRecoveryConfirmPending();410+ return { ok: true };401411 case "RECOVER_WITH_WORDS": {402412 // User has lost their password but has their 12 recovery words.403413 // We: (1) validate the words, (2) re-encrypt vault with new password.@@ -1326,6 +1336,7 @@13261336 if (chrome.storage.session) {13271337 await chrome.storage.session.clear();13281338 }1339+ await clearRecoveryConfirmPending();13291340 return { ok: true };13301341 }13311342 src/background/recovery-pending.test.ts+56 −0
@@ -0,0 +1,56 @@1+import { beforeEach, describe, expect, it, vi } from "vitest";2+3+describe("recovery confirmation state", () => {4+ let local: { get: ReturnType<typeof vi.fn>; set: ReturnType<typeof vi.fn>; remove: ReturnType<typeof vi.fn> };5+ let session: { get: ReturnType<typeof vi.fn>; set: ReturnType<typeof vi.fn>; remove: ReturnType<typeof vi.fn> };6+ let sessionValues: Record<string, unknown>;7+8+ beforeEach(() => {9+ vi.resetModules();10+ sessionValues = {};11+ local = {12+ get: vi.fn(async () => ({})),13+ set: vi.fn(async () => undefined),14+ remove: vi.fn(async () => undefined),15+ };16+ session = {17+ get: vi.fn(async (key: string) => ({ [key]: sessionValues[key] })),18+ set: vi.fn(async (values: Record<string, unknown>) => Object.assign(sessionValues, values)),19+ remove: vi.fn(async (key: string) => { delete sessionValues[key]; }),20+ };21+ vi.stubGlobal("chrome", { storage: { local, session } });22+ });23+24+ it("stores the pending exemption in session storage, never local storage", async () => {25+ const pending = await import("./recovery-pending");26+27+ await pending.setRecoveryConfirmPending();28+29+ expect(session.set).toHaveBeenCalledWith({ peach_recovery_pending: "1" });30+ expect(local.set).not.toHaveBeenCalled();31+ await expect(pending.isRecoveryConfirmPending()).resolves.toBe(true);32+ });33+34+ it("clears the session-owned exemption", async () => {35+ const pending = await import("./recovery-pending");36+ await pending.setRecoveryConfirmPending();37+38+ await pending.clearRecoveryConfirmPending();39+40+ expect(session.remove).toHaveBeenCalledWith("peach_recovery_pending");41+ await expect(pending.isRecoveryConfirmPending()).resolves.toBe(false);42+ expect(local.remove).not.toHaveBeenCalled();43+ });44+45+ it("uses background memory when session storage is unavailable", async () => {46+ vi.stubGlobal("chrome", { storage: { local } });47+ const pending = await import("./recovery-pending");48+49+ await pending.setRecoveryConfirmPending();50+51+ await expect(pending.isRecoveryConfirmPending()).resolves.toBe(true);52+ await pending.clearRecoveryConfirmPending();53+ await expect(pending.isRecoveryConfirmPending()).resolves.toBe(false);54+ expect(local.set).not.toHaveBeenCalled();55+ });56+});src/background/recovery-pending.ts+31 −13
@@ -1,27 +1,45 @@1-// Persistent flag set while the user has just created a vault and is reading2-// their 12 recovery words but has not yet typed the 3 confirmation words.3-// While the flag is set: (a) lock-on-close and auto-lock are suppressed so4-// the in-memory key needed to re-derive the words survives a popup close, and5-// (b) on the next successful unlock (even after a full browser restart), the6-// popup re-routes to the recovery-words display instead of the entries view.7-//8-// Lives in chrome.storage.local so the flag survives browser restart. Cleared9-// on confirmation success and on DESTROY_VAULT (storage.local.clear()).1+// Background-owned state set while the user has just created or rotated a2+// vault identity and is confirming their recovery words. It intentionally3+// never lives in chrome.storage.local: content scripts can read/write local4+// storage unless the browser supports and has applied a trusted-context gate.5+// Chrome's session storage is in-memory and trusted-context-only by default;6+// Firefox falls back to this background module's memory for the same process.107 118 const KEY = "peach_recovery_pending";9+let inMemoryPending = false;1210 11+function sessionStorage(): chrome.storage.StorageArea | undefined {12+ return chrome.storage?.session;13+}14+1315 export async function isRecoveryConfirmPending(): Promise<boolean> {1416 try {15- if (!chrome.storage?.local) return false;16- const out = await chrome.storage.local.get(KEY);17+ const session = sessionStorage();18+ if (!session) return inMemoryPending;19+ const out = await session.get(KEY);1720 return (out as Record<string, unknown>)[KEY] === "1";1821 } catch {19- return false;22+ return inMemoryPending;2023 }2124 }2225 2326 export async function setRecoveryConfirmPending(): Promise<void> {2427 try {25- await chrome.storage.local?.set({ [KEY]: "1" });28+ const session = sessionStorage();29+ if (session) {30+ await session.set({ [KEY]: "1" });31+ return;32+ }33+ } catch { /* use the in-memory fallback */ }34+ inMemoryPending = true;35+}36+37+export async function clearRecoveryConfirmPending(): Promise<void> {38+ try {39+ const session = sessionStorage();40+ if (session) {41+ await session.remove(KEY);42+ }2643 } catch { /* best-effort */ }44+ inMemoryPending = false;2745 }src/popup/App.tsx+7 −8
@@ -627,13 +627,12 @@627627 // If the user created a vault but hasn't yet confirmed their recovery628628 // words, route back to the recovery-words screen instead of opening the629629 // entries view. The flag lives in chrome.storage.local so it survives630- // both popup close (SW suppresses lock-on-close) and full browser631- // restart (handleUnlock checks the same flag after a fresh password632- // unlock). Cleared when confirmation succeeds.630+ // popup close while the background owns the session-scoped confirmation631+ // exemption. Cleared when confirmation succeeds.633632 let recoveryPending = false;634633 try {635- const out = await chrome.storage.local?.get("peach_recovery_pending");636- recoveryPending = (out as { peach_recovery_pending?: string })?.peach_recovery_pending === "1";634+ const out = await sendMessage({ type: "GET_RECOVERY_CONFIRM_PENDING" });635+ recoveryPending = out.ok === true && out.data === true;637636 } catch { /* best-effort */ }638637 639638 // If this popup was opened from the inline "Open Peach to unlock" button,@@ -1025,14 +1024,14 @@10251024 async function handleUnlock() {10261025 // If the user previously created a vault but hadn't yet confirmed their10271026 // recovery words (and the browser was closed before they could), the1028- // peach_recovery_pending flag is still set in storage.local. Re-route1027+ // the background-owned recovery-pending state is still set. Re-route10291028 // back to the recovery surface so they finish the confirmation step10301029 // before reaching the entries view — the words can be re-derived now10311030 // that the vault is unlocked again.10321031 let recoveryPending = false;10331032 try {1034- const out = await chrome.storage.local?.get("peach_recovery_pending");1035- recoveryPending = (out as { peach_recovery_pending?: string })?.peach_recovery_pending === "1";1033+ const out = await sendMessage({ type: "GET_RECOVERY_CONFIRM_PENDING" });1034+ recoveryPending = out.ok === true && out.data === true;10361035 } catch { /* best-effort */ }10371036 if (recoveryPending) {10381037 setForceRecoveryWords(true);src/popup/views/Lock.tsx+4 −8
@@ -217,14 +217,10 @@217217 });218218 const result = response as { ok: boolean; error?: string };219219 if (result.ok) {220- // Don't unlock yet — show the recovery words screen first. Mark the221- // confirmation as pending in chrome.storage.local so it survives both222- // popup close (lock-on-close skips while set) and full browser restart223- // (next unlock re-routes to the recovery surface). Cleared when the224- // user types the 3 confirmation words successfully.225- try {226- await chrome.storage.local?.set({ peach_recovery_pending: "1" });227- } catch { /* best-effort */ }220+ // Don't unlock yet — show the recovery words screen first. The221+ // background owns this session-scoped exemption so content scripts222+ // cannot forge it through storage.local.223+ await sendMessage({ type: "SET_RECOVERY_CONFIRM_PENDING" });228224 setShowRecoveryWords(true);229225 setLoading(false);230226 return;src/popup/views/RecoveryWordDisplay.tsx+4 −5
@@ -101,13 +101,12 @@101101 102102 setConfirmed(true);103103 setConfirmError("");104- // Clear the recovery-pending flag so lock-on-close / auto-lock resume104+ // Clear the background-owned recovery-pending state so lock-on-close /105+ // auto-lock resume105106 // their normal behavior and future unlocks no longer re-route here.106107 // Once the user has confirmed the words, losing the in-memory key is107108 // no longer a data-loss event.108- try {109- await chrome.storage.local?.remove("peach_recovery_pending");110- } catch { /* best-effort */ }109+ await sendMessage({ type: "CLEAR_RECOVERY_CONFIRM_PENDING" });111110 // Small pause so user sees the success state, then proceed.112111 await new Promise((r) => setTimeout(r, 600));113112 props.onDone();@@ -247,4 +246,4 @@247246 document.body.appendChild(a);248247 a.click();249248 a.remove();250-}\ No newline at end of file249+}src/popup/views/Settings.tsx+3 −4
@@ -694,10 +694,9 @@694694 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");695695 setShowChangePw(false);696696 // LV-4: the user just authenticated (current password), so the recovery697- // words reveal that follows is exempt from the re-auth gate. Set the698- // pending flag the same way the create/recovery flow does; the display699- // clears it on confirm.700- await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });697+ // words reveal that follows is exempt from the re-auth gate. The698+ // background owns this session-scoped exemption; the display clears it.699+ await sendMessage({ type: "SET_RECOVERY_CONFIRM_PENDING" });701700 setShowNewRecoveryWords(true);702701 } else {703702 setChangePwError(res.error ?? "failed to change password");src/shared/types.ts+3 −0
@@ -289,6 +289,9 @@289289 | "VALIDATE_RECOVERY_WORDS"290290 | "RECOVER_WITH_WORDS"291291 | "GET_RECOVERY_WORDS"292+ | "GET_RECOVERY_CONFIRM_PENDING"293+ | "SET_RECOVERY_CONFIRM_PENDING"294+ | "CLEAR_RECOVERY_CONFIRM_PENDING"292295 | "GET_IDENTITY_SUGGESTIONS"293296 | "TOUCH_AUTO_LOCK"294297 | "SAVE_NOTE"