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
13 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 −0
manifest.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": {

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.17",5+  "version": "5.0.19",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.17",3+  "version": "5.0.19",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

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+});

view file

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+}

view file

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 

view file

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+});

view file

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 }

view file

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);

view file

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;

view file

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+}

view file

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");

view file

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"

view file