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"] }