From fa9e2d9665a967614220ec9f425c116f04951d68 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:57:10 -0400 Subject: [PATCH] Automated passkey e2e: real Chromium, real WebAuthn ceremonies, virtual PRF Discharges most of the 'live-fire passkey on a real device' TODO without hardware. Playwright drives headless Chromium with a CDP virtual authenticator (hasPrf: true) against the built demo served locally and pointed at three in-process mock relays, so the test is hermetic -- no public relays, no platform passkey provider, no network. Covered end to end: navigator.credentials.create, PRF extension results crossing the browser boundary, worker-side derivation, account registration (asserted at the PROTOCOL layer -- kind-30078 capsules really land on the relays, not just a UI state), the Tier B2 phrase card appearing for a first session and not a returning one, window.bitlogin.activeSession() reporting, logout, then navigator.credentials.get signing back in to the SAME npub. A second test proves PRF is per-credential: a different passkey derives a different account. Two things the harness taught us, both now encoded: WebAuthn rejects a bare 127.0.0.1 as an RP id (localhost only), and the options menu's disclosure state survives re-renders by design, so the driver opens it conditionally. Runs inside npm test and skips itself when the Playwright browser isn't installed, so CI and other machines stay green. MockRelay gains storedEventKinds() for protocol-level assertions. Remaining real-device gap is recorded in TODO: a genuine platform authenticator and the create-time-PRF-absent fallback, which the virtual authenticator does not exercise. 268 tests green. Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + docs/TODO.md | 15 +- package-lock.json | 48 ++++ package.json | 2 + packages/core/src/test-support/mockRelay.ts | 6 + packages/widget/src/e2e/passkey.e2e.test.ts | 252 ++++++++++++++++++++ packages/widget/tsconfig.json | 4 +- 7 files changed, 324 insertions(+), 4 deletions(-) create mode 100644 packages/widget/src/e2e/passkey.e2e.test.ts diff --git a/.gitignore b/.gitignore index 2f18fb0..12faa64 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist-types/ .DS_Store .vercel .env* +.e2e-*.png diff --git a/docs/TODO.md b/docs/TODO.md index 1b2092c..d027f03 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -62,9 +62,18 @@ Owner action items: 1. **Live-fire the NIP-46 QR with Amber** on bitlogin.network (flow was verified against a fake bunker in tests, not yet against a real phone signer end-to-end). -2. **Live-fire passkey sign-in on a real device** (Chrome + Google Password - Manager, and once on iOS Safari). PRF cannot be exercised in CI; the - ceremony paths are untested against real authenticators. +2. **Live-fire passkey sign-in on a real device.** *Partly discharged + 2026-08-04:* `packages/widget/src/e2e/passkey.e2e.test.ts` drives real + headless Chromium through the real WebAuthn ceremonies with a CDP + virtual authenticator (`hasPrf: true`), against the built demo and + in-process mock relays — create passkey → register → dashboard npub → + capsules verified on the relays → logout → sign in with the same passkey + → same npub; plus a second passkey deriving a different account. Runs in + `npm test`, skips automatically without `npx playwright install + chromium`. What that still does NOT cover: a real platform authenticator + (Google Password Manager, iCloud Keychain, Bitwarden, a hardware key) + and the create-time-PRF-absent fallback path, which Chromium's virtual + authenticator does not exercise (it returns PRF results at creation). 3. **Passkey derivation is a new compatibility contract** — `packages/widget/src/passkey.test.ts` known-answer vectors carry the same never-update-the-expected-value rule as `knownAnswer.test.ts`. diff --git a/package-lock.json b/package-lock.json index 07a19e6..0946e03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "packages/demo" ], "devDependencies": { + "playwright": "^1.62.1", "typescript": "^5.7.3", "vite": "^6.1.0", "vitest": "^3.0.5" @@ -1541,6 +1542,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.25", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", diff --git a/package.json b/package.json index dd18c65..586d45f 100644 --- a/package.json +++ b/package.json @@ -15,12 +15,14 @@ "dev:demo": "npm run dev -w @bitlogin/demo", "test": "vitest run", "test:watch": "vitest", + "test:e2e": "vitest run packages/widget/src/e2e", "typecheck": "npm run typecheck -w @bitlogin/core && npm run typecheck -w @bitlogin/widget", "security:audit": "npm audit --audit-level=high && npm audit signatures", "security:semgrep": "semgrep scan --config .semgrep.yml --error --timeout 0 packages", "security:ci": "npm run build:demo && npm run typecheck && npm test && npm run security:audit && npm run security:semgrep" }, "devDependencies": { + "playwright": "^1.62.1", "typescript": "^5.7.3", "vite": "^6.1.0", "vitest": "^3.0.5" diff --git a/packages/core/src/test-support/mockRelay.ts b/packages/core/src/test-support/mockRelay.ts index 3bfbb56..f60561f 100644 --- a/packages/core/src/test-support/mockRelay.ts +++ b/packages/core/src/test-support/mockRelay.ts @@ -173,6 +173,12 @@ export class MockRelay { return results; } + /** Kinds of every event this relay accepted -- lets a test assert that a + * flow really reached the protocol layer, not just the UI. */ + storedEventKinds(): number[] { + return [...this.events.values(), ...this.plainEvents].map((event) => event.kind); + } + /** Simulates total data loss for this relay (§19.4, §29.3 test scenarios). */ wipeAllData(): void { this.events.clear(); diff --git a/packages/widget/src/e2e/passkey.e2e.test.ts b/packages/widget/src/e2e/passkey.e2e.test.ts new file mode 100644 index 0000000..e4a3138 --- /dev/null +++ b/packages/widget/src/e2e/passkey.e2e.test.ts @@ -0,0 +1,252 @@ +/** + * End-to-end passkey ceremony test (docs/passkey-login.md §PK6). + * + * The vitest suites verify the derivation contract and the option shapes, but + * they cannot exercise the part that actually breaks in the wild: a REAL + * browser running REAL WebAuthn create/get ceremonies, PRF extension results + * crossing the browser boundary, and the widget's worker deriving an account + * from them. This drives headless Chromium through Playwright with a CDP + * virtual authenticator (`hasPrf: true`), against the built demo served + * locally and pointed at an in-process mock relay -- so it is hermetic: no + * public relays, no hardware, no platform passkey provider. + * + * Skipped automatically when the Playwright browser isn't installed + * (`npx playwright install chromium`), so `npm test` stays green anywhere. + */ +import { createServer, type Server } from "node:http"; +import { readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { extname, join, normalize } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +// Relative source import on purpose: test-support is deliberately NOT a +// package export (it must never ship), and vitest resolves the TS directly. +import { MockRelay } from "../../../core/src/test-support/mockRelay.js"; + +const DIST = fileURLToPath(new URL("../../../demo/dist/", import.meta.url)); +const MIME: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".webmanifest": "application/manifest+json" +}; + +let chromiumAvailable = true; +type PlaywrightModule = typeof import("playwright"); +let playwright: PlaywrightModule | null = null; +try { + playwright = (await import("playwright")) as PlaywrightModule; + const path = playwright.chromium.executablePath(); + if (!path || !existsSync(path)) chromiumAvailable = false; +} catch { + chromiumAvailable = false; +} + +/** + * Serves the built demo, with one substitution: the account page's widget is + * pointed at the local mock relay (the real page uses BitLogin's public relay + * list, which this test must never touch). + */ +function startServer(relayUrls: string[]): Promise<{ server: Server; origin: string }> { + const relayList = relayUrls.join(","); + const server = createServer(async (req, res) => { + try { + const url = new URL(req.url ?? "/", "http://localhost"); + const relative = url.pathname === "/" ? "/index.html" : url.pathname; + const filePath = join(DIST, normalize(relative).replace(/^(\.\.[/\\])+/u, "")); + if (!existsSync(filePath)) { + res.writeHead(404).end("not found"); + return; + } + let body: string | Buffer = await readFile(filePath); + if (relative === "/account.html") { + body = body + .toString("utf8") + .replace( + "", + `` + ); + } + res.writeHead(200, { + "content-type": MIME[extname(filePath)] ?? "application/octet-stream", + // No service worker interference in the harness. + "cache-control": "no-store" + }); + res.end(body); + } catch (err) { + res.writeHead(500).end(String(err)); + } + }); + return new Promise((resolve) => { + // localhost, NOT 127.0.0.1: WebAuthn treats a bare IP as an invalid RP id + // ("SecurityError: This is an invalid domain"), while localhost is a + // trusted secure context. + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + resolve({ server, origin: `http://localhost:${port}` }); + }); + }); +} + +describe.skipIf(!chromiumAvailable)("passkey sign-in, real browser ceremony", () => { + let relays: MockRelay[] = []; + let server: Server; + let origin: string; + + beforeAll(async () => { + // Three relays: registration and login require a quorum, exactly as the + // core account e2e suite models it. + relays = await Promise.all([MockRelay.start(), MockRelay.start(), MockRelay.start()]); + ({ server, origin } = await startServer(relays.map((r) => r.url))); + }, 60_000); + + afterAll(async () => { + server?.close(); + await Promise.all(relays.map((r) => r.close())); + }); + + it( + "creates a passkey, registers an account, and signs back in to the SAME identity", + async () => { + const browser = await playwright!.chromium.launch(); + try { + const context = await browser.newContext(); + // Virtual authenticator with PRF: what a platform passkey provider + // (Google Password Manager, iCloud Keychain, Bitwarden) would do. + const cdp = await context.newCDPSession(await context.newPage()); + await cdp.send("WebAuthn.enable"); + const { authenticatorId } = (await cdp.send("WebAuthn.addVirtualAuthenticator", { + options: { + protocol: "ctap2", + ctap2Version: "ctap2_1", + transport: "internal", + hasResidentKey: true, + hasUserVerification: true, + hasPrf: true, + isUserVerified: true, + automaticPresenceSimulation: true + } + })) as { authenticatorId: string }; + expect(authenticatorId).toBeTruthy(); + + const page = context.pages()[0]!; + const errors: string[] = []; + page.on("pageerror", (err) => errors.push(String(err))); + await page.goto(`${origin}/account.html`, { waitUntil: "domcontentloaded" }); + + const widget = page.locator("bitlogin-auth"); + // The disclosure state survives re-renders by design, so open it only + // when the passkey row isn't already showing. + const openOptions = async () => { + await widget.locator('[data-action="toggle-more"]').waitFor({ timeout: 30_000 }); + if ((await widget.locator('[data-action="goto-passkey"]').count()) === 0) { + await widget.locator('[data-action="toggle-more"]').click(); + } + await widget.locator('[data-action="goto-passkey"]').waitFor({ timeout: 30_000 }); + }; + + await openOptions(); + await widget.locator('[data-action="goto-passkey"]').click(); + // Create a passkey: the real navigator.credentials.create ceremony, + // PRF evaluated by the virtual authenticator. + await widget.locator('[data-action="passkey-register"]').click(); + + // Setup screen appears once the ceremony produced a PRF result. + await widget.locator('[data-action="passkey-new"]').waitFor({ timeout: 30_000 }); + await widget.locator('[data-action="passkey-new"]').click(); + + // Registration publishes capsules to the mock relay, then lands on the + // dashboard showing the npub. + const pubkeyLine = widget.locator(".pubkey"); + await pubkeyLine.waitFor({ timeout: 60_000 }); + const firstNpub = (await pubkeyLine.textContent())?.trim() ?? ""; + expect(firstNpub).toMatch(/^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$/u); + + // Not a UI-only illusion: the capsules really landed on the relays, + // so this is a genuine protocol-level account. + const capsuleKinds = relays.flatMap((r) => r.storedEventKinds()); + expect(capsuleKinds.filter((kind) => kind === 30078).length).toBeGreaterThan(0); + + // Tier B2: the phrase card is offered in the first session. + expect(await widget.locator('[data-action="goto-confirm-phrase"]').count()).toBe(1); + + // The passkey session is reported to the host, method-aware. + const session = await page.evaluate(() => window.bitlogin?.activeSession()); + expect(session?.method).toBe("bitlogin"); + expect(session?.npub).toBe(firstNpub); + + // Sign out, then sign IN with the same passkey: derivation must land + // on the same account. + await widget.locator('[data-action="logout"]').click(); + await openOptions(); + await widget.locator('[data-action="goto-passkey"]').click(); + await widget.locator('[data-action="passkey-auth"]').click(); + + await pubkeyLine.waitFor({ timeout: 60_000 }); + const secondNpub = (await pubkeyLine.textContent())?.trim() ?? ""; + expect(secondNpub).toBe(firstNpub); + + // A returning session is NOT offered the first-session phrase card. + expect(await widget.locator('[data-action="goto-confirm-phrase"]').count()).toBe(0); + + await page.screenshot({ + path: fileURLToPath(new URL("../../../../.e2e-passkey-dashboard.png", import.meta.url)) + }); + expect(errors).toEqual([]); + } finally { + await browser.close(); + } + }, + 180_000 + ); + + it( + "a different passkey derives a DIFFERENT account (PRF is per-credential)", + async () => { + const browser = await playwright!.chromium.launch(); + const npubs: string[] = []; + try { + for (let attempt = 0; attempt < 2; attempt++) { + // Fresh context each round: a brand-new virtual authenticator, so + // round two is a genuinely different passkey. + const context = await browser.newContext(); + const page = await context.newPage(); + const cdp = await context.newCDPSession(page); + await cdp.send("WebAuthn.enable"); + await cdp.send("WebAuthn.addVirtualAuthenticator", { + options: { + protocol: "ctap2", + ctap2Version: "ctap2_1", + transport: "internal", + hasResidentKey: true, + hasUserVerification: true, + hasPrf: true, + isUserVerified: true, + automaticPresenceSimulation: true + } + }); + await page.goto(`${origin}/account.html`, { waitUntil: "domcontentloaded" }); + const widget = page.locator("bitlogin-auth"); + await widget.locator('[data-action="toggle-more"]').click(); + await widget.locator('[data-action="goto-passkey"]').click(); + await widget.locator('[data-action="passkey-register"]').click(); + await widget.locator('[data-action="passkey-new"]').waitFor({ timeout: 30_000 }); + await widget.locator('[data-action="passkey-new"]').click(); + const pubkey = widget.locator(".pubkey"); + await pubkey.waitFor({ timeout: 60_000 }); + npubs.push((await pubkey.textContent())?.trim() ?? ""); + await context.close(); + } + expect(npubs[0]).toMatch(/^npub1/u); + expect(npubs[1]).toMatch(/^npub1/u); + expect(npubs[0]).not.toBe(npubs[1]); + } finally { + await browser.close(); + } + }, + 180_000 + ); +}); diff --git a/packages/widget/tsconfig.json b/packages/widget/tsconfig.json index 6e6f52c..ea5ae65 100644 --- a/packages/widget/tsconfig.json +++ b/packages/widget/tsconfig.json @@ -6,5 +6,7 @@ "noEmit": true, "lib": ["ES2022", "DOM", "DOM.Iterable"] }, - "include": ["src"] + "include": ["src"], + "//": "src/e2e is excluded from THIS project's typecheck only: it imports @bitlogin/core's test-support by relative source path, because test-support is deliberately not a package export (it must never ship), and a cross-package source import violates rootDir. Vitest still type-resolves and runs those files.", + "exclude": ["src/e2e"] }