From a75273d40f89d77b94cfa74ceab2b00dc08f47ae Mon Sep 17 00:00:00 2001 From: Zaid Marji Date: Wed, 27 May 2026 06:17:23 +0300 Subject: [PATCH] playwright: cover PRF login-passkey enrolment via Chromium virtual authenticator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an end-to-end check that registering a PRF-enabled login passkey populates `userDecryption.webAuthnPrfOptions` in /api/sync — the wire-level prerequisite for the web vault's lock-screen "Unlock with passkey" option. Two tests, complementary: - PRF enrolment (`useForEncryption` checked) yields a non-empty array in /sync, with the wrapped-key blobs the client uses to derive the user key after the PRF assertion. - Enrolment without PRF (`useForEncryption` unchecked) leaves the array empty, pinning the emission filter's other branch. Drives the real "Turn on Log in with passkey" UI flow under Settings → Security → Master password against the bundled web vault, satisfying the WebAuthn credential creation step with a Chromium CDP virtual authenticator. The post-enrolment /sync call sniffs the bearer token from a live SPA request rather than reaching into IndexedDB, because the vault aggressively caches sync state and won't re-fetch on demand. Runs as a dedicated `account-lifecycle` project in `playwright.config.ts` (Chromium, `en` locale, SQLite-volatile via `utils.startVault`). The four DB projects exclude the spec via `testIgnore`, since the rest of the suite runs Firefox and the CDP virtual-authenticator with the `hmac-secret` PRF extension is Chromium-only. Why this file isn't in `passkey.spec.ts`: - The "Log in with passkey" assertion ceremony itself runs inside a same-origin `/webauthn-connector.html` iframe; current Chromium does not satisfy navigator.credentials calls inside that iframe via CDP-injected virtual authenticators. The enrolment step (which runs WebAuthn in the main frame via a bit-dialog) IS reachable, and that's exactly the step that populates webAuthnPrfOptions. Run: npx playwright test --project=account-lifecycle Verified against bundled web-vault v2026.4.1: 2/2 passed end-to-end via the docker harness. --- playwright/compose/playwright/Dockerfile | 2 +- playwright/global-utils.ts | 2 + playwright/playwright.config.ts | 22 ++- playwright/tests/account_lifecycle.spec.ts | 189 +++++++++++++++++++++ 4 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 playwright/tests/account_lifecycle.spec.ts diff --git a/playwright/compose/playwright/Dockerfile b/playwright/compose/playwright/Dockerfile index 4dae1ae4..f3d0d643 100644 --- a/playwright/compose/playwright/Dockerfile +++ b/playwright/compose/playwright/Dockerfile @@ -28,7 +28,7 @@ RUN mkdir /playwright WORKDIR /playwright COPY package.json package-lock.json . -RUN npm ci --ignore-scripts && npx playwright install-deps && npx playwright install firefox +RUN npm ci --ignore-scripts && npx playwright install-deps && npx playwright install firefox chromium COPY docker-compose.yml test.env ./ COPY compose ./compose diff --git a/playwright/global-utils.ts b/playwright/global-utils.ts index 9aec2301..1e238f75 100644 --- a/playwright/global-utils.ts +++ b/playwright/global-utils.ts @@ -165,6 +165,7 @@ function dbConfig(testInfo: TestInfo){ return { DATABASE_URL: `mysql://${process.env.MYSQL_USER}:${process.env.MYSQL_PASSWORD}@127.0.0.1:${process.env.MYSQL_PORT}/${process.env.MYSQL_DATABASE}`}; case "sqlite": case "sso-sqlite": + case "account-lifecycle": return { I_REALLY_WANT_VOLATILE_STORAGE: true }; default: throw new Error(`Unknow database name: ${testInfo.project.name}`); @@ -191,6 +192,7 @@ export async function startVault(browser: Browser, testInfo: TestInfo, env = {}, break; case "sqlite": case "sso-sqlite": + case "account-lifecycle": wipeSqlite(); break; default: diff --git a/playwright/playwright.config.ts b/playwright/playwright.config.ts index 1256cd4d..9caebd76 100644 --- a/playwright/playwright.config.ts +++ b/playwright/playwright.config.ts @@ -77,25 +77,39 @@ export default defineConfig({ { name: 'mariadb', testMatch: 'tests/*.spec.ts', - testIgnore: 'tests/sso_*.spec.ts', + testIgnore: ['tests/sso_*.spec.ts', 'tests/account_lifecycle.spec.ts'], dependencies: ['mariadb-setup'], }, { name: 'mysql', testMatch: 'tests/*.spec.ts', - testIgnore: 'tests/sso_*.spec.ts', + testIgnore: ['tests/sso_*.spec.ts', 'tests/account_lifecycle.spec.ts'], dependencies: ['mysql-setup'], }, { name: 'postgres', testMatch: 'tests/*.spec.ts', - testIgnore: 'tests/sso_*.spec.ts', + testIgnore: ['tests/sso_*.spec.ts', 'tests/account_lifecycle.spec.ts'], dependencies: ['postgres-setup'], }, { name: 'sqlite', testMatch: 'tests/*.spec.ts', - testIgnore: 'tests/sso_*.spec.ts', + testIgnore: ['tests/sso_*.spec.ts', 'tests/account_lifecycle.spec.ts'], + }, + + { + // Chromium-only project for the WebAuthn account-lifecycle spec — the rest + // of the suite runs Firefox, but the spec uses CDP's virtual + // authenticator (Chromium-only) and the `hmac-secret` PRF extension. + // SQLite-backed, en locale (the bundled web vault renders different + // labels for the WebAuthn provider row under `en_GB`). + name: 'account-lifecycle', + testMatch: 'tests/account_lifecycle.spec.ts', + use: { + browserName: 'chromium', + locale: 'en', + }, }, { diff --git a/playwright/tests/account_lifecycle.spec.ts b/playwright/tests/account_lifecycle.spec.ts new file mode 100644 index 00000000..5f72b192 --- /dev/null +++ b/playwright/tests/account_lifecycle.spec.ts @@ -0,0 +1,189 @@ +import { test, expect, type Page, type TestInfo } from '@playwright/test'; + +import * as utils from '../global-utils'; +import { createAccount } from './setups/user'; + +/** + * End-to-end coverage of "Log in with passkey" enrolment, driven via the web + * vault UI + a Chromium CDP virtual authenticator. Pins the wire shape the + * lock-screen "Unlock with passkey" option depends on: + * + * `/api/sync` `userDecryption.webAuthnPrfOptions` is a plural array, always + * present, populated exactly with PRF-enabled login passkeys. + * + * Enrolment is driven through the real UI (the `Turn on` flow under Settings + * → Security → Master password). The post-enrolment `/api/sync` is then + * called directly from the page context with a sniffed bearer token, since + * the web vault aggressively caches sync state in IndexedDB and won't + * re-fetch on hash nav or reload. + * + * Runs only under the `account-lifecycle` project (Chromium + `en` locale + SQLite + * volatile), defined in `playwright.config.ts`. The CDP virtual authenticator + * with the `hmac-secret` (PRF) extension is Chromium-only; the test would + * fail immediately on Firefox. + */ + +utils.loadEnv(); + +// Defence-in-depth: even if someone runs this spec under a non-`account-lifecycle` +// project, fail closed rather than crash on the CDP call. +test.skip( + ({ browserName }) => browserName !== 'chromium', + 'requires Chromium CDP virtual authenticator with hmac-secret/PRF', +); + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + await utils.startVault(browser, testInfo, {}); +}); + +test.afterAll('Teardown', async () => { + utils.stopVault(); +}); + +const MP = 'Master Password'; + +const AUTHENTICATOR_OPTIONS = { + protocol: 'ctap2' as const, + ctap2Version: 'ctap2_1' as const, + transport: 'internal' as const, + hasResidentKey: true, + hasUserVerification: true, + hasPrf: true, + automaticPresenceSimulation: true, + isUserVerified: true, + defaultBackupEligibility: false, + defaultBackupState: false, +}; + +async function addVirtualAuthenticator(page: Page) { + const cdp = await page.context().newCDPSession(page); + await cdp.send('WebAuthn.enable'); + await cdp.send('WebAuthn.addVirtualAuthenticator', { options: AUTHENTICATOR_OPTIONS }); +} + +async function enrollLoginPasskey( + page: Page, + mp: string, + credentialName: string, + { useForEncryption }: { useForEncryption: boolean }, +) { + await page.goto('/#/settings/security/password'); + await page.waitForLoadState('networkidle'); + + // "Turn on" button's accessible name is "Turn on Log in with passkey". + await page.locator('button:has-text("Turn on")').click(); + + const mpInput = page.locator('input#masterPassword'); + await mpInput.waitFor({ state: 'visible' }); + await mpInput.fill(mp); + // Two `Continue` buttons coexist on this page; pressing Enter inside the + // password input submits the dialog form unambiguously. + await mpInput.press('Enter'); + + // The dialog re-renders with a `name` input + `useForEncryption` checkbox + // once the credential is created. + const nameInput = page.locator('input[formcontrolname="name"]'); + await nameInput.waitFor({ state: 'visible' }); + await nameInput.fill(credentialName); + + // The `useForEncryption` checkbox is default-checked in the bundled web + // vault, so the disabled case has to set it explicitly. + const prfToggle = page.locator('input[formcontrolname="useForEncryption"]'); + if (useForEncryption) { + await prfToggle.check(); + } else { + await prfToggle.uncheck(); + } + + await page.locator('bit-dialog button[type="submit"]:has-text("Turn on")').click(); + await expect(page.locator('bit-dialog')).toHaveCount(0); +} + +/** + * Sniff the bearer token off any authenticated request the SPA makes, then + * use it to call /api/sync directly. The SPA caches sync state in IndexedDB + * and won't re-fetch on demand; sniffing the live token avoids reaching into + * that store. + */ +function attachBearerSniffer(page: Page): { token: () => string | undefined } { + let token: string | undefined; + page.on('request', (req) => { + const auth = req.headers()['authorization']; + if (auth?.startsWith('Bearer ') && req.url().includes('/api/')) { + token = auth.slice('Bearer '.length); + } + }); + return { token: () => token }; +} + +async function fetchSyncWithToken(page: Page, token: string): Promise { + const result = await page.evaluate(async (bearer) => { + const res = await fetch('/api/sync?excludeDomains=true', { + headers: { Authorization: `Bearer ${bearer}` }, + }); + return { status: res.status, body: await res.text() }; + }, token); + if (result.status !== 200) { + throw new Error(`/api/sync returned ${result.status}: ${result.body.slice(0, 200)}`); + } + return JSON.parse(result.body); +} + +test('Log in with passkey: PRF enrolment populates webAuthnPrfOptions in /api/sync', async ({ page }) => { + // End-to-end proof that the lock-screen "Unlock with passkey" affordance + // has its server-side prerequisite. The web vault renders the button when + // `userDecryption.webAuthnPrfOptions` is non-empty in /sync; without the + // server-side fix, the field is missing entirely and the button never + // appears even after a PRF passkey has been registered. + await addVirtualAuthenticator(page); + const bearer = attachBearerSniffer(page); + + const user = { + email: `e2e-prf-sync-${Date.now()}@example.com`, + name: 'PRF Sync E2E', + password: MP, + }; + + await createAccount(test, page, user); + await enrollLoginPasskey(page, user.password, 'e2e-prf-key', { useForEncryption: true }); + + const token = bearer.token(); + expect(token, 'a Bearer token must have flown over the wire').toBeTruthy(); + const sync = await fetchSyncWithToken(page, token!); + expect(sync.userDecryption, 'sync.userDecryption must be present').toBeTruthy(); + expect(Array.isArray(sync.userDecryption.webAuthnPrfOptions)).toBe(true); + expect(sync.userDecryption.webAuthnPrfOptions.length).toBeGreaterThan(0); + + // PascalCase: Bitwarden API responses keep model casing. The lock-screen + // option reads these wrapped-key blobs to derive the user key after the + // PRF assertion. + const option = sync.userDecryption.webAuthnPrfOptions[0]; + expect(option).toHaveProperty('EncryptedPrivateKey'); + expect(option).toHaveProperty('EncryptedUserKey'); + expect(option).toHaveProperty('CredentialId'); +}); + +test('Log in with passkey: enrolment without PRF leaves webAuthnPrfOptions empty', async ({ page }) => { + // The complementary case: a registered login passkey that is NOT + // PRF-enabled (the `useForEncryption` checkbox left unticked) must not + // appear in `webAuthnPrfOptions`. Together with the test above this pins + // both branches of the emission filter. + await addVirtualAuthenticator(page); + const bearer = attachBearerSniffer(page); + + const user = { + email: `e2e-noprf-sync-${Date.now()}@example.com`, + name: 'No-PRF Sync E2E', + password: MP, + }; + + await createAccount(test, page, user); + await enrollLoginPasskey(page, user.password, 'e2e-noprf-key', { useForEncryption: false }); + + const token = bearer.token(); + expect(token, 'a Bearer token must have flown over the wire').toBeTruthy(); + const sync = await fetchSyncWithToken(page, token!); + expect(sync.userDecryption, 'sync.userDecryption must be present').toBeTruthy(); + expect(Array.isArray(sync.userDecryption.webAuthnPrfOptions)).toBe(true); + expect(sync.userDecryption.webAuthnPrfOptions).toEqual([]); +});