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

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

view file

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

view file

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

view file

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

view file

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

view file

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

view file

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

view file at the parent

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

view file