diff --git a/README.md b/README.md index 779b183e..335acccd 100644 --- a/README.md +++ b/README.md @@ -344,9 +344,16 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles - [`new_page`](docs/tool-reference.md#new_page) - [`select_page`](docs/tool-reference.md#select_page) - [`wait_for`](docs/tool-reference.md#wait_for) -- **Emulation** (2 tools) +- **Emulation** (9 tools) - [`emulate`](docs/tool-reference.md#emulate) - [`resize_page`](docs/tool-reference.md#resize_page) + - [`webauthn_add_authenticator`](docs/tool-reference.md#webauthn_add_authenticator) + - [`webauthn_add_credential`](docs/tool-reference.md#webauthn_add_credential) + - [`webauthn_clear_credentials`](docs/tool-reference.md#webauthn_clear_credentials) + - [`webauthn_enable`](docs/tool-reference.md#webauthn_enable) + - [`webauthn_get_credentials`](docs/tool-reference.md#webauthn_get_credentials) + - [`webauthn_remove_authenticator`](docs/tool-reference.md#webauthn_remove_authenticator) + - [`webauthn_set_user_verified`](docs/tool-reference.md#webauthn_set_user_verified) - **Performance** (3 tools) - [`performance_analyze_insight`](docs/tool-reference.md#performance_analyze_insight) - [`performance_start_trace`](docs/tool-reference.md#performance_start_trace) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index a8605b83..2458e0e3 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -18,9 +18,16 @@ - [`new_page`](#new_page) - [`select_page`](#select_page) - [`wait_for`](#wait_for) -- **[Emulation](#emulation)** (2 tools) +- **[Emulation](#emulation)** (9 tools) - [`emulate`](#emulate) - [`resize_page`](#resize_page) + - [`webauthn_add_authenticator`](#webauthn_add_authenticator) + - [`webauthn_add_credential`](#webauthn_add_credential) + - [`webauthn_clear_credentials`](#webauthn_clear_credentials) + - [`webauthn_enable`](#webauthn_enable) + - [`webauthn_get_credentials`](#webauthn_get_credentials) + - [`webauthn_remove_authenticator`](#webauthn_remove_authenticator) + - [`webauthn_set_user_verified`](#webauthn_set_user_verified) - **[Performance](#performance)** (3 tools) - [`performance_analyze_insight`](#performance_analyze_insight) - [`performance_start_trace`](#performance_start_trace) @@ -223,6 +230,85 @@ --- +### `webauthn_add_authenticator` + +**Description:** Add a virtual WebAuthn authenticator. + +**Parameters:** + +- **protocol** (enum: "u2f", "ctap2") **(required)**: The protocol the virtual authenticator speaks. +- **transport** (enum: "usb", "nfc", "ble", "internal") **(required)**: The transport for the authenticator. +- **hasResidentKey** (boolean) _(optional)_: Whether the authenticator supports resident keys (passkeys). +- **hasUserVerification** (boolean) _(optional)_: Whether the authenticator supports user verification. +- **isUserVerified** (boolean) _(optional)_: Whether user verification is currently enabled/verified. + +--- + +### `webauthn_add_credential` + +**Description:** Add a credential to a virtual authenticator. + +**Parameters:** + +- **authenticatorId** (string) **(required)**: The ID of the authenticator to add the credential to. +- **credentialId** (string) **(required)**: The credential ID (base64 encoded). +- **isResidentCredential** (boolean) **(required)**: Whether this is a resident (discoverable) credential. +- **privateKey** (string) **(required)**: The private key in PKCS#8 format (base64 encoded). +- **rpId** (string) **(required)**: The relying party ID. +- **signCount** (integer) _(optional)_: The signature counter. +- **userHandle** (string) _(optional)_: The user handle (base64 encoded). + +--- + +### `webauthn_clear_credentials` + +**Description:** Clear all credentials from a virtual authenticator. + +**Parameters:** + +- **authenticatorId** (string) **(required)**: The ID of the authenticator to clear credentials from. + +--- + +### `webauthn_enable` + +**Description:** Enable the WebAuthn virtual authenticator environment for the selected page. + +**Parameters:** None + +--- + +### `webauthn_get_credentials` + +**Description:** Get all credentials registered with a virtual authenticator. + +**Parameters:** + +- **authenticatorId** (string) **(required)**: The ID of the authenticator to get credentials from. + +--- + +### `webauthn_remove_authenticator` + +**Description:** Remove a virtual WebAuthn authenticator. + +**Parameters:** + +- **authenticatorId** (string) **(required)**: The ID of the authenticator to remove. + +--- + +### `webauthn_set_user_verified` + +**Description:** Set whether user verification succeeds or fails for a virtual authenticator. + +**Parameters:** + +- **authenticatorId** (string) **(required)**: The ID of the authenticator. +- **isUserVerified** (boolean) **(required)**: Whether user verification should succeed. + +--- + ## Performance ### `performance_analyze_insight` diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 0b9dc53c..57619844 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -15,6 +15,7 @@ import * as screenshotTools from './screenshot.js'; import * as scriptTools from './script.js'; import * as snapshotTools from './snapshot.js'; import type {ToolDefinition} from './ToolDefinition.js'; +import * as webauthnTools from './webauthn.js'; const tools = [ ...Object.values(consoleTools), @@ -27,6 +28,7 @@ const tools = [ ...Object.values(screenshotTools), ...Object.values(scriptTools), ...Object.values(snapshotTools), + ...Object.values(webauthnTools), ] as ToolDefinition[]; tools.sort((a, b) => { diff --git a/src/tools/webauthn.ts b/src/tools/webauthn.ts new file mode 100644 index 00000000..bfd7d991 --- /dev/null +++ b/src/tools/webauthn.ts @@ -0,0 +1,304 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {CDPSession} from '../third_party/index.js'; +import {zod} from '../third_party/index.js'; + +import {ToolCategory} from './categories.js'; +import type {Context} from './ToolDefinition.js'; +import {defineTool} from './ToolDefinition.js'; + +/** + * Gets the CDP session from the current page context. + */ +function getCDPSession(context: Context): CDPSession { + const page = context.getSelectedPage(); + // @ts-expect-error _client is internal Puppeteer API + return page._client() as CDPSession; +} + +/** + * Wraps CDP errors with more helpful messages. + */ +function handleWebAuthnError(error: unknown): never { + const message = error instanceof Error ? error.message : String(error); + + if (message.includes('not been enabled')) { + throw new Error( + 'WebAuthn virtual authenticator environment not enabled. Call webauthn_enable first.', + ); + } + if (message.includes('authenticator')) { + throw new Error( + `Invalid or unknown authenticator ID. Use webauthn_add_authenticator to create one. Original error: ${message}`, + ); + } + throw error; +} + +export const enableWebAuthn = defineTool({ + name: 'webauthn_enable', + description: + 'Enable the WebAuthn virtual authenticator environment for the selected page.', + annotations: { + category: ToolCategory.EMULATION, + readOnlyHint: false, + }, + schema: {}, + handler: async (_request, response, context) => { + const session = getCDPSession(context); + await session.send('WebAuthn.enable'); + response.appendResponseLine( + 'WebAuthn virtual authenticator environment enabled.', + ); + }, +}); + +export const addVirtualAuthenticator = defineTool({ + name: 'webauthn_add_authenticator', + description: 'Add a virtual WebAuthn authenticator.', + annotations: { + category: ToolCategory.EMULATION, + readOnlyHint: false, + }, + schema: { + protocol: zod + .enum(['u2f', 'ctap2']) + .describe('The protocol the virtual authenticator speaks.'), + transport: zod + .enum(['usb', 'nfc', 'ble', 'internal']) + .describe('The transport for the authenticator.'), + hasResidentKey: zod + .boolean() + .optional() + .describe('Whether the authenticator supports resident keys (passkeys).'), + hasUserVerification: zod + .boolean() + .optional() + .describe('Whether the authenticator supports user verification.'), + isUserVerified: zod + .boolean() + .optional() + .describe('Whether user verification is currently enabled/verified.'), + }, + handler: async (request, response, context) => { + const session = getCDPSession(context); + const { + protocol, + transport, + hasResidentKey, + hasUserVerification, + isUserVerified, + } = request.params; + + try { + const result = await session.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol, + transport, + hasResidentKey: hasResidentKey ?? false, + hasUserVerification: hasUserVerification ?? false, + isUserVerified: isUserVerified ?? false, + }, + }); + response.appendResponseLine( + `Added virtual authenticator (authenticatorId: ${result.authenticatorId})`, + ); + } catch (error) { + handleWebAuthnError(error); + } + }, +}); + +export const removeVirtualAuthenticator = defineTool({ + name: 'webauthn_remove_authenticator', + description: 'Remove a virtual WebAuthn authenticator.', + annotations: { + category: ToolCategory.EMULATION, + readOnlyHint: false, + }, + schema: { + authenticatorId: zod + .string() + .describe('The ID of the authenticator to remove.'), + }, + handler: async (request, response, context) => { + const session = getCDPSession(context); + try { + await session.send('WebAuthn.removeVirtualAuthenticator', { + authenticatorId: request.params.authenticatorId, + }); + response.appendResponseLine( + `Removed virtual authenticator (authenticatorId: ${request.params.authenticatorId})`, + ); + } catch (error) { + handleWebAuthnError(error); + } + }, +}); + +export const getCredentials = defineTool({ + name: 'webauthn_get_credentials', + description: 'Get all credentials registered with a virtual authenticator.', + annotations: { + category: ToolCategory.EMULATION, + readOnlyHint: true, + }, + schema: { + authenticatorId: zod + .string() + .describe('The ID of the authenticator to get credentials from.'), + }, + handler: async (request, response, context) => { + const session = getCDPSession(context); + try { + const result = await session.send('WebAuthn.getCredentials', { + authenticatorId: request.params.authenticatorId, + }); + + if (result.credentials.length === 0) { + response.appendResponseLine('No credentials registered.'); + } else { + response.appendResponseLine( + `Found ${result.credentials.length} credential(s):`, + ); + for (const cred of result.credentials) { + response.appendResponseLine( + `- credentialId: ${cred.credentialId}, rpId: ${cred.rpId}, signCount: ${cred.signCount}`, + ); + } + } + } catch (error) { + handleWebAuthnError(error); + } + }, +}); + +export const addCredential = defineTool({ + name: 'webauthn_add_credential', + description: 'Add a credential to a virtual authenticator.', + annotations: { + category: ToolCategory.EMULATION, + readOnlyHint: false, + }, + schema: { + authenticatorId: zod + .string() + .describe('The ID of the authenticator to add the credential to.'), + credentialId: zod.string().describe('The credential ID (base64 encoded).'), + isResidentCredential: zod + .boolean() + .describe('Whether this is a resident (discoverable) credential.'), + rpId: zod.string().describe('The relying party ID.'), + privateKey: zod + .string() + .describe('The private key in PKCS#8 format (base64 encoded).'), + userHandle: zod + .string() + .optional() + .describe('The user handle (base64 encoded).'), + signCount: zod.number().int().optional().describe('The signature counter.'), + }, + handler: async (request, response, context) => { + const session = getCDPSession(context); + const { + authenticatorId, + credentialId, + isResidentCredential, + rpId, + privateKey, + userHandle, + signCount, + } = request.params; + + try { + await session.send('WebAuthn.addCredential', { + authenticatorId, + credential: { + credentialId, + isResidentCredential, + rpId, + privateKey, + userHandle, + signCount: signCount ?? 0, + }, + }); + response.appendResponseLine( + `Added credential (credentialId: ${credentialId}) to authenticator ${authenticatorId}`, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('User Handle is required')) { + throw new Error( + 'Resident credentials require a userHandle. Provide userHandle parameter.', + ); + } + if (message.includes('error occurred trying to create')) { + throw new Error( + 'Failed to create credential. Ensure privateKey is a valid PKCS#8 EC P-256 key (base64 encoded).', + ); + } + handleWebAuthnError(error); + } + }, +}); + +export const clearCredentials = defineTool({ + name: 'webauthn_clear_credentials', + description: 'Clear all credentials from a virtual authenticator.', + annotations: { + category: ToolCategory.EMULATION, + readOnlyHint: false, + }, + schema: { + authenticatorId: zod + .string() + .describe('The ID of the authenticator to clear credentials from.'), + }, + handler: async (request, response, context) => { + const session = getCDPSession(context); + try { + await session.send('WebAuthn.clearCredentials', { + authenticatorId: request.params.authenticatorId, + }); + response.appendResponseLine( + `Cleared all credentials from authenticator ${request.params.authenticatorId}`, + ); + } catch (error) { + handleWebAuthnError(error); + } + }, +}); + +export const setUserVerified = defineTool({ + name: 'webauthn_set_user_verified', + description: + 'Set whether user verification succeeds or fails for a virtual authenticator.', + annotations: { + category: ToolCategory.EMULATION, + readOnlyHint: false, + }, + schema: { + authenticatorId: zod.string().describe('The ID of the authenticator.'), + isUserVerified: zod + .boolean() + .describe('Whether user verification should succeed.'), + }, + handler: async (request, response, context) => { + const session = getCDPSession(context); + try { + await session.send('WebAuthn.setUserVerified', { + authenticatorId: request.params.authenticatorId, + isUserVerified: request.params.isUserVerified, + }); + response.appendResponseLine( + `Set user verification to ${request.params.isUserVerified} for authenticator ${request.params.authenticatorId}`, + ); + } catch (error) { + handleWebAuthnError(error); + } + }, +}); diff --git a/tests/tools/webauthn.test.ts b/tests/tools/webauthn.test.ts new file mode 100644 index 00000000..41fe28c0 --- /dev/null +++ b/tests/tools/webauthn.test.ts @@ -0,0 +1,223 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import {describe, it} from 'node:test'; + +import { + addCredential, + addVirtualAuthenticator, + clearCredentials, + enableWebAuthn, + getCredentials, + removeVirtualAuthenticator, + setUserVerified, +} from '../../src/tools/webauthn.js'; +import {withMcpContext} from '../utils.js'; + +describe('webauthn', () => { + describe('webauthn_enable', () => { + it('enables WebAuthn so virtual authenticators can be added', async () => { + await withMcpContext(async (response, context) => { + await enableWebAuthn.handler({params: {}}, response, context); + + // Verify WebAuthn is enabled by successfully adding a virtual authenticator + // This will fail if WebAuthn.enable wasn't called + const page = context.getSelectedPage(); + // @ts-expect-error _client is internal Puppeteer API + const session = page._client(); + const result = await session.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + transport: 'internal', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + }, + }); + assert.ok(result.authenticatorId, 'Should return authenticator ID'); + }); + }); + }); + + describe('webauthn_add_authenticator', () => { + it('adds a virtual authenticator and returns its ID', async () => { + await withMcpContext(async (response, context) => { + // First enable WebAuthn + await enableWebAuthn.handler({params: {}}, response, context); + + // Then add authenticator via tool + await addVirtualAuthenticator.handler( + { + params: { + protocol: 'ctap2', + transport: 'internal', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + }, + }, + response, + context, + ); + + // Response should contain the authenticator ID + const hasAuthenticatorId = response.responseLines.some(line => + line.includes('authenticatorId'), + ); + assert.ok( + hasAuthenticatorId, + 'Should include authenticator ID in response', + ); + }); + }); + }); + + describe('webauthn_remove_authenticator', () => { + it('removes a virtual authenticator', async () => { + await withMcpContext(async (response, context) => { + // Enable and add authenticator + await enableWebAuthn.handler({params: {}}, response, context); + + const page = context.getSelectedPage(); + // @ts-expect-error _client is internal Puppeteer API + const session = page._client(); + const {authenticatorId} = await session.send( + 'WebAuthn.addVirtualAuthenticator', + { + options: { + protocol: 'ctap2', + transport: 'internal', + }, + }, + ); + + // Remove via tool + await removeVirtualAuthenticator.handler( + {params: {authenticatorId}}, + response, + context, + ); + + // Verify it was removed by trying to use it (should fail) + await assert.rejects(async () => { + await session.send('WebAuthn.getCredentials', {authenticatorId}); + }, /authenticator/i); + }); + }); + }); + + describe('webauthn_get_credentials', () => { + it('returns credentials from an authenticator', async () => { + await withMcpContext(async (response, context) => { + await enableWebAuthn.handler({params: {}}, response, context); + + const page = context.getSelectedPage(); + // @ts-expect-error _client is internal Puppeteer API + const session = page._client(); + const {authenticatorId} = await session.send( + 'WebAuthn.addVirtualAuthenticator', + { + options: { + protocol: 'ctap2', + transport: 'internal', + hasResidentKey: true, + }, + }, + ); + + await getCredentials.handler( + {params: {authenticatorId}}, + response, + context, + ); + + const hasNoCredentials = response.responseLines.some(line => + line.includes('No credentials'), + ); + assert.ok(hasNoCredentials, 'Should indicate no credentials initially'); + }); + }); + }); + + describe('webauthn_clear_credentials', () => { + it('clears credentials from an authenticator', async () => { + await withMcpContext(async (response, context) => { + await enableWebAuthn.handler({params: {}}, response, context); + + const page = context.getSelectedPage(); + // @ts-expect-error _client is internal Puppeteer API + const session = page._client(); + const {authenticatorId} = await session.send( + 'WebAuthn.addVirtualAuthenticator', + { + options: { + protocol: 'ctap2', + transport: 'internal', + }, + }, + ); + + await clearCredentials.handler( + {params: {authenticatorId}}, + response, + context, + ); + + const hasCleared = response.responseLines.some(line => + line.includes('Cleared all credentials'), + ); + assert.ok(hasCleared, 'Should confirm credentials cleared'); + }); + }); + }); + + describe('webauthn_set_user_verified', () => { + it('sets user verification state', async () => { + await withMcpContext(async (response, context) => { + await enableWebAuthn.handler({params: {}}, response, context); + + const page = context.getSelectedPage(); + // @ts-expect-error _client is internal Puppeteer API + const session = page._client(); + const {authenticatorId} = await session.send( + 'WebAuthn.addVirtualAuthenticator', + { + options: { + protocol: 'ctap2', + transport: 'internal', + hasUserVerification: true, + isUserVerified: true, + }, + }, + ); + + await setUserVerified.handler( + {params: {authenticatorId, isUserVerified: false}}, + response, + context, + ); + + const hasSet = response.responseLines.some(line => + line.includes('Set user verification to false'), + ); + assert.ok(hasSet, 'Should confirm user verification set'); + }); + }); + }); + + describe('webauthn_add_credential', () => { + it('is defined with correct schema', async () => { + // Verify the tool exists and has the expected schema + assert.strictEqual(addCredential.name, 'webauthn_add_credential'); + assert.ok(addCredential.schema.authenticatorId); + assert.ok(addCredential.schema.credentialId); + assert.ok(addCredential.schema.isResidentCredential); + assert.ok(addCredential.schema.rpId); + assert.ok(addCredential.schema.privateKey); + }); + }); +});