b8f0394669b3

harden session storage to trusted contexts with fail-closed escrow (PB-26-203, v5.0.59)

time
2026-09-03 10:05 UTC · 1 month ago
machine
koh2
agent
muse-spark-1.3-contributor-free
session
PB-26-203 session escrow hardening
parent
4da393b64b5d
14 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/entry.ts+10 −4src/background/message-policy.test.ts+1 −0src/background/message-policy.ts+1 −0src/background/messages.ts+22 −0src/background/passkey-firefox-proxy.test.ts+32 −0src/background/storage-access.test.ts+69 −14src/background/storage-access.ts+30 −10src/background/vault.session-escrow.test.ts+30 −0src/background/vault.ts+20 −1src/content/index.ts+22 −22src/shared/types.ts+1 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.0.58",4+  "version": "5.0.59",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.58",5+  "version": "5.0.59",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.58",3+  "version": "5.0.59",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/entry.ts+10 −4
@@ -4,7 +4,7 @@44 import { setupPasskeyProxy } from "./passkey";55 import { setupCommands } from "./commands";66 import { setupClipboardClearAlarm } from "./clipboard";7-import { restoreFromSession, isUnlocked, lock } from "./vault";7+import { applyStorageHardening, restoreFromSession, isUnlocked, lock } from "./vault";88 import { ensureSettingsLoaded } from "./vault";99 import { isRecoveryConfirmPending } from "./recovery-pending";1010 import { denyAllPendingPasskeyRequests } from "./passkey";@@ -26,7 +26,7 @@2626   selectProductionDialTargets,2727   setupMeshRelayListener,2828 } from "./mesh-relay-listener";29-import { restrictLocalStorageToTrustedContexts } from "./storage-access";29+import { hardenExtensionStorage } from "./storage-access";3030 3131 // Keep alive for MV3 service worker3232 chrome.runtime.onInstalled.addListener(() => {});@@ -58,7 +58,11 @@5858 });5959 6060 export function start(): void {61-  void restrictLocalStorageToTrustedContexts();61+  // PB-26-203: establish the storage trust boundary first, then decide62+  // whether the session escrow may operate, then restore. The composed63+  // promise is handed to the message layer as a gate; the handler itself is64+  // still registered synchronously below (MV3 requirement).65+  const hardening = hardenExtensionStorage();6266   setLockedSyncHooks({6367     refresh: refreshLockedSyncSpool,6468     afterUnlock: drainAndRefreshLockedSyncSpool,@@ -73,7 +77,9 @@7377   // gate, and register the handler synchronously. Unlock-dependent handlers7478   // await the gate (see handleMessage), which preserves the SEV-3-B fix — no7579   // spurious lock screen — without delaying listener registration.76-  const restored = restoreFromSession();80+  const restored = hardening81+    .then((h) => applyStorageHardening(h.sessionEscrowAvailable))82+    .then(() => restoreFromSession());7783   setRestoreReady(restored);7884   setupMessageHandler();7985   void setupAutoLock();

view file

src/background/message-policy.test.ts+1 −0
@@ -31,6 +31,7 @@3131       "DEACTIVATE_LICENSE", "GET_IDENTITIES", "DESTROY_VAULT", "INITIATE_FIREFOX_PASSKEY_CREATE",3232       "COMPLETE_FIREFOX_PASSKEY_CREATE", "PASSKEY_OFFER_DECLINE", "GET_PASSKEY_OFFER_DECLINED",3333       "GET_PASSKEY_FOR_DOMAIN", "PASSKEY_HAS_CREDENTIALS", "PASSKEY_CREATE", "PASSKEY_GET",34+      "MARK_PASSKEY_BANNER_SHOWN",3435       "PASSKEY_AUTH_RESPONSE", "VALIDATE_RECOVERY_WORDS", "RECOVER_WITH_WORDS", "GET_RECOVERY_WORDS",3536       "GET_RECOVERY_CONFIRM_PENDING", "SET_RECOVERY_CONFIRM_PENDING", "CLEAR_RECOVERY_CONFIRM_PENDING",3637       "GET_IDENTITY_SUGGESTIONS", "TOUCH_AUTO_LOCK", "SAVE_NOTE", "DELETE_NOTE", "GET_NOTES", "SEARCH_VAULT",

view file

src/background/message-policy.ts+1 −0
@@ -119,6 +119,7 @@119119   OPEN_PRO_GATE: "content-script-allowed",120120   OPEN_POPUP: "content-script-allowed",121121   TOUCH_AUTO_LOCK: "content-script-allowed",122+  MARK_PASSKEY_BANNER_SHOWN: "content-script-allowed",122123 123124   // These seven are the explicit Firefox MAIN-world passkey relay surface.124125   INITIATE_FIREFOX_PASSKEY_CREATE: "page-relay",

view file

src/background/messages.ts+22 −0
@@ -1422,14 +1422,36 @@14221422         if (!match || !match.passkey) return { ok: true, data: null };14231423         // Slim shape — only the fields State 11 needs. Never expose private14241424         // key material, even though this is locked behind isUnlocked.1425+        // bannerShown is the per-domain "already surfaced" flag, kept in1426+        // trusted background session storage so content scripts never touch1427+        // chrome.storage.session directly (PB-26-203).1428+        let bannerShown = false;1429+        if (chrome.storage.session) {1430+          const flag = await chrome.storage.session.get(`peach_state11_shown:${domain}`);1431+          bannerShown = Boolean((flag as Record<string, unknown>)[`peach_state11_shown:${domain}`]);1432+        }14251433         return {14261434           ok: true,14271435           data: {14281436             rpId: match.passkey.rpId,14291437             userName: match.passkey.userName,14301438             browser: match.passkey.browser,1439+            bannerShown,14311440           },14321441         };1442+      }1443+1444+      case "MARK_PASSKEY_BANNER_SHOWN": {1445+        const { domain } = (msg.payload ?? {}) as { domain?: string };1446+        if (typeof domain !== "string" || !domain) return { ok: false, error: "domain_required" };1447+        // Same origin binding as the read path: a page may only mark its own1448+        // domain, and the flag reveals nothing either way.1449+        const origin = attestedSenderOrigin(sender);1450+        if (!origin || !isValidRpId(domain, origin)) return { ok: true };1451+        if (chrome.storage.session) {1452+          await chrome.storage.session.set({ [`peach_state11_shown:${domain}`]: Date.now() });1453+        }1454+        return { ok: true };14331455       }14341456 14351457       case "DESTROY_VAULT": {

view file

src/background/passkey-firefox-proxy.test.ts+32 −0
@@ -364,6 +364,38 @@364364     expect((own.data as { userName?: string } | null)?.userName).toBe("[email protected]");365365   });366366 367+  it("MARK_PASSKEY_BANNER_SHOWN is origin-bound and surfaces via bannerShown (PB-26-203)", async () => {368+    const vault = await import("./vault");369+    await vault.createVault("correct-pass-12345");370+    await vault.addEntry({371+      type: "login", title: "GitHub", url: "https://github.com", username: "june",372+      passkey: {373+        rpId: "github.com", rpName: "GitHub", credentialId: "c1", publicKeyCose: "pk",374+        privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" } as JsonWebKey,375+        userHandle: "u", userName: "[email protected]", userDisplayName: "June", algorithm: -7,376+        signCount: 0, aaguid: "", backupEligible: false, backupState: false, createdAt: 1,377+      },378+    });379+380+    // evil.com cannot mark github.com's flag: no write, no error leak.381+    await expect(dispatch({382+      type: "MARK_PASSKEY_BANNER_SHOWN", payload: { domain: "github.com" },383+    }, "https://evil.com/")).resolves.toMatchObject({ ok: true });384+    const before = await dispatch({385+      type: "GET_PASSKEY_FOR_DOMAIN", payload: { domain: "github.com" },386+    }, "https://github.com/");387+    expect((before.data as { bannerShown?: boolean } | null)?.bannerShown).toBe(false);388+389+    // github.com marks its own flag; the read path surfaces it.390+    await expect(dispatch({391+      type: "MARK_PASSKEY_BANNER_SHOWN", payload: { domain: "github.com" },392+    }, "https://github.com/")).resolves.toMatchObject({ ok: true });393+    const after = await dispatch({394+      type: "GET_PASSKEY_FOR_DOMAIN", payload: { domain: "github.com" },395+    }, "https://github.com/");396+    expect((after.data as { bannerShown?: boolean } | null)?.bannerShown).toBe(true);397+  });398+367399   it("handleFirefoxPasskeyCreate throws a SecurityError directly on rpId mismatch", async () => {368400     const { handleFirefoxPasskeyCreate } = await import("./passkey");369401     await expect(

view file

src/background/storage-access.test.ts+69 −14
@@ -1,27 +1,82 @@1+import { readdirSync, readFileSync, statSync } from "node:fs";2+import { join } from "node:path";3+import { fileURLToPath } from "node:url";14 import { describe, expect, it, vi } from "vitest";25 3-import { restrictLocalStorageToTrustedContexts } from "./storage-access";6+import { hardenExtensionStorage } from "./storage-access";47 5-describe("local storage access hardening", () => {6-  it("restricts local storage to trusted extension contexts when supported", async () => {7-    const setAccessLevel = vi.fn(async () => undefined);8-    vi.stubGlobal("chrome", { storage: { local: { setAccessLevel } } });8+function area(setAccessLevel?: (options: { accessLevel: string }) => Promise<void>) {9+  return setAccessLevel ? { setAccessLevel } : {};10+}911 10-    await restrictLocalStorageToTrustedContexts();12+describe("extension storage hardening (PB-26-203)", () => {13+  it("requests trusted-only access for both local and session storage", async () => {14+    const localSetAccessLevel = vi.fn(async () => undefined);15+    const sessionSetAccessLevel = vi.fn(async () => undefined);16+    vi.stubGlobal("chrome", {17+      storage: {18+        local: area(localSetAccessLevel),19+        session: area(sessionSetAccessLevel),20+      },21+    });1122 12-    expect(setAccessLevel).toHaveBeenCalledWith({ accessLevel: "TRUSTED_CONTEXTS" });23+    const result = await hardenExtensionStorage();24+25+    expect(localSetAccessLevel).toHaveBeenCalledWith({ accessLevel: "TRUSTED_CONTEXTS" });26+    expect(sessionSetAccessLevel).toHaveBeenCalledWith({ accessLevel: "TRUSTED_CONTEXTS" });27+    expect(result).toEqual({28+      localHardened: true,29+      sessionHardened: true,30+      sessionEscrowAvailable: true,31+    });1332   });1433 15-  it("does not fail startup when Firefox lacks setAccessLevel", async () => {16-    vi.stubGlobal("chrome", { storage: { local: {} } });34+  it("fails closed on escrow when session hardening is rejected", async () => {35+    const localSetAccessLevel = vi.fn(async () => undefined);36+    const sessionSetAccessLevel = vi.fn(async () => { throw new Error("unsupported"); });37+    vi.stubGlobal("chrome", {38+      storage: {39+        local: area(localSetAccessLevel),40+        session: area(sessionSetAccessLevel),41+      },42+    });1743 18-    await expect(restrictLocalStorageToTrustedContexts()).resolves.toBeUndefined();44+    const result = await hardenExtensionStorage();45+46+    expect(result).toEqual({47+      localHardened: true,48+      sessionHardened: false,49+      sessionEscrowAvailable: false,50+    });1951   });2052 21-  it("does not fail startup when the browser rejects the optional hardening call", async () => {22-    const setAccessLevel = vi.fn(async () => { throw new Error("unsupported"); });23-    vi.stubGlobal("chrome", { storage: { local: { setAccessLevel } } });53+  it("treats a missing session area as no escrow without failing startup", async () => {54+    const localSetAccessLevel = vi.fn(async () => undefined);55+    vi.stubGlobal("chrome", { storage: { local: area(localSetAccessLevel) } });2456 25-    await expect(restrictLocalStorageToTrustedContexts()).resolves.toBeUndefined();57+    const result = await hardenExtensionStorage();58+59+    expect(result.sessionEscrowAvailable).toBe(false);60+    expect(result.localHardened).toBe(true);61+  });62+63+  it("never widens session storage to untrusted contexts", () => {64+    const hits: string[] = [];65+    // The forbidden level name appears in this file's own assertion — match66+    // only real setAccessLevel calls, which is the shape a violation takes.67+    const callPattern = /setAccessLevel\(\s*\{\s*accessLevel:\s*"TRUSTED_AND_UNTRUSTED_CONTEXTS"/;68+    const walk = (dir: string) => {69+      for (const name of readdirSync(dir)) {70+        const path = join(dir, name);71+        if (statSync(path).isDirectory()) {72+          walk(path);73+        } else if (path.endsWith(".ts") || path.endsWith(".tsx")) {74+          if (callPattern.test(readFileSync(path, "utf8"))) hits.push(path);75+        }76+      }77+    };78+    walk(join(fileURLToPath(new URL(".", import.meta.url)), ".."));79+80+    expect(hits).toEqual([]);2681   });2782 });

view file

src/background/storage-access.ts+30 −10
@@ -1,18 +1,38 @@11 /**2- * Restrict persistent extension storage from content scripts when the browser3- * exposes the WebExtensions access-level API. Firefox versions without this4- * method remain safe for the sensitive paths because message authorization and5- * session-owned secrets do not rely on this defense-in-depth setting.2+ * PB-26-203: the session-key escrow is only as trustworthy as the boundary3+ * around chrome.storage.session. This initializer positively establishes4+ * trusted-only access on both storage areas before any session restore runs.5+ * Where the browser cannot prove that state, the caller must disable the6+ * escrow (fail closed) instead of trusting an unproven boundary.67  */7-export async function restrictLocalStorageToTrustedContexts(): Promise<void> {8-  const local = chrome.storage?.local as (typeof chrome.storage.local & {9-    setAccessLevel?: (options: { accessLevel: "TRUSTED_CONTEXTS" }) => Promise<void>;10-  }) | undefined;11-  if (typeof local?.setAccessLevel !== "function") return;8+export type StorageHardening = {9+  localHardened: boolean;10+  sessionHardened: boolean;11+  sessionEscrowAvailable: boolean;12+};13+14+type AccessLevelArea = {15+  setAccessLevel?: (options: { accessLevel: "TRUSTED_CONTEXTS" }) => Promise<void>;16+};17+18+async function setTrustedContexts(area: unknown): Promise<boolean> {19+  const typed = area as AccessLevelArea | undefined;20+  if (typeof typed?.setAccessLevel !== "function") return false;1221   try {13-    await local.setAccessLevel({ accessLevel: "TRUSTED_CONTEXTS" });22+    await typed.setAccessLevel({ accessLevel: "TRUSTED_CONTEXTS" });23+    return true;1424   } catch {1525     // Optional browser hardening must never prevent the extension from1626     // starting; the message policy remains the primary security boundary.27+    return false;1728   }29+}30+31+export async function hardenExtensionStorage(): Promise<StorageHardening> {32+  const localHardened = await setTrustedContexts(chrome.storage?.local);33+  const session = chrome.storage?.session;34+  // No session area (older Firefox): no escrow exists to protect, so there35+  // is nothing to harden — but escrow is still reported unavailable.36+  const sessionHardened = session ? await setTrustedContexts(session) : false;37+  return { localHardened, sessionHardened, sessionEscrowAvailable: sessionHardened };1838 }

view file

src/background/vault.session-escrow.test.ts+30 −0
@@ -257,6 +257,36 @@257257     expect(session.peach_recovery_pending).toBeUndefined();258258   });259259 260+  it("disabled escrow clears the stored key and blocks save and restore", async () => {261+    const v1 = await import("./vault");262+    await v1.createVault("strong-pass-abc123");263+    await v1.unlock("strong-pass-abc123");264+    await vi.waitFor(() => expect(session.peach_session_dk).toBeDefined());265+    const parked = session.peach_session_dk;266+267+    await v1.applyStorageHardening(false);268+    expect(session.peach_session_dk).toBeUndefined();269+270+    // A later unlock must not re-park the key while escrow is off.271+    await v1.unlock("strong-pass-abc123");272+    await Promise.resolve();273+    expect(session.peach_session_dk).toBeUndefined();274+275+    // A restarted worker with the same stored key restores only when the276+    // hardening result allows escrow; a disabled escrow fails closed.277+    session.peach_session_dk = parked;278+    vi.resetModules();279+    const v2 = await import("./vault");280+    expect(await v2.restoreFromSession()).toBe(true);281+282+    vi.resetModules();283+    const v3 = await import("./vault");284+    await v3.applyStorageHardening(false);285+    expect(session.peach_session_dk).toBeUndefined();286+    expect(await v3.restoreFromSession()).toBe(false);287+    expect(v3.isUnlocked()).toBe(false);288+  });289+260290   it("deduplicates simultaneous restores and runs the unlock hook once", async () => {261291     const v1 = await import("./vault");262292     await v1.createVault("strong-pass-abc123");

view file

src/background/vault.ts+20 −1
@@ -1506,8 +1506,26 @@15061506 // Firefox does not implement it (persistent background script, no restart risk).15071507 const SESSION_KEY_STORAGE = "peach_session_dk";15081508 1509+// PB-26-203: the session-key escrow may only operate where startup positively1510+// established trusted-only session storage. Defaults to available so direct1511+// vault imports (tests, non-entry bootstraps) keep working; entry.ts applies1512+// the real hardening result before the first restore.1513+let sessionEscrowAvailable = true;1514+1515+/**1516+ * Apply the storage-hardening result at startup. When the trusted-only1517+ * session state cannot be established, any stored session key is removed and1518+ * the escrow stays off: the user unlocks again after a worker restart1519+ * instead of trusting an unproven boundary.1520+ */1521+export async function applyStorageHardening(sessionEscrow: boolean): Promise<void> {1522+  if (sessionEscrow) return;1523+  sessionEscrowAvailable = false;1524+  await clearSessionKey();1525+}1526+15091527 async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1510-  if (!chrome.storage.session) return;1528+  if (!chrome.storage.session || !sessionEscrowAvailable) return;15111529   await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });15121530 }15131531 @@ -1551,6 +1569,7 @@15511569   const epoch = vaultEpoch;15521570   const stale = () => epoch !== vaultEpoch;15531571   if (derivedKey !== null) return true;1572+  if (!sessionEscrowAvailable) return false;15541573   if (!chrome.storage.session) return false;15551574   const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);15561575   if (stale()) return false;

view file

src/content/index.ts+22 −22
@@ -1270,35 +1270,35 @@12701270 // the page's own credentials.get transparently and the popup approval view12711271 // already surfaces it. A banner here would just be noise.12721272 //1273-// Dedupe across navigation: each navigation re-injects this content script.1274-// chrome.storage.session keyed by domain is the natural per-tab/per-session1275-// memory; session storage clears when the user closes the browser.1273+// Dedupe across navigation: the "already shown" flag lives in trusted1274+// background session storage behind MARK_PASSKEY_BANNER_SHOWN — content1275+// scripts must not touch chrome.storage.session directly (PB-26-203), since1276+// that API has to stay inaccessible to hostile-page-adjacent contexts.12761277 {12771278   function checkPasskeyAvailability() {12781279     const domain = location.hostname.replace(/^www\./, "");12791280     if (!domain) return;1280-    const sessionKey = `peach_state11_shown:${domain}`;1281-    chrome.storage.session?.get(sessionKey).then((stored) => {1282-      if (stored && (stored as Record<string, unknown>)[sessionKey]) return;1281+    chrome.runtime.sendMessage({1282+      type: "GET_PASSKEY_FOR_DOMAIN",1283+      payload: { domain },1284+    }).then((res: { ok: boolean; data?: { rpId: string; userName: string; browser?: "chrome" | "firefox"; bannerShown?: boolean } | null } | undefined) => {1285+      const data = res?.data;1286+      if (!data || data.bannerShown) return;1287+      const current = detectBrowser();1288+      const isCrossBrowser =1289+        data.browser && current !== "unknown" && data.browser !== current;1290+      const valuableOnFirefox = current === "firefox";1291+      if (!isCrossBrowser && !valuableOnFirefox) return;12831292       chrome.runtime.sendMessage({1284-        type: "GET_PASSKEY_FOR_DOMAIN",1293+        type: "MARK_PASSKEY_BANNER_SHOWN",12851294         payload: { domain },1286-      }).then((res: { ok: boolean; data?: { rpId: string; userName: string; browser?: "chrome" | "firefox" } | null } | undefined) => {1287-        const data = res?.data;1288-        if (!data) return;1289-        const current = detectBrowser();1290-        const isCrossBrowser =1291-          data.browser && current !== "unknown" && data.browser !== current;1292-        const valuableOnFirefox = current === "firefox";1293-        if (!isCrossBrowser && !valuableOnFirefox) return;1294-        chrome.storage.session?.set({ [sessionKey]: Date.now() }).catch(() => {});1295-        showPasskeyAvailable({1296-          rpId: data.rpId,1297-          userName: data.userName,1298-          fromBrowser: data.browser,1299-          currentBrowser: current,1300-        });13011295       }).catch(() => {});1296+      showPasskeyAvailable({1297+        rpId: data.rpId,1298+        userName: data.userName,1299+        fromBrowser: data.browser,1300+        currentBrowser: current,1301+      });13021302     }).catch(() => {});13031303   }13041304 

view file

src/shared/types.ts+1 −0
@@ -281,6 +281,7 @@281281   | "PASSKEY_OFFER_DECLINE"282282   | "GET_PASSKEY_OFFER_DECLINED"283283   | "GET_PASSKEY_FOR_DOMAIN"284+  | "MARK_PASSKEY_BANNER_SHOWN"284285   // Firefox content-script proxy path (v2.12.0). Sent FROM the page-world285286   // monkey-patch through the extension-world relay TO the background. See286287   // src/shared/passkey-flags.ts for removal conditions.

view file