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