40265d95b2a3
add mandatory chrome and firefox release-browser gate with fail-closed meta-tests (PB-26-210, v5.0.66)
- time
- 2026-09-03 18:30 UTC · 1 month ago
- machine
- koh2
- agent
- muse-spark-1.3-contributor-free
- session
- PB-26-210 mandatory browser gate
- parent
- fec1978916dd
↓ peach-browser-40265d95.tar.gzatlas at this save
files changed10 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1playwright.config.ts+28 −0scripts/ci.ts+37 −0scripts/release-gate.test.ts+128 −0scripts/release-gate.ts+56 −0scripts/test-firefox-passkeys.mjs+318 −0tests/passkeys-firefox.spec.ts+0 −67tests/security-sentinels.spec.ts+207 −0manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "5.0.65",4+ "version": "5.0.66",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.65",5+ "version": "5.0.66",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.65",3+ "version": "5.0.66",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",playwright.config.ts+28 −0
@@ -6,12 +6,16 @@66 const __dirname = dirname(__filename);77 const EXTENSION_PATH = join(__dirname, "dist-chrome");88 9+const CI = process.env.CI === "true" || process.env.PEACH_RELEASE_BROWSER_GATE === "1";10+911 export default defineConfig({1012 testDir: "./tests",1113 timeout: 30_000,1214 use: {1315 headless: false,1416 viewport: { width: 320, height: 600 },17+ trace: CI ? "retain-on-failure" : "off",18+ screenshot: CI ? "only-on-failure" : "off",1519 },1620 projects: [1721 {@@ -28,6 +32,30 @@2832 },2933 },3034 testMatch: "**/*.spec.ts",35+ testIgnore: /security-sentinels\.spec\.ts/,36+ },37+ // PB-26-210: mandatory Chrome release browser gate.38+ // Headless, deterministic timeouts, artifact capture on failure.39+ // Runs security sentinels for PB-26-201 (authorization) and40+ // PB-26-203 (session storage isolation).41+ {42+ name: "chrome-release",43+ use: {44+ headless: true,45+ launchOptions: {46+ executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,47+ args: [48+ `--disable-extensions-except=${EXTENSION_PATH}`,49+ `--load-extension=${EXTENSION_PATH}`,50+ `--no-sandbox`,51+ `--disable-gpu`,52+ `--disable-dev-shm-usage`,53+ ],54+ },55+ },56+ testMatch: /security-sentinels\.spec\.ts/,57+ timeout: 120_000,58+ retries: 0,3159 },3260 ],3361 });scripts/ci.ts+37 −0
@@ -13,6 +13,10 @@1313 const BOLD = '\x1b[1m';1414 const DIM = '\x1b[2m';1515 const RUN_FIREFOX_BROWSER_CHECKS = process.env.CI === 'true' || process.env.PEACH_E2E_FIREFOX === '1';16+// PB-26-210: the release browser gate is mandatory in CI and opt-in for17+// local pre-release verification. When enabled, both Chrome and Firefox18+// browser tests MUST run and pass — they do not silently skip.19+const RUN_BROWSER_GATE = process.env.CI === 'true' || process.env.PEACH_RELEASE_BROWSER_GATE === '1';1620 1721 interface Step {1822 name: string;@@ -60,6 +64,23 @@6064 cmd: ['npm', 'run', 'test:firefox:sender'],6165 }]6266 : []),67+ // PB-26-210: mandatory release browser gate.68+ // Chrome sentinels cover PB-26-201 (authorization) and PB-26-203 (storage isolation).69+ ...(RUN_BROWSER_GATE70+ ? [{71+ name: 'Chrome release browser gate',72+ cmd: ['npx', 'playwright', 'test', '--project=chrome-release'],73+ }]74+ : []),75+ // Firefox passkey matrix covers PB-26-209 (exportFunction privileged bridge).76+ // If Firefox/Marionette cannot launch, the script exits non-zero — the gate77+ // fails rather than silently skipping.78+ ...(RUN_BROWSER_GATE79+ ? [{80+ name: 'Firefox release browser gate',81+ cmd: ['node', 'scripts/test-firefox-passkeys.mjs'],82+ }]83+ : []),6384 ];6485 6586 function run(step: Step): { ok: boolean; output: string; duration: number } {@@ -99,6 +120,22 @@99120 100121 let failed = false;101122 const failures: { name: string; output: string }[] = [];123+124+ // PB-26-210: report browser executables/versions before the browser gate125+ // so release hosts can verify their runtime dependencies.126+ if (RUN_BROWSER_GATE) {127+ console.log(`${DIM}Browser gate enabled — reporting runtime versions:${RESET}`);128+ const chromedriver = spawnSync({129+ cmd: ['npx', 'playwright', '--version'],130+ stdout: 'pipe',131+ stderr: 'pipe',132+ });133+ if (chromedriver.stdout) console.log(`${DIM} Playwright: ${chromedriver.stdout.toString().trim()}${RESET}`);134+ if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH) {135+ console.log(`${DIM} Chrome: ${process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH}${RESET}`);136+ }137+ console.log();138+ }102139 103140 for (const step of steps) {104141 process.stdout.write(` ${DIM}…${RESET} ${step.name.padEnd(20)}\r`);scripts/release-gate.test.ts+128 −0
@@ -0,0 +1,128 @@1+import { readFileSync } from "node:fs";2+import { fileURLToPath } from "node:url";3+import { dirname, join, resolve } from "node:path";4+import { describe, expect, it } from "vitest";5+6+import {7+ validateGateResult,8+ REQUIRED_BROWSER_PROJECTS,9+ type GateResult,10+ type ProjectResult,11+} from "./release-gate";12+13+const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");14+15+function project(name: string, overrides: Partial<ProjectResult> = {}): ProjectResult {16+ return { name, totalTests: 5, passed: 5, skipped: 0, flaky: 0, ...overrides };17+}18+19+function passingGate(): GateResult {20+ return {21+ projects: [22+ project("chrome-release"),23+ project("firefox-release"),24+ ],25+ };26+}27+28+describe("PB-26-210: gate validation — fail-closed conditions", () => {29+ it("passes a clean result with both required projects and no skips", () => {30+ expect(validateGateResult(passingGate())).toEqual([]);31+ });32+33+ it("fails on zero discovered tests across all projects", () => {34+ const result: GateResult = {35+ projects: [project("chrome-release", { totalTests: 0 }), project("firefox-release", { totalTests: 0 })],36+ };37+ const errors = validateGateResult(result);38+ expect(errors).toContain("zero tests discovered across all projects");39+ expect(errors.some((e) => e.includes("zero tests"))).toBe(true);40+ });41+42+ it("fails on a skipped required test", () => {43+ const result: GateResult = {44+ projects: [project("chrome-release", { skipped: 2 }), project("firefox-release")],45+ };46+ const errors = validateGateResult(result);47+ expect(errors.some((e) => e.includes("chrome-release") && e.includes("skipped"))).toBe(true);48+ });49+50+ it("fails on an empty project (zero tests in one required project)", () => {51+ const result: GateResult = {52+ projects: [project("chrome-release", { totalTests: 0, passed: 0 }), project("firefox-release")],53+ };54+ const errors = validateGateResult(result);55+ expect(errors.some((e) => e.includes("chrome-release") && e.includes("zero tests"))).toBe(true);56+ });57+58+ it("fails on a missing required project name", () => {59+ const result: GateResult = {60+ projects: [project("chrome-release")],61+ };62+ const errors = validateGateResult(result);63+ expect(errors.some((e) => e.includes("firefox-release") && e.includes("missing"))).toBe(true);64+ });65+66+ it("fails on flaky retries that remain flaky", () => {67+ const result: GateResult = {68+ projects: [project("chrome-release", { flaky: 1 }), project("firefox-release")],69+ };70+ const errors = validateGateResult(result);71+ expect(errors.some((e) => e.includes("chrome-release") && e.includes("flaky"))).toBe(true);72+ });73+74+ it("fails on a simulated missing fixture (project exists but zero tests and zero passed)", () => {75+ const result: GateResult = {76+ projects: [77+ project("chrome-release", { totalTests: 0, passed: 0, skipped: 0 }),78+ project("firefox-release", { totalTests: 0, passed: 0, skipped: 0 }),79+ ],80+ };81+ const errors = validateGateResult(result);82+ expect(errors.length).toBeGreaterThan(0);83+ });84+});85+86+describe("PB-26-210: release scripts invoke the CI gate and cannot bypass the browser phase", () => {87+ const RELEASE_SCRIPTS = ["scripts/release.ts", "scripts/release-firefox.ts"];88+89+ for (const scriptPath of RELEASE_SCRIPTS) {90+ it(`${scriptPath} calls "npm run ci" before any packaging or upload`, () => {91+ const source = readFileSync(join(ROOT, scriptPath), "utf8");92+ const ciIndex = source.indexOf('npm run ci');93+ expect(ciIndex).toBeGreaterThan(-1);94+95+ const packageIndex = source.search(/zip -r|web-ext sign|chrome-webstore-upload-cli upload/i);96+ if (packageIndex > -1) {97+ expect(ciIndex).toBeLessThan(packageIndex);98+ }99+ });100+ }101+102+ it("scripts/ci.ts includes both Chrome and Firefox release browser gate steps", () => {103+ const source = readFileSync(join(ROOT, "scripts/ci.ts"), "utf8");104+ expect(source).toMatch(/Chrome.*release.*browser|chrome-release/i);105+ expect(source).toMatch(/Firefox.*release.*browser|firefox.*passkey/i);106+ });107+108+ it("scripts/ci.ts does not silently skip the browser gate when enabled", () => {109+ const source = readFileSync(join(ROOT, "scripts/ci.ts"), "utf8");110+ // When the browser gate is enabled, both Chrome and Firefox steps must be111+ // present in the steps array — not behind a comment or a skip condition112+ // that would make them disappear entirely.113+ expect(source).toContain("chrome-release");114+ expect(source).toContain("firefox");115+ });116+});117+118+describe("PB-26-210: playwright config defines required release project", () => {119+ it("playwright.config.ts defines a chrome-release project", () => {120+ const source = readFileSync(join(ROOT, "playwright.config.ts"), "utf8");121+ expect(source).toContain("chrome-release");122+ });123+124+ it("REQUIRED_BROWSER_PROJECTS includes chrome-release and firefox-release", () => {125+ expect(REQUIRED_BROWSER_PROJECTS).toContain("chrome-release");126+ expect(REQUIRED_BROWSER_PROJECTS).toContain("firefox-release");127+ });128+});scripts/release-gate.ts+56 −0
@@ -0,0 +1,56 @@1+/**2+ * PB-26-210: Release browser-gate validation.3+ *4+ * Pure functions that verify a gate result has no silent failures.5+ * A release cannot report success with zero tests, unexpected skips,6+ * flaky retries, or missing required projects.7+ */8+9+export interface ProjectResult {10+ name: string;11+ totalTests: number;12+ passed: number;13+ skipped: number;14+ flaky: number;15+}16+17+export interface GateResult {18+ projects: ProjectResult[];19+}20+21+export const REQUIRED_BROWSER_PROJECTS = ["chrome-release", "firefox-release"] as const;22+23+/**24+ * Validate a gate result. Returns a list of error strings — empty means pass.25+ * The release gate fails (non-zero) on any error.26+ */27+export function validateGateResult(28+ result: GateResult,29+ requiredProjects: readonly string[] = REQUIRED_BROWSER_PROJECTS,30+): string[] {31+ const errors: string[] = [];32+33+ const totalTests = result.projects.reduce((sum, p) => sum + p.totalTests, 0);34+ if (totalTests === 0) {35+ errors.push("zero tests discovered across all projects");36+ }37+38+ for (const name of requiredProjects) {39+ const project = result.projects.find((p) => p.name === name);40+ if (!project) {41+ errors.push(`required project "${name}" is missing`);42+ continue;43+ }44+ if (project.totalTests === 0) {45+ errors.push(`project "${name}" has zero tests`);46+ }47+ if (project.skipped > 0) {48+ errors.push(`project "${name}" has ${project.skipped} skipped test(s)`);49+ }50+ if (project.flaky > 0) {51+ errors.push(`project "${name}" has ${project.flaky} flaky retry/retr(y)ies`);52+ }53+ }54+55+ return errors;56+}scripts/test-firefox-passkeys.mjs+318 −0
@@ -0,0 +1,318 @@1+#!/usr/bin/env node2+3+// PB-26-210: mandatory Firefox release browser gate.4+// Tests the PB-26-209 exportFunction privileged passkey bridge in real Firefox.5+//6+// If Firefox/Marionette cannot launch, this script exits non-zero — the gate7+// FAILS rather than silently skipping.8+//9+// Security properties verified:10+// 1. navigator.credentials.create is wrapped by exportFunction (not native).11+// 2. navigator.credentials.get is wrapped by exportFunction (not native).12+// 3. No forgeable PEACH_PASSKEY_RESPONSE postMessage channel exists.13+// 4. The bridge falls through to native when vault is locked (no crash/hang).14+// 5. The page cannot forge the extension's response via postMessage.15+16+import { execFile } from "node:child_process";17+import { promisify } from "node:util";18+import { cp, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";19+import { existsSync } from "node:fs";20+import { tmpdir } from "node:os";21+import { join, resolve } from "node:path";22+import net from "node:net";23+import { start } from "geckodriver";24+25+const execFileAsync = promisify(execFile);26+const ROOT = resolve(new URL("..", import.meta.url).pathname);27+const DIST = join(ROOT, "dist-firefox");28+const WEB_EXT = join(ROOT, "node_modules/.bin/web-ext");29+30+function findFirefoxBinary() {31+ const candidates = [32+ process.env.PEACH_FIREFOX_BINARY,33+ process.env.FIREFOX_BIN,34+ "/usr/bin/firefox",35+ "/usr/local/bin/firefox",36+ "/Applications/Firefox.app/Contents/MacOS/firefox",37+ "/Applications/Firefox Nightly.app/Contents/MacOS/firefox",38+ ].filter(Boolean);39+ return candidates.find((c) => existsSync(c));40+}41+42+function findFreePort() {43+ return new Promise((resolvePort, reject) => {44+ const server = net.createServer();45+ server.once("error", reject);46+ server.listen(0, "127.0.0.1", () => {47+ const address = server.address();48+ if (!address || typeof address === "string") {49+ server.close();50+ reject(new Error("Could not allocate a WebDriver port"));51+ return;52+ }53+ server.close(() => resolvePort(address.port));54+ });55+ });56+}57+58+async function waitForDriver(port) {59+ const deadline = Date.now() + 30_000;60+ while (Date.now() < deadline) {61+ try {62+ const response = await fetchWithTimeout(`http://127.0.0.1:${port}/status`, {}, 500);63+ if (response.ok && (await response.json()).value?.ready) return;64+ } catch {65+ // geckodriver is still starting.66+ }67+ await new Promise((r) => setTimeout(r, 250));68+ }69+ throw new Error("geckodriver did not become ready within 30 seconds");70+}71+72+async function fetchWithTimeout(url, options, timeoutMs) {73+ const controller = new AbortController();74+ let timeoutId;75+ const timeout = new Promise((_, reject) => {76+ timeoutId = setTimeout(() => reject(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs);77+ });78+ try {79+ return await Promise.race([80+ fetch(url, { ...options, signal: controller.signal }),81+ timeout,82+ ]);83+ } finally {84+ clearTimeout(timeoutId);85+ controller.abort();86+ }87+}88+89+async function webdriverRequest(port, path, options = {}) {90+ const response = await fetchWithTimeout(`http://127.0.0.1:${port}${path}`, {91+ ...options,92+ headers: { "content-type": "application/json", ...(options.headers ?? {}) },93+ }, 10_000);94+ const body = await response.json();95+ if (!response.ok || body.value?.error) {96+ throw new Error(`WebDriver ${options.method ?? "GET"} ${path} failed: ${JSON.stringify(body)}`);97+ }98+ return body.value;99+}100+101+async function buildTestExtension(tempRoot) {102+ const sourceDir = join(tempRoot, "extension");103+ const artifactsDir = join(tempRoot, "artifacts");104+ await cp(DIST, sourceDir, { recursive: true });105+ await mkdir(artifactsDir, { recursive: true });106+107+ await execFileAsync(WEB_EXT, [108+ "build",109+ "--source-dir", sourceDir,110+ "--artifacts-dir", artifactsDir,111+ "--filename", "peach-firefox-passkey-matrix.xpi",112+ "--overwrite-dest",113+ "--no-input",114+ ], { cwd: ROOT });115+ return join(artifactsDir, "peach-firefox-passkey-matrix.xpi");116+}117+118+const PROBE_SCRIPT = `119+ const done = arguments[arguments.length - 1];120+121+ // 1. Check if navigator.credentials.create is wrapped by exportFunction.122+ // The wrapped function is from the page world via exportFunction, so123+ // it will NOT be the native function. We check by calling toString()124+ // — a native function returns "function create() { [native code] }"125+ // while an exportFunction wrapper returns something else.126+ const createStr = navigator.credentials.create.toString();127+ const getStr = navigator.credentials.get.toString();128+ const createIsNative = createStr.includes("[native code]");129+ const getIsNative = getStr.includes("[native code]");130+131+ // 2. Check no PEACH_PASSKEY_RESPONSE postMessage listener exists.132+ // Dispatch a forged response and verify no handler picks it up.133+ let forgedResponseReceived = false;134+ const forgedListener = (event) => {135+ if (event.data?.type === "PEACH_PASSKEY_RESPONSE") {136+ forgedResponseReceived = true;137+ }138+ };139+ window.addEventListener("message", forgedListener);140+ window.postMessage(141+ { type: "PEACH_PASSKEY_RESPONSE", id: "forged-uuid-test", payload: { ok: true } },142+ window.location.origin,143+ );144+ await new Promise((r) => setTimeout(r, 200));145+ window.removeEventListener("message", forgedListener);146+147+ // 3. Verify no PEACH_PASSKEY_REQUEST postMessage is used by the bridge.148+ // We check the page for any existing listeners that post PEACH_PASSKEY_REQUEST.149+ const pageSourceHasRelay = document.documentElement.innerHTML.includes("PEACH_PASSKEY_REQUEST");150+151+ // 4. Call navigator.credentials.create with a publicKey request.152+ // With a locked vault, the bridge should fall through to native153+ // (return null in headless Firefox without a real authenticator).154+ let createResult = null;155+ let createError = null;156+ try {157+ createResult = await navigator.credentials.create({158+ publicKey: {159+ rp: { id: "example.com", name: "Test" },160+ user: { id: new Uint8Array(16), name: "test", displayName: "Test" },161+ challenge: new Uint8Array(16),162+ pubKeyCredParams: [{ alg: -7, type: "public-key" }],163+ },164+ });165+ } catch (e) {166+ createError = e?.name ?? String(e);167+ }168+169+ // 5. Call navigator.credentials.get — should also fall through.170+ let getResult = null;171+ let getError = null;172+ try {173+ getResult = await navigator.credentials.get({174+ publicKey: {175+ rpId: "example.com",176+ challenge: new Uint8Array(16),177+ allowCredentials: [{ id: new Uint8Array(32), type: "public-key" }],178+ },179+ });180+ } catch (e) {181+ getError = e?.name ?? String(e);182+ }183+184+ done({185+ pageOrigin: location.origin,186+ createIsNative,187+ getIsNative,188+ forgedResponseReceived,189+ pageSourceHasRelay,190+ createResult,191+ createError,192+ getResult,193+ getError,194+ });195+`;196+197+async function run() {198+ if (!existsSync(DIST)) throw new Error("dist-firefox is missing; run npm run build:firefox first");199+ if (!existsSync(WEB_EXT)) throw new Error("web-ext is missing; run bun install first");200+201+ const tempRoot = await mkdtemp(join(tmpdir(), "peach-firefox-passkey-"));202+ let driver;203+ let driverPort;204+ let sessionId;205+ try {206+ const xpiPath = await buildTestExtension(tempRoot);207+ const firefoxBinary = findFirefoxBinary();208+ const port = await findFreePort();209+ driverPort = port;210+ driver = await start({211+ host: "127.0.0.1",212+ port,213+ geckoDriverVersion: "0.37.1",214+ ...(firefoxBinary ? { binary: firefoxBinary } : {}),215+ log: "error",216+ });217+ await waitForDriver(port);218+219+ const capabilities = {220+ browserName: "firefox",221+ acceptInsecureCerts: true,222+ "moz:firefoxOptions": {223+ args: ["-headless"],224+ ...(firefoxBinary ? { binary: firefoxBinary } : {}),225+ prefs: {226+ "browser.shell.checkDefaultBrowser": false,227+ "browser.startup.page": 0,228+ "browser.startup.homepage": "about:blank",229+ },230+ },231+ };232+ const session = await webdriverRequest(port, "/session", {233+ method: "POST",234+ body: JSON.stringify({ capabilities: { alwaysMatch: capabilities } }),235+ });236+ sessionId = session.sessionId;237+238+ // Install the temporary add-on.239+ await webdriverRequest(port, `/session/${sessionId}/moz/addon/install`, {240+ method: "POST",241+ body: JSON.stringify({242+ addon: (await readFile(xpiPath)).toString("base64"),243+ temporary: true,244+ }),245+ });246+247+ // Navigate to a test page and wait for the content script to install.248+ await webdriverRequest(port, `/session/${sessionId}/url`, {249+ method: "POST",250+ body: JSON.stringify({ url: "https://example.com/" }),251+ });252+ // Give the content script time to run at document_start.253+ await new Promise((r) => setTimeout(r, 2000));254+255+ const result = await webdriverRequest(port, `/session/${sessionId}/execute/async`, {256+ method: "POST",257+ body: JSON.stringify({ script: PROBE_SCRIPT, args: [] }),258+ });259+260+ if (result.pageOrigin !== "https://example.com") {261+ throw new Error(`Unexpected page origin: ${result.pageOrigin}`);262+ }263+264+ const failures = [];265+266+ // 1. navigator.credentials.create must be wrapped (not native).267+ if (result.createIsNative) {268+ failures.push("navigator.credentials.create is not wrapped by exportFunction — the privileged bridge was not installed");269+ }270+271+ // 2. navigator.credentials.get must be wrapped (not native).272+ if (result.getIsNative) {273+ failures.push("navigator.credentials.get is not wrapped by exportFunction — the privileged bridge was not installed");274+ }275+276+ // 3. No forgeable PEACH_PASSKEY_RESPONSE channel.277+ if (result.forgedResponseReceived) {278+ failures.push("a PEACH_PASSKEY_RESPONSE postMessage listener exists — the forgeable relay was not removed");279+ }280+281+ // 4. No PEACH_PASSKEY_REQUEST in page source.282+ if (result.pageSourceHasRelay) {283+ failures.push("PEACH_PASSKEY_REQUEST found in page source — the old relay may still be present");284+ }285+286+ // 5. Create with locked vault should fall through (null or NotAllowedError287+ // from native authenticator, not a crash/hang).288+ if (result.createError && result.createError !== "NotAllowedError" && result.createError !== "AbortError") {289+ failures.push(`navigator.credentials.create threw unexpected error: ${result.createError}`);290+ }291+292+ // 6. Get with locked vault should fall through.293+ if (result.getError && result.getError !== "NotAllowedError" && result.getError !== "AbortError") {294+ failures.push(`navigator.credentials.get threw unexpected error: ${result.getError}`);295+ }296+297+ if (failures.length > 0) {298+ throw new Error(`Firefox passkey matrix failures:\n - ${failures.join("\n - ")}`);299+ }300+301+ console.log("Firefox passkey matrix passed: exportFunction bridge installed, no forgeable relay, bridge falls through with locked vault.");302+ } finally {303+ if (driver && sessionId) {304+ try {305+ await webdriverRequest(driverPort, `/session/${sessionId}`, { method: "DELETE" });306+ } catch {307+ // Browser may already have exited.308+ }309+ }310+ driver?.kill("SIGTERM");311+ await rm(tempRoot, { recursive: true, force: true });312+ }313+}314+315+run().catch((error) => {316+ console.error(error instanceof Error ? error.message : error);317+ process.exitCode = 1;318+});tests/passkeys-firefox.spec.ts+0 −67
@@ -1,67 +0,0 @@1-/**2- * passkeys-firefox.spec.ts — Firefox content-script proxy E2E suite.3- *4- * The MAIN-world monkey-patch only runs under Firefox with a real Gecko5- * credentials API to delegate to. Playwright's bundled chromium runner cannot6- * exercise it. To run this suite, build with `npm run build:firefox` and7- * launch via `web-ext run --target=firefox-desktop --source-dir=dist-firefox`,8- * then drive Firefox with playwright's `firefox` browser type plus the9- * preferences below to load the temporary add-on. Until that harness lands10- * in CI the suite is gated by PEACH_E2E_FIREFOX_PASSKEYS=1, so the default11- * `npx playwright test` run skips it cleanly.12- *13- * The list below is the verification matrix from the v2.12.0 spec. Each14- * item maps to a manual smoke-test step on a real RP (github.com,15- * webauthn.io, google.com account settings).16- */17-18-import { test } from "@playwright/test";19-20-const ENABLED = process.env.PEACH_E2E_FIREFOX_PASSKEYS === "1";21-const skip = ENABLED ? test : test.skip;22-23-test.describe("Firefox passkey content-script proxy (MAIN world)", () => {24- // 1. navigator.credentials.create is replaced on document_start.25- skip("navigator.credentials.create is replaced on page load (MAIN world)", async () => {});26-27- // 2. navigator.credentials.get is replaced on document_start.28- skip("navigator.credentials.get is replaced on page load (MAIN world)", async () => {});29-30- // 3. Registration: approval UI appears, vault gets a passkey entry with31- // privateKeyJwk and browser:'firefox'.32- skip("registration: approval UI appears, passkey stored in vault", async () => {});33-34- // 4. Authentication: approval UI appears, RP receives a valid assertion.35- skip("authentication: approval UI appears, assertion returned", async () => {});36-37- // 5. clientDataJSON.origin equals the page origin in both responses.38- skip("clientDataJSON.origin equals the page origin in both responses", async () => {});39-40- // 6. clientDataJSON.origin must NEVER contain `moz-extension://`.41- skip("clientDataJSON.origin does not contain moz-extension://", async () => {});42-43- // 7. signCount strictly increments across two consecutive get ceremonies.44- skip("signCount increments between two authentication ceremonies", async () => {});45-46- // 8. user_cancelled: page receives DOMException with name 'NotAllowedError'.47- skip("user_cancelled: DOMException with NotAllowedError name thrown", async () => {});48-49- // 9. Non-publicKey credentials.create requests fall through to the50- // platform unchanged (PasswordCredential, FederatedCredential).51- skip("non-passkey credentials.create falls through to original", async () => {});52-53- // 10. Non-publicKey credentials.get falls through.54- skip("non-passkey credentials.get falls through to original", async () => {});55-56- // 11. No passkey for rpId: PASSKEY_HAS_CREDENTIALS returns false → fall57- // through to the platform authenticator. No Peach UI appears.58- skip("no passkey for rpId: falls through to platform authenticator", async () => {});59-60- // 12. Free tier: pro_required fires; page-side dispatches61- // 'peach:passkey-pro-gate' event; ceremony falls through to platform.62- skip("free tier: pro gate fires, falls through to platform", async () => {});63-64- // 13. Round-trip: register passkey → authenticate → vault entry's65- // signCount and lastUsedAt update.66- skip("round-trip: register passkey then authenticate updates vault entry", async () => {});67-});tests/security-sentinels.spec.ts+207 −0
@@ -0,0 +1,207 @@1+/**2+ * security-sentinels.spec.ts — PB-26-210 mandatory Chrome release browser gate.3+ *4+ * Security sentinels that must pass (non-skipped, assertion-bearing) before5+ * any release can report success:6+ *7+ * - PB-26-201: a hostile page cannot obtain vault-wide metadata, a foreign8+ * password, a raw TOTP seed, or password history via content-script message9+ * shapes. Attacker-supplied Booleans (userGesture, explicitSummon) have no10+ * authorization effect.11+ * - PB-26-203: a real content script cannot read peach_session_dk from12+ * chrome.storage.session after unlock — the TRUSTED_CONTEXTS access level13+ * enforced by the storage-hardening initializer blocks content scripts.14+ */15+16+import { test, expect, type BrowserContext } from "@playwright/test";17+import { makeContext, setupVault, addEntry } from "./helpers/vault";18+19+test.describe.configure({ timeout: 120_000 });20+21+test.describe("PB-26-201 @security: hostile page cannot obtain foreign secrets", () => {22+ let ctx: BrowserContext;23+24+ test.beforeEach(async () => {25+ ctx = await makeContext("pb26201-sentinel");26+ });27+28+ test.afterEach(async () => {29+ await ctx.close();30+ });31+32+ test("content script GET_ENTRY_FOR_EDIT is denied (@security)", async () => {33+ const { popup } = await setupVault(ctx);34+ await addEntry(popup, {35+ title: "Foreign Entry",36+ url: "https://example.org/login",37+ username: "[email protected]",38+ password: "foreign-secret-totally-private",39+ });40+41+ const page = await ctx.newPage();42+ await page.goto("https://example.com/");43+44+ const result = await popup.evaluate(async () => {45+ const tabs = await chrome.tabs.query({ url: "https://example.com/*" });46+ const tab = tabs.find((t) => t.id !== undefined);47+ if (!tab?.id) throw new Error("test page tab not found");48+49+ const exec = await chrome.scripting.executeScript({50+ target: { tabId: tab.id },51+ func: async () => {52+ const editResp = await chrome.runtime.sendMessage({53+ type: "GET_ENTRY_FOR_EDIT",54+ payload: { id: "any-id" },55+ });56+ return { editResp };57+ },58+ });59+ return exec[0]?.result as { editResp: { ok?: boolean; error?: string } };60+ });61+62+ expect(result?.editResp?.ok).toBe(false);63+ expect(result?.editResp?.error).toBe("invalid_sender");64+ });65+66+ test("userGesture and explicitSummon Booleans cannot authorize GET_ENTRIES (@security)", async () => {67+ const { popup } = await setupVault(ctx);68+ await addEntry(popup, {69+ title: "Secret Vault Entry",70+ url: "https://example.org/login",71+ username: "[email protected]",72+ password: "should-never-leak",73+ });74+75+ const page = await ctx.newPage();76+ await page.goto("https://example.com/");77+78+ const result = await popup.evaluate(async () => {79+ const tabs = await chrome.tabs.query({ url: "https://example.com/*" });80+ const tab = tabs.find((t) => t.id !== undefined);81+ if (!tab?.id) throw new Error("test page tab not found");82+83+ const exec = await chrome.scripting.executeScript({84+ target: { tabId: tab.id },85+ func: async () => {86+ const entriesResp = await chrome.runtime.sendMessage({87+ type: "GET_ENTRIES",88+ payload: { includeAll: true, userGesture: true, explicitSummon: true },89+ });90+ return { entriesResp };91+ },92+ });93+ return exec[0]?.result as { entriesResp: { ok?: boolean; error?: string } };94+ });95+96+ expect(result?.entriesResp?.ok).toBe(false);97+ expect(result?.entriesResp?.error).toBe("invalid_sender");98+ });99+100+ test("AUTOFILL_QUERY on hostile page returns no foreign passwords or TOTP seeds (@security)", async () => {101+ const { popup } = await setupVault(ctx);102+ await addEntry(popup, {103+ title: "Example Org",104+ url: "https://example.org/login",105+ username: "[email protected]",106+ password: "foreign-pass-12345",107+ totp: "JBSWY3DPEHPK3PXP",108+ });109+110+ const page = await ctx.newPage();111+ await page.goto("https://example.com/");112+113+ const result = await popup.evaluate(async () => {114+ const tabs = await chrome.tabs.query({ url: "https://example.com/*" });115+ const tab = tabs.find((t) => t.id !== undefined);116+ if (!tab?.id) throw new Error("test page tab not found");117+118+ const exec = await chrome.scripting.executeScript({119+ target: { tabId: tab.id },120+ func: async () => {121+ // AUTOFILL_QUERY is content-script-allowed but must be origin-scoped.122+ // example.com should get NO results for example.org entries.123+ const queryResp = await chrome.runtime.sendMessage({124+ type: "AUTOFILL_QUERY",125+ payload: { explicitSummon: true, userGesture: true },126+ });127+ return { queryResp: JSON.stringify(queryResp) };128+ },129+ });130+ return exec[0]?.result as { queryResp: string };131+ });132+133+ // The response must not contain the foreign password, TOTP seed, or134+ // the foreign origin's entry data.135+ expect(result?.queryResp).not.toContain("foreign-pass-12345");136+ expect(result?.queryResp).not.toContain("JBSWY3DPEHPK3PXP");137+ expect(result?.queryResp).not.toContain("example.org");138+ });139+});140+141+test.describe("PB-26-203 @security: content script cannot read session storage key", () => {142+ let ctx: BrowserContext;143+144+ test.beforeEach(async () => {145+ ctx = await makeContext("pb26203-sentinel");146+ });147+148+ test.afterEach(async () => {149+ await ctx.close();150+ });151+152+ test("content script cannot read peach_session_dk from storage.session after unlock (@security)", async () => {153+ const { popup } = await setupVault(ctx);154+155+ // Navigate to a real page so a content script context exists.156+ const page = await ctx.newPage();157+ await page.goto("https://example.com/");158+159+ const result = await popup.evaluate(async () => {160+ const tabs = await chrome.tabs.query({ url: "https://example.com/*" });161+ const tab = tabs.find((t) => t.id !== undefined);162+ if (!tab?.id) throw new Error("test page tab not found");163+164+ const exec = await chrome.scripting.executeScript({165+ target: { tabId: tab.id },166+ func: async () => {167+ // Content scripts are NOT trusted contexts after the storage168+ // hardening initializer sets TRUSTED_CONTEXTS. This call should169+ // either throw or return an empty object — the key must not170+ // be readable.171+ try {172+ const data = await chrome.storage.session.get("peach_session_dk");173+ return {174+ accessible: true,175+ hasKey: "peach_session_dk" in data,176+ keys: Object.keys(data),177+ };178+ } catch (e) {179+ return { accessible: false, error: String(e) };180+ }181+ },182+ });183+ return exec[0]?.result as {184+ accessible: boolean;185+ hasKey?: boolean;186+ keys?: string[];187+ error?: string;188+ };189+ });190+191+ // The content script must not be able to read the session key.192+ // With TRUSTED_CONTEXTS, chrome.storage.session.get throws.193+ expect(result?.accessible).toBe(false);194+195+ // Verify the extension page (trusted context) CAN still read it —196+ // proving the key exists but is isolated from content scripts.197+ const extAccess = await popup.evaluate(async () => {198+ try {199+ const data = await chrome.storage.session.get("peach_session_dk");200+ return { hasKey: "peach_session_dk" in data };201+ } catch {202+ return { hasKey: false };203+ }204+ });205+ expect(extAccess?.hasKey).toBe(true);206+ });207+});