Browse Source
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.pull/7297/head
4 changed files with 210 additions and 5 deletions
@ -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<any> { |
||||
|
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([]); |
||||
|
}); |
||||
Loading…
Reference in new issue