You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
189 lines
7.5 KiB
189 lines
7.5 KiB
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([]);
|
|
});
|
|
|