diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8438a14f854..a0d4ee6543c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -102,6 +102,7 @@ ## Web3Auth Team /packages/seedless-onboarding-controller @MetaMask/web3auth +/packages/passkey-controller @MetaMask/web3auth /packages/shield-controller @MetaMask/web3auth /packages/subscription-controller @MetaMask/web3auth /packages/claims-controller @MetaMask/web3auth @@ -168,6 +169,8 @@ /packages/geolocation-controller/CHANGELOG.md @MetaMask/core-platform /packages/keyring-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform /packages/keyring-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/passkey-controller/package.json @MetaMask/web3auth @MetaMask/core-platform +/packages/passkey-controller/CHANGELOG.md @MetaMask/web3auth @MetaMask/core-platform /packages/logging-controller/package.json @MetaMask/confirmations @MetaMask/core-platform /packages/logging-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform /packages/message-manager/package.json @MetaMask/confirmations @MetaMask/core-platform diff --git a/README.md b/README.md index 14ad251ba49..3122d17adc4 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/network-controller`](packages/network-controller) - [`@metamask/network-enablement-controller`](packages/network-enablement-controller) - [`@metamask/notification-services-controller`](packages/notification-services-controller) +- [`@metamask/passkey-controller`](packages/passkey-controller) - [`@metamask/permission-controller`](packages/permission-controller) - [`@metamask/permission-log-controller`](packages/permission-log-controller) - [`@metamask/perps-controller`](packages/perps-controller) @@ -168,6 +169,7 @@ linkStyle default opacity:0.5 network_controller(["@metamask/network-controller"]); network_enablement_controller(["@metamask/network-enablement-controller"]); notification_services_controller(["@metamask/notification-services-controller"]); + passkey_controller(["@metamask/passkey-controller"]); permission_controller(["@metamask/permission-controller"]); permission_log_controller(["@metamask/permission-log-controller"]); perps_controller(["@metamask/perps-controller"]); @@ -423,6 +425,8 @@ linkStyle default opacity:0.5 notification_services_controller --> keyring_controller; notification_services_controller --> messenger; notification_services_controller --> profile_sync_controller; + passkey_controller --> base_controller; + passkey_controller --> messenger; permission_controller --> approval_controller; permission_controller --> base_controller; permission_controller --> controller_utils; diff --git a/packages/passkey-controller/CHANGELOG.md b/packages/passkey-controller/CHANGELOG.md new file mode 100644 index 00000000000..349b1314029 --- /dev/null +++ b/packages/passkey-controller/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial `@metamask/passkey-controller` ([#8422](https://github.com/MetaMask/core/pull/8422)): `PasskeyController` for WebAuthn passkey vault key protection (HKDF-derived keys, AES-256-GCM wrap/unwrap), PRF or `userHandle` derivation, challenge-keyed `CeremonyManager`, enrollment/unlock/renewal flows, `verifyPasskeyAuthentication`, selectors, and exported ceremony timing constants. +- `PasskeyControllerError` with stable `code`, optional `cause` / `context`, `toJSON`, and `toString`; `PasskeyControllerErrorCode`, `PasskeyControllerErrorMessage`, and `controllerName`. Replaces `PasskeyAuthenticationRejectedError`—use `PasskeyControllerError` and `code` for auth failures. +- **BREAKING:** Operational error messages are prefixed with `PasskeyController - `; prefer `code` or `instanceof PasskeyControllerError` over matching raw strings. +- `renewVaultKeyProtection` uses the same `vault_key_decryption_failed` code as `retrieveVaultKeyWithPasskey` when AES-GCM decrypt fails. +- Thrown failures from `verifyRegistrationResponse` / `verifyAuthenticationResponse` are wrapped in `PasskeyControllerError` with `registration_verification_failed` / `authentication_verification_failed` and the underlying error as `cause` (aligned with the `verified: false` path). +- Debug logging (via `@metamask/utils`) for registration/authentication verification failures, missing ceremony state, vault decrypt failures, and vault key mismatch during renewal. + +### Fixed + +- Registration verification requires the credential `id`/`rawId` to match the credential id in authenticator data; vault wrapping key derivation uses that verified credential id so enrollment keys align with the stored credential. +- Registration options request attestation conveyance `'none'` so clients are not asked for direct attestation formats the verifier does not implement (`none` and self-attested `packed` only). + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/passkey-controller/LICENSE b/packages/passkey-controller/LICENSE new file mode 100644 index 00000000000..c259cd7ebcf --- /dev/null +++ b/packages/passkey-controller/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/passkey-controller/README.md b/packages/passkey-controller/README.md new file mode 100644 index 00000000000..2acd9de9cf3 --- /dev/null +++ b/packages/passkey-controller/README.md @@ -0,0 +1,155 @@ +# `@metamask/passkey-controller` + +Manages passkey-based vault key protection using [WebAuthn](https://www.w3.org/TR/webauthn-3/). Orchestrates the full passkey lifecycle: generating WebAuthn ceremony options, verifying authenticator responses, and protecting/retrieving the vault encryption key via AES-256-GCM wrapping with HKDF-derived keys. + +## Installation + +`yarn add @metamask/passkey-controller` + +or + +`npm install @metamask/passkey-controller` + +## Overview + +The controller follows a two-phase ceremony pattern for both enrollment and authentication: + +1. **Generate options** — call a synchronous method that returns options JSON and records **in-flight ceremony** state (challenge-keyed; not a user login session). +2. **Verify response** — pass the authenticator's response back to the controller, which verifies the WebAuthn signature and performs the cryptographic operation (protect or retrieve the vault key). + +### Key derivation strategies + +The controller supports two key derivation methods, selected automatically during enrollment: + +| Strategy | When used | Input key material | +| -------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| **PRF** | Authenticator supports the [WebAuthn PRF extension](https://w3c.github.io/webauthn/#prf-extension) | PRF evaluation output | +| **userHandle** | PRF is unavailable | Random `userHandle` generated during registration | + +Both strategies feed the input key material through **HKDF-SHA256** with the credential ID as salt and a fixed info string to produce the 32-byte AES-256 wrapping key. + +## Usage + +### Setting up the controller + +```typescript +import { PasskeyController } from '@metamask/passkey-controller'; +import type { PasskeyControllerMessenger } from '@metamask/passkey-controller'; + +const messenger: PasskeyControllerMessenger = /* create via root messenger */; + +const controller = new PasskeyController({ + messenger, + rpID: 'example.com', + rpName: 'My Wallet', + expectedOrigin: 'chrome-extension://abcdef1234567890', + // Optional — both default to `rpName` when omitted. + userName: 'My Wallet', + userDisplayName: 'My Wallet', +}); +``` + +### Passkey enrollment (registration) + +```typescript +// 1. Generate registration options (synchronous) +const options = controller.generateRegistrationOptions(); + +// 2. Pass options to the browser WebAuthn API +const response = await navigator.credentials.create({ publicKey: options }); + +// 3. Verify and protect the vault key +await controller.protectVaultKeyWithPasskey({ + registrationResponse: response, + vaultKey: myVaultEncryptionKey, +}); +``` + +### Passkey unlock (authentication) + +```typescript +// 1. Generate authentication options (synchronous) +const options = controller.generateAuthenticationOptions(); + +// 2. Pass options to the browser WebAuthn API +const response = await navigator.credentials.get({ publicKey: options }); + +// 3. Verify and retrieve the vault key +const vaultKey = await controller.retrieveVaultKeyWithPasskey(response); +``` + +### Password change (vault key renewal) + +```typescript +const options = controller.generateAuthenticationOptions(); +const response = await navigator.credentials.get({ publicKey: options }); + +await controller.renewVaultKeyProtection({ + authenticationResponse: response, + oldVaultKey: currentVaultKey, + newVaultKey: newVaultKey, +}); +``` + +### Checking enrollment and removing a passkey + +```typescript +controller.isPasskeyEnrolled(); // boolean + +controller.removePasskey(); // user-facing unenroll; clears persisted passkey and in-flight ceremonies + +controller.clearState(); // same persisted reset + clears in-flight ceremony state; use for app lifecycle (e.g. wallet reset) +``` + +### Selectors + +For Redux selectors and other code paths without access to the controller +instance, use the exported selector(s): + +```typescript +import { passkeyControllerSelectors } from '@metamask/passkey-controller'; + +passkeyControllerSelectors.selectIsPasskeyEnrolled(state); // boolean +``` + +### Errors + +`PasskeyControllerError` is thrown for controller failures. Expected operational +cases use a stable `code` from `PasskeyControllerErrorCode` (for example: +`not_enrolled`, `no_registration_ceremony`, `authentication_verification_failed`, +`missing_key_material`, `vault_key_decryption_failed`). Human-readable strings +live on `PasskeyControllerErrorMessage`. Use `instanceof PasskeyControllerError` +and a defined `error.code` to tell these apart from malformed WebAuthn payloads +and other `Error` values. Thrown errors from the internal WebAuthn verify helpers +are also surfaced as `PasskeyControllerError` with the same `registration_verification_failed` +or `authentication_verification_failed` code and the original error as `cause`. +`verifyPasskeyAuthentication` returns `false` only for +those controller errors (with `code`) and rethrows everything else. + +## API + +### State + +| Property | Type | Description | +| --------------- | ----------------------- | --------------------------------------------------------------------------------------------- | +| `passkeyRecord` | `PasskeyRecord \| null` | Enrolled passkey credential data and encrypted vault key. `null` when no passkey is enrolled. | + +### Messenger actions + +| Action | Handler | +| ---------------------------- | ------------------------------------ | +| `PasskeyController:getState` | Returns the current controller state | + +For derived enrollment status outside of components that hold a controller +reference, use `passkeyControllerSelectors.selectIsPasskeyEnrolled` (see +[Selectors](#selectors)). + +### Messenger events + +| Event | Payload | +| -------------------------------- | ------------------------------------------------------------ | +| `PasskeyController:stateChanged` | Emitted when state changes (standard `BaseController` event) | + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/passkey-controller/jest.config.js b/packages/passkey-controller/jest.config.js new file mode 100644 index 00000000000..1dbe6a6c174 --- /dev/null +++ b/packages/passkey-controller/jest.config.js @@ -0,0 +1,14 @@ +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + displayName, + testEnvironment: '/jest.environment.js', + coverageThreshold: { + global: { branches: 100, functions: 100, lines: 100, statements: 100 }, + }, +}); diff --git a/packages/passkey-controller/jest.environment.js b/packages/passkey-controller/jest.environment.js new file mode 100644 index 00000000000..c3b47d5c246 --- /dev/null +++ b/packages/passkey-controller/jest.environment.js @@ -0,0 +1,17 @@ +const { TestEnvironment } = require('jest-environment-node'); + +/** + * Passkey orchestration uses the Web Crypto API (`crypto.getRandomValues`) in Node tests. + */ +class CustomTestEnvironment extends TestEnvironment { + async setup() { + await super.setup(); + if (typeof this.global.crypto === 'undefined') { + // Only used for testing. + // eslint-disable-next-line n/no-unsupported-features/node-builtins + this.global.crypto = require('crypto').webcrypto; + } + } +} + +module.exports = CustomTestEnvironment; diff --git a/packages/passkey-controller/package.json b/packages/passkey-controller/package.json new file mode 100644 index 00000000000..931a45cd83a --- /dev/null +++ b/packages/passkey-controller/package.json @@ -0,0 +1,78 @@ +{ + "name": "@metamask/passkey-controller", + "version": "0.0.0", + "description": "Controller and utilities for passkey-based wallet unlock", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/passkey-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/passkey-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/passkey-controller", + "publish:preview": "yarn npm publish --tag preview", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@levischuck/tiny-cbor": "^0.3.3", + "@metamask/base-controller": "^9.1.0", + "@metamask/messenger": "^1.1.1", + "@metamask/utils": "^11.9.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.2", + "@noble/hashes": "^1.8.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^29.5.14", + "deepmerge": "^4.2.2", + "jest": "^29.7.0", + "jest-environment-node": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/passkey-controller/src/PasskeyController.test.ts b/packages/passkey-controller/src/PasskeyController.test.ts new file mode 100644 index 00000000000..1d7f36566ac --- /dev/null +++ b/packages/passkey-controller/src/PasskeyController.test.ts @@ -0,0 +1,1770 @@ +import { Messenger } from '@metamask/messenger'; + +import { CEREMONY_MAX_AGE_MS, WEBAUTHN_TIMEOUT_MS } from './ceremony-manager'; +import { + PasskeyControllerErrorCode, + PasskeyControllerErrorMessage, +} from './constants'; +import { PasskeyControllerError } from './errors'; +import { + getDefaultPasskeyControllerState, + passkeyControllerSelectors, + PasskeyController, +} from './PasskeyController'; +import type { + PasskeyControllerMessenger, + PasskeyControllerState, +} from './PasskeyController'; +import type { PasskeyRecord, PrfClientExtensionResults } from './types'; +import * as passkeyCrypto from './utils/crypto'; +import type { + PasskeyRegistrationResponse, + PasskeyAuthenticationResponse, +} from './webauthn/types'; + +type ExtOutputsWithPrf = Record & PrfClientExtensionResults; + +function prfResults(first: string, enabled?: boolean): ExtOutputsWithPrf { + if (enabled === undefined) { + return { prf: { results: { first } } } as ExtOutputsWithPrf; + } + return { prf: { enabled, results: { first } } } as ExtOutputsWithPrf; +} + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const mockVerifyRegistrationResponse = jest.fn(); +const mockVerifyAuthenticationResponse = jest.fn(); + +jest.mock('./webauthn/verify-registration-response', () => ({ + ...jest.requireActual('./webauthn/verify-registration-response'), + verifyRegistrationResponse: (...args: unknown[]): unknown => + mockVerifyRegistrationResponse(...args), +})); + +jest.mock('./webauthn/verify-authentication-response', () => ({ + ...jest.requireActual('./webauthn/verify-authentication-response'), + verifyAuthenticationResponse: (...args: unknown[]): unknown => + mockVerifyAuthenticationResponse(...args), +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function bytesToBase64URL(bytes: Uint8Array): string { + const binary = String.fromCharCode(...bytes); + return btoa(binary) + .replace(/\+/gu, '-') + .replace(/\//gu, '_') + .replace(/[=]+$/u, ''); +} + +const TEST_RP_ID = 'example.com'; +const TEST_ORIGIN = 'https://example.com'; +const TEST_CREDENTIAL_ID = 'QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo'; +const TEST_PUBLIC_KEY = bytesToBase64URL(new Uint8Array(32).fill(0xaa)); +const TEST_CHALLENGE = 'dGVzdC1jaGFsbGVuZ2U'; + +function getPasskeyMessenger(): PasskeyControllerMessenger { + return new Messenger({ + namespace: 'PasskeyController', + }) as PasskeyControllerMessenger; +} + +const TEST_RP_NAME = 'Test RP'; + +function createController( + overrides?: Partial[0]>, +): PasskeyController { + return new PasskeyController({ + messenger: getPasskeyMessenger(), + rpID: TEST_RP_ID, + rpName: TEST_RP_NAME, + expectedOrigin: TEST_ORIGIN, + ...overrides, + }); +} + +function minimalRegistrationResponse( + overrides?: Partial, + challenge: string = TEST_CHALLENGE, +): PasskeyRegistrationResponse { + return { + id: TEST_CREDENTIAL_ID, + rawId: TEST_CREDENTIAL_ID, + type: 'public-key', + response: { + clientDataJSON: bytesToBase64URL( + new TextEncoder().encode( + JSON.stringify({ + type: 'webauthn.create', + challenge, + origin: TEST_ORIGIN, + }), + ), + ), + attestationObject: bytesToBase64URL(new Uint8Array([0, 1, 2])), + }, + clientExtensionResults: {}, + authenticatorAttachment: 'platform', + ...overrides, + } as PasskeyRegistrationResponse; +} + +function minimalAuthenticationResponse( + userHandle?: string, + overrides?: Partial, + challenge: string = TEST_CHALLENGE, +): PasskeyAuthenticationResponse { + return { + id: TEST_CREDENTIAL_ID, + rawId: TEST_CREDENTIAL_ID, + type: 'public-key', + response: { + clientDataJSON: bytesToBase64URL( + new TextEncoder().encode( + JSON.stringify({ + type: 'webauthn.get', + challenge, + origin: TEST_ORIGIN, + }), + ), + ), + authenticatorData: bytesToBase64URL(new Uint8Array([0])), + signature: bytesToBase64URL(new Uint8Array([0])), + ...(userHandle === undefined ? {} : { userHandle }), + }, + clientExtensionResults: {}, + authenticatorAttachment: 'platform', + ...overrides, + } as PasskeyAuthenticationResponse; +} + +/** + * Sets up mocks for a full registration + protect flow. + */ +function setupRegistrationMocks(): void { + mockVerifyRegistrationResponse.mockResolvedValue({ + verified: true, + registrationInfo: { + credentialId: TEST_CREDENTIAL_ID, + publicKey: new Uint8Array(32).fill(0xaa), + counter: 0, + transports: ['internal'], + aaguid: '00000000-0000-0000-0000-000000000000', + attestationFormat: 'none', + userVerified: true, + }, + }); +} + +function setupAuthenticationMocks(): void { + mockVerifyAuthenticationResponse.mockResolvedValue({ + verified: true, + authenticationInfo: { + credentialId: TEST_CREDENTIAL_ID, + newCounter: 0, + userVerified: true, + origin: TEST_ORIGIN, + rpID: TEST_RP_ID, + }, + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('PasskeyController', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getDefaultPasskeyControllerState', () => { + it('returns null passkeyRecord', () => { + expect(getDefaultPasskeyControllerState()).toStrictEqual({ + passkeyRecord: null, + }); + }); + }); + + describe('constructor', () => { + it('merges partial initial state with defaults', () => { + const record: PasskeyRecord = { + credential: { + id: TEST_CREDENTIAL_ID, + publicKey: TEST_PUBLIC_KEY, + counter: 0, + transports: ['internal'], + aaguid: '00000000-0000-0000-0000-000000000000', + }, + encryptedVaultKey: { + ciphertext: 'YQ==', + iv: 'YWFhYWFhYWFhYQ==', + }, + keyDerivation: { method: 'userHandle' }, + }; + const controller = createController({ + state: { passkeyRecord: record }, + }); + expect(controller.state.passkeyRecord).toStrictEqual(record); + }); + }); + + describe('isPasskeyEnrolled', () => { + it('returns false when no record is stored', () => { + const controller = createController(); + expect(controller.isPasskeyEnrolled()).toBe(false); + }); + }); + + describe('generateRegistrationOptions', () => { + it('returns options with PRF extension and challenge', () => { + const controller = createController(); + + const options = controller.generateRegistrationOptions(); + + expect(options.rp).toStrictEqual({ + name: TEST_RP_NAME, + id: TEST_RP_ID, + }); + expect(options.challenge).toBeDefined(); + expect(options.challenge.length).toBeGreaterThan(0); + expect(options.pubKeyCredParams).toStrictEqual([ + { alg: -8, type: 'public-key' }, + { alg: -7, type: 'public-key' }, + { alg: -257, type: 'public-key' }, + ]); + expect(options.attestation).toBe('none'); + expect(options.timeout).toBe(WEBAUTHN_TIMEOUT_MS); + expect( + (options.extensions as Record)?.prf, + ).toBeDefined(); + }); + + it('uses rpID and rpName from constructor', () => { + const controller = createController({ + rpID: 'custom-rp.io', + rpName: 'Custom RP', + }); + const options = controller.generateRegistrationOptions(); + expect(options.rp.id).toBe('custom-rp.io'); + expect(options.rp.name).toBe('Custom RP'); + }); + + it('includes PRF extension when prfAvailable is true', () => { + const controller = createController(); + const options = controller.generateRegistrationOptions({ + prfAvailable: true, + }); + expect( + (options.extensions as Record)?.prf, + ).toBeDefined(); + }); + + it('includes PRF extension when prfAvailable is undefined (default)', () => { + const controller = createController(); + const options = controller.generateRegistrationOptions(); + expect( + (options.extensions as Record)?.prf, + ).toBeDefined(); + }); + + it('omits PRF extension when prfAvailable is false', () => { + const controller = createController(); + const options = controller.generateRegistrationOptions({ + prfAvailable: false, + }); + expect(options.extensions).toBeUndefined(); + }); + + it('uses userHandle derivation for the full round-trip when prfAvailable is false', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const vaultKey = 'no-prf-vault-key'; + + const regOptions = controller.generateRegistrationOptions({ + prfAvailable: false, + }); + expect(regOptions.extensions).toBeUndefined(); + + const userHandle = regOptions.user.id; + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOptions.challenge, + ), + vaultKey, + }); + + expect(controller.state.passkeyRecord?.keyDerivation).toStrictEqual({ + method: 'userHandle', + }); + + const authOptions = controller.generateAuthenticationOptions(); + expect(authOptions.extensions).toStrictEqual({}); + + const retrieved = await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + userHandle, + undefined, + authOptions.challenge, + ), + ); + expect(retrieved).toBe(vaultKey); + }); + }); + + describe('generateAuthenticationOptions', () => { + it('throws when passkey is not enrolled', () => { + const controller = createController(); + expect(() => controller.generateAuthenticationOptions()).toThrow( + PasskeyControllerErrorMessage.NotEnrolled, + ); + }); + + it('returns options with PRF for prf-enrolled credentials', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(9)); + const controller = createController(); + + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst, true), + }, + regOpts.challenge, + ), + vaultKey: 'k', + }); + + const authOpts = controller.generateAuthenticationOptions(); + + expect(authOpts.rpId).toBe(TEST_RP_ID); + expect(authOpts.allowCredentials).toStrictEqual([ + expect.objectContaining({ + id: TEST_CREDENTIAL_ID, + type: 'public-key', + }), + ]); + expect( + (authOpts.extensions as Record)?.prf, + ).toBeDefined(); + }); + }); + + describe('protectVaultKeyWithPasskey', () => { + it('throws when there is no active registration ceremony', async () => { + const controller = createController(); + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse(), + vaultKey: 'k', + }), + ).rejects.toThrow(PasskeyControllerErrorMessage.NoRegistrationCeremony); + }); + + it('throws when verification fails', async () => { + mockVerifyRegistrationResponse.mockResolvedValue({ + verified: false, + }); + + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'k', + }), + ).rejects.toThrow( + PasskeyControllerErrorMessage.RegistrationVerificationFailed, + ); + }); + + it('wraps non-Error verifyRegistrationResponse rejection in RegistrationVerificationFailed', async () => { + mockVerifyRegistrationResponse.mockRejectedValue('verify-string-error'); + + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'k', + }), + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.RegistrationVerificationFailed, + cause: expect.objectContaining({ message: 'verify-string-error' }), + }); + }); + + it('wraps verifyRegistrationResponse rejection in RegistrationVerificationFailed and clears ceremony state', async () => { + mockVerifyRegistrationResponse.mockRejectedValue( + new Error('verify-error'), + ); + + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'k', + }), + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.RegistrationVerificationFailed, + message: PasskeyControllerErrorMessage.RegistrationVerificationFailed, + cause: expect.objectContaining({ message: 'verify-error' }), + }); + + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'k', + }), + ).rejects.toThrow(PasskeyControllerErrorMessage.NoRegistrationCeremony); + }); + + it('stores passkey record with publicKey after successful verification', async () => { + setupRegistrationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'test-vault-key', + }); + + expect(controller.isPasskeyEnrolled()).toBe(true); + const record = controller.state.passkeyRecord; + expect(record?.credential.id).toBe(TEST_CREDENTIAL_ID); + expect(record?.credential.publicKey).toBe(TEST_PUBLIC_KEY); + expect(record?.credential.transports).toStrictEqual(['internal']); + expect(record?.credential.aaguid).toBe( + '00000000-0000-0000-0000-000000000000', + ); + expect(record?.keyDerivation.method).toBe('userHandle'); + }); + + it('uses prf derivation when extension results include PRF output', async () => { + setupRegistrationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(9)); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst, true), + }, + regOpts.challenge, + ), + vaultKey: 'vault-key-prf-path', + }); + + expect(controller.state.passkeyRecord?.keyDerivation.method).toBe('prf'); + expect(controller.state.passkeyRecord?.keyDerivation).toMatchObject({ + method: 'prf', + prfSalt: expect.any(String), + }); + }); + + it('uses userHandle derivation when PRF was requested but registration returns no PRF output bytes', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const vaultKey = 'vault-prf-requested-no-output'; + + const regOptions = controller.generateRegistrationOptions({ + prfAvailable: true, + }); + expect( + (regOptions.extensions as Record)?.prf, + ).toBeDefined(); + + const userHandle = regOptions.user.id; + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: { + prf: { enabled: true }, + } as ExtOutputsWithPrf, + }, + regOptions.challenge, + ), + vaultKey, + }); + + expect(controller.state.passkeyRecord?.keyDerivation).toStrictEqual({ + method: 'userHandle', + }); + + const authOptions = controller.generateAuthenticationOptions(); + expect(authOptions.extensions).toStrictEqual({}); + + const retrieved = await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + userHandle, + undefined, + authOptions.challenge, + ), + ); + expect(retrieved).toBe(vaultKey); + }); + }); + + describe('retrieveVaultKeyWithPasskey', () => { + it('throws when passkey is not enrolled', async () => { + setupAuthenticationMocks(); + const controller = createController(); + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse('uh'), + ), + ).rejects.toThrow(PasskeyControllerErrorMessage.NotEnrolled); + }); + + it('throws when there is no authentication ceremony', async () => { + setupRegistrationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'k', + }); + + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse('uh'), + ), + ).rejects.toThrow(PasskeyControllerErrorMessage.NoAuthenticationCeremony); + }); + + it('throws when verification fails', async () => { + setupRegistrationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'k', + }); + + mockVerifyAuthenticationResponse.mockResolvedValue({ + verified: false, + }); + + const authOpts = controller.generateAuthenticationOptions(); + + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse('uh', undefined, authOpts.challenge), + ), + ).rejects.toThrow( + PasskeyControllerErrorMessage.AuthenticationVerificationFailed, + ); + }); + + it('wraps non-Error verifyAuthenticationResponse rejection in AuthenticationVerificationFailed', async () => { + setupRegistrationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'k', + }); + + mockVerifyAuthenticationResponse.mockRejectedValue('auth-string-error'); + + const authOpts = controller.generateAuthenticationOptions(); + + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse('uh', undefined, authOpts.challenge), + ), + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.AuthenticationVerificationFailed, + cause: expect.objectContaining({ message: 'auth-string-error' }), + }); + }); + + it('wraps verifyAuthenticationResponse rejection in AuthenticationVerificationFailed', async () => { + setupRegistrationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'k', + }); + + mockVerifyAuthenticationResponse.mockRejectedValue( + new Error('auth-verify-error'), + ); + + const authOpts = controller.generateAuthenticationOptions(); + + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse('uh', undefined, authOpts.challenge), + ), + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.AuthenticationVerificationFailed, + message: PasskeyControllerErrorMessage.AuthenticationVerificationFailed, + cause: expect.objectContaining({ message: 'auth-verify-error' }), + }); + }); + + it('wraps non-Error decrypt failure in VaultKeyDecryptionFailed when retrieving vault key', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + vaultKey: 'secret', + }); + + const decryptSpy = jest + .spyOn(passkeyCrypto, 'decryptWithKey') + .mockImplementation(() => { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- exercise non-Error rejection normalization + throw 'decrypt-string-fail'; + }); + + const authOpts = controller.generateAuthenticationOptions(); + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + ), + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.VaultKeyDecryptionFailed, + cause: expect.objectContaining({ + message: 'decrypt-string-fail', + }), + }); + + decryptSpy.mockRestore(); + }); + + it('throws when passkey record disappears while persisting counter after auth', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + vaultKey: 'secret', + }); + + const updateSpy = jest.spyOn(controller, 'update' as never); + (updateSpy as unknown as jest.Mock).mockImplementation( + (updater: (state: PasskeyControllerState) => void) => { + updater({ + ...getDefaultPasskeyControllerState(), + passkeyRecord: null, + } as PasskeyControllerState); + }, + ); + + const authOpts = controller.generateAuthenticationOptions(); + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + ), + ).rejects.toThrow(PasskeyControllerError); + + updateSpy.mockRestore(); + }); + + it('clears the authentication ceremony after successful retrieval (prf)', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(99)); + + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + vaultKey: 'secret', + }); + + const authOpts = controller.generateAuthenticationOptions(); + await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + ); + + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse(undefined, { + clientExtensionResults: prfResults(prfFirst), + }), + ), + ).rejects.toThrow(PasskeyControllerErrorMessage.NoAuthenticationCeremony); + }); + }); + + describe('verifyPasskeyAuthentication', () => { + it('returns false when passkey is not enrolled', async () => { + setupAuthenticationMocks(); + const controller = createController(); + expect( + await controller.verifyPasskeyAuthentication( + minimalAuthenticationResponse('uh'), + ), + ).toBe(false); + }); + + it('returns false when there is no authentication ceremony', async () => { + setupRegistrationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'k', + }); + + expect( + await controller.verifyPasskeyAuthentication( + minimalAuthenticationResponse('uh'), + ), + ).toBe(false); + }); + + it('returns false when verification fails', async () => { + setupRegistrationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'k', + }); + + mockVerifyAuthenticationResponse.mockResolvedValue({ + verified: false, + }); + + const authOpts = controller.generateAuthenticationOptions(); + + expect( + await controller.verifyPasskeyAuthentication( + minimalAuthenticationResponse('uh', undefined, authOpts.challenge), + ), + ).toBe(false); + }); + + it('rethrows non-operational errors (e.g. malformed clientDataJSON)', async () => { + const controller = createController(); + const badClientData = bytesToBase64URL( + new TextEncoder().encode('not-json'), + ); + await expect( + controller.verifyPasskeyAuthentication( + minimalAuthenticationResponse('uh', { + response: { + ...minimalAuthenticationResponse('uh').response, + clientDataJSON: badClientData, + }, + }), + ), + ).rejects.toThrow(SyntaxError); + }); + + it('returns true on successful authentication (prf)', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(7)); + const vaultKey = 'verify-bool-ok'; + + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + vaultKey, + }); + + const authOpts = controller.generateAuthenticationOptions(); + expect( + await controller.verifyPasskeyAuthentication( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + ), + ).toBe(true); + }); + }); + + describe('registration and authentication round-trip (userHandle)', () => { + it('retrieves vault key using userHandle derivation', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const vaultKey = 'userhandle-roundtrip-key'; + + const regOpts = controller.generateRegistrationOptions(); + + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey, + }); + + expect(controller.state.passkeyRecord?.keyDerivation.method).toBe( + 'userHandle', + ); + + let authOpts = controller.generateAuthenticationOptions(); + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + 'bWlzbWF0Y2hlZFVzZXJIYW5kbGU', + undefined, + authOpts.challenge, + ), + ), + ).rejects.toThrow(PasskeyControllerErrorMessage.VaultKeyDecryptionFailed); + + authOpts = controller.generateAuthenticationOptions(); + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + undefined, + authOpts.challenge, + ), + ), + ).rejects.toThrow(PasskeyControllerErrorMessage.MissingKeyMaterial); + }); + }); + + describe('registration and authentication round-trip (prf)', () => { + it('retrieves vault key when auth response repeats the same PRF output', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + const vaultKey = 'prf-roundtrip-key'; + + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + vaultKey, + }); + + const authOpts = controller.generateAuthenticationOptions(); + const out = await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + ); + + expect(out).toBe(vaultKey); + }); + }); + + describe('renewVaultKeyProtection', () => { + it('throws when passkey is not enrolled', async () => { + setupAuthenticationMocks(); + const controller = createController(); + await expect( + controller.renewVaultKeyProtection({ + authenticationResponse: minimalAuthenticationResponse('uh'), + oldVaultKey: 'old', + newVaultKey: 'new', + }), + ).rejects.toThrow(PasskeyControllerErrorMessage.NotEnrolled); + }); + + it('updates the passkey wrap when before/after vault keys match', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + const beforeKey = 'vault-key-before-password'; + + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + vaultKey: beforeKey, + }); + + let authOpts = controller.generateAuthenticationOptions(); + const afterKey = 'vault-key-after-password'; + await controller.renewVaultKeyProtection({ + authenticationResponse: minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + oldVaultKey: beforeKey, + newVaultKey: afterKey, + }); + + authOpts = controller.generateAuthenticationOptions(); + const unwrapped = await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + ); + expect(unwrapped).toBe(afterKey); + }); + + it('throws when the old vault key does not match', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + vaultKey: 'actual-wrapped-key', + }); + + const authOpts = controller.generateAuthenticationOptions(); + + await expect( + controller.renewVaultKeyProtection({ + authenticationResponse: minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + oldVaultKey: 'wrong-expected-key', + newVaultKey: 'new-key', + }), + ).rejects.toThrow(PasskeyControllerErrorMessage.VaultKeyMismatch); + }); + + it('throws when decrypting the wrapped vault key fails during renewal', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + vaultKey: 'wrapped-key', + }); + + const decryptSpy = jest + .spyOn(passkeyCrypto, 'decryptWithKey') + .mockImplementation(() => { + throw new Error('decrypt failed'); + }); + + const authOpts = controller.generateAuthenticationOptions(); + await expect( + controller.renewVaultKeyProtection({ + authenticationResponse: minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + oldVaultKey: 'wrapped-key', + newVaultKey: 'new-key', + }), + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.VaultKeyDecryptionFailed, + }); + + decryptSpy.mockRestore(); + }); + + it('wraps non-Error decrypt failure in VaultKeyDecryptionFailed during renewal', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + vaultKey: 'wrapped-key', + }); + + const decryptSpy = jest + .spyOn(passkeyCrypto, 'decryptWithKey') + .mockImplementation(() => { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- exercise non-Error rejection normalization + throw 'renew-decrypt-fail'; + }); + + const authOpts = controller.generateAuthenticationOptions(); + await expect( + controller.renewVaultKeyProtection({ + authenticationResponse: minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + oldVaultKey: 'wrapped-key', + newVaultKey: 'new-key', + }), + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.VaultKeyDecryptionFailed, + cause: expect.objectContaining({ message: 'renew-decrypt-fail' }), + }); + + decryptSpy.mockRestore(); + }); + + it('throws when passkey record disappears while persisting renewed ciphertext', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + vaultKey: 'wrapped', + }); + + const updateSpy = jest.spyOn(controller, 'update' as never); + (updateSpy as unknown as jest.Mock).mockImplementation( + (updater: (state: PasskeyControllerState) => void) => { + updater({ + ...getDefaultPasskeyControllerState(), + passkeyRecord: null, + } as PasskeyControllerState); + }, + ); + + const authOpts = controller.generateAuthenticationOptions(); + await expect( + controller.renewVaultKeyProtection({ + authenticationResponse: minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + oldVaultKey: 'wrapped', + newVaultKey: 'next', + }), + ).rejects.toThrow(PasskeyControllerError); + + updateSpy.mockRestore(); + }); + + it('completes renewal without an active authentication ceremony (prf)', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + vaultKey: 'wrapped', + }); + + await controller.renewVaultKeyProtection({ + authenticationResponse: minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + TEST_CHALLENGE, + ), + oldVaultKey: 'wrapped', + newVaultKey: 'new', + }); + + const authOpts = controller.generateAuthenticationOptions(); + const unwrapped = await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + ); + expect(unwrapped).toBe('new'); + }); + + it('does not invoke verifyAuthenticationResponse', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + vaultKey: 'wrapped', + }); + + mockVerifyAuthenticationResponse.mockClear(); + + const authOpts = controller.generateAuthenticationOptions(); + await controller.renewVaultKeyProtection({ + authenticationResponse: minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + oldVaultKey: 'wrapped', + newVaultKey: 'rotated', + }); + + expect(mockVerifyAuthenticationResponse).not.toHaveBeenCalled(); + }); + }); + + describe('removePasskey', () => { + it('clears in-flight registration ceremonies', async () => { + setupRegistrationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + controller.removePasskey(); + + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'k', + }), + ).rejects.toThrow(PasskeyControllerErrorMessage.NoRegistrationCeremony); + }); + + it('clears stored record and resets enrollment', async () => { + setupRegistrationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'k', + }); + expect(controller.isPasskeyEnrolled()).toBe(true); + + controller.removePasskey(); + expect(controller.isPasskeyEnrolled()).toBe(false); + expect(controller.state.passkeyRecord).toBeNull(); + }); + }); + + describe('clearState', () => { + it('clears stored record and resets enrollment', async () => { + setupRegistrationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'k', + }); + expect(controller.isPasskeyEnrolled()).toBe(true); + + controller.clearState(); + expect(controller.isPasskeyEnrolled()).toBe(false); + expect(controller.state.passkeyRecord).toBeNull(); + }); + }); + + describe('destroy', () => { + it('clears in-flight ceremony state', async () => { + setupRegistrationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + + controller.destroy(); + + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'k', + }), + ).rejects.toThrow(PasskeyControllerErrorMessage.NoRegistrationCeremony); + }); + }); + + describe('passkeyControllerSelectors', () => { + describe('selectIsPasskeyEnrolled', () => { + it('returns false when no record is stored', () => { + expect( + passkeyControllerSelectors.selectIsPasskeyEnrolled({ + passkeyRecord: null, + }), + ).toBe(false); + }); + + it('returns true when a record is stored', () => { + const record: PasskeyRecord = { + credential: { + id: TEST_CREDENTIAL_ID, + publicKey: TEST_PUBLIC_KEY, + counter: 0, + transports: ['internal'], + aaguid: '00000000-0000-0000-0000-000000000000', + }, + encryptedVaultKey: { ciphertext: 'YQ==', iv: 'YWFhYWFhYWFhYQ==' }, + keyDerivation: { method: 'userHandle' }, + }; + expect( + passkeyControllerSelectors.selectIsPasskeyEnrolled({ + passkeyRecord: record, + }), + ).toBe(true); + }); + }); + }); + + describe('verifyRegistrationResponse parameters', () => { + it('passes expectedOrigin and expectedRPID to verification', async () => { + setupRegistrationMocks(); + const controller = createController({ + rpID: 'custom-rp.com', + expectedOrigin: 'chrome-extension://abc123', + }); + + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'k', + }); + + expect(mockVerifyRegistrationResponse).toHaveBeenCalledWith( + expect.objectContaining({ + expectedOrigin: 'chrome-extension://abc123', + expectedRPID: 'custom-rp.com', + requireUserVerification: false, + }), + ); + }); + }); + + describe('verifyAuthenticationResponse parameters', () => { + it('passes credential with publicKey and stored counter to verification', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + vaultKey: 'k', + }); + + const authOpts = controller.generateAuthenticationOptions(); + + try { + await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + ); + } catch { + // key derivation result doesn't matter here + } + + expect(mockVerifyAuthenticationResponse).toHaveBeenCalledWith( + expect.objectContaining({ + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + credential: expect.objectContaining({ + id: TEST_CREDENTIAL_ID, + counter: 0, + }), + requireUserVerification: false, + }), + ); + }); + + it('persists newCounter from authentication and passes it on next auth', async () => { + setupRegistrationMocks(); + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + vaultKey: 'k', + }); + + expect(controller.state.passkeyRecord?.credential.counter).toBe(0); + + mockVerifyAuthenticationResponse.mockResolvedValue({ + verified: true, + authenticationInfo: { + credentialId: TEST_CREDENTIAL_ID, + newCounter: 5, + userVerified: true, + origin: TEST_ORIGIN, + rpID: TEST_RP_ID, + }, + }); + + let authOpts = controller.generateAuthenticationOptions(); + await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + ); + + expect(controller.state.passkeyRecord?.credential.counter).toBe(5); + + mockVerifyAuthenticationResponse.mockResolvedValue({ + verified: true, + authenticationInfo: { + credentialId: TEST_CREDENTIAL_ID, + newCounter: 10, + userVerified: true, + origin: TEST_ORIGIN, + rpID: TEST_RP_ID, + }, + }); + + authOpts = controller.generateAuthenticationOptions(); + await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + ); + + expect(mockVerifyAuthenticationResponse).toHaveBeenLastCalledWith( + expect.objectContaining({ + credential: expect.objectContaining({ + counter: 5, + }), + }), + ); + expect(controller.state.passkeyRecord?.credential.counter).toBe(10); + }); + }); + + describe('concurrent WebAuthn ceremonies', () => { + it('completes authentication using the first challenge after a second generateAuthenticationOptions', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(7)); + const vaultKey = 'multi-auth-ceremony'; + + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + vaultKey, + }); + + const authOpts1 = controller.generateAuthenticationOptions(); + const authOpts2 = controller.generateAuthenticationOptions(); + + const retrieved = await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts1.challenge, + ), + ); + + expect(retrieved).toBe(vaultKey); + + const authOpts3 = controller.generateAuthenticationOptions(); + expect( + await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts2.challenge, + ), + ), + ).toBe(vaultKey); + + expect( + await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts3.challenge, + ), + ), + ).toBe(vaultKey); + }); + + it('does not overwrite passkey fields updated while authentication verification awaits', async () => { + setupRegistrationMocks(); + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(99)); + const vaultKey = 'vault-concurrent-field'; + + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + vaultKey, + }); + + let finishVerify!: (value: unknown) => void; + mockVerifyAuthenticationResponse.mockImplementationOnce( + () => + new Promise((resolve) => { + finishVerify = resolve; + }), + ); + + const authOpts = controller.generateAuthenticationOptions(); + const retrievePromise = controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + ); + + await Promise.resolve(); + + const concurrentTransports = ['hybrid', 'internal'] as const; + expect( + controller.state.passkeyRecord?.credential.transports, + ).toStrictEqual(['internal']); + + ( + controller as unknown as { + update: (callback: (state: PasskeyControllerState) => void) => void; + } + ).update((state) => { + if (!state.passkeyRecord) { + return; + } + state.passkeyRecord.credential.transports = [...concurrentTransports]; + }); + + finishVerify({ + verified: true, + authenticationInfo: { + credentialId: TEST_CREDENTIAL_ID, + newCounter: 3, + userVerified: true, + origin: TEST_ORIGIN, + rpID: TEST_RP_ID, + }, + }); + + await retrievePromise; + + expect( + controller.state.passkeyRecord?.credential.transports, + ).toStrictEqual([...concurrentTransports]); + expect(controller.state.passkeyRecord?.credential.counter).toBe(3); + }); + + it('completes registration using the first challenge after a second generateRegistrationOptions', async () => { + setupRegistrationMocks(); + const controller = createController(); + const vaultKey = 'multi-reg-ceremony'; + + const regOpts1 = controller.generateRegistrationOptions(); + controller.generateRegistrationOptions(); + + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts1.challenge, + ), + vaultKey, + }); + + expect(controller.isPasskeyEnrolled()).toBe(true); + expect(controller.state.passkeyRecord).not.toBeNull(); + }); + }); + + describe('ceremony TTL', () => { + it('drops expired registration ceremonies before protectVaultKeyWithPasskey', async () => { + jest.useFakeTimers(); + jest.setSystemTime(1_000_000); + setupRegistrationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + + jest.setSystemTime(1_000_000 + CEREMONY_MAX_AGE_MS + 1); + + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'k', + }), + ).rejects.toThrow(PasskeyControllerErrorMessage.NoRegistrationCeremony); + + jest.useRealTimers(); + }); + + it('removes authentication ceremony entry when verification fails', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + const userHandle = regOpts.user.id; + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'k', + }); + + mockVerifyAuthenticationResponse.mockResolvedValue({ + verified: false, + }); + + const authOpts = controller.generateAuthenticationOptions(); + + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + userHandle, + undefined, + authOpts.challenge, + ), + ), + ).rejects.toThrow( + PasskeyControllerErrorMessage.AuthenticationVerificationFailed, + ); + + mockVerifyAuthenticationResponse.mockResolvedValue({ + verified: true, + authenticationInfo: { + credentialId: TEST_CREDENTIAL_ID, + newCounter: 0, + userVerified: true, + origin: TEST_ORIGIN, + rpID: TEST_RP_ID, + }, + }); + + const authOptsRetry = controller.generateAuthenticationOptions(); + expect( + await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + userHandle, + undefined, + authOptsRetry.challenge, + ), + ), + ).toBe('k'); + }); + }); +}); diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts new file mode 100644 index 00000000000..2eaaf39df89 --- /dev/null +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -0,0 +1,650 @@ +import type { + ControllerGetStateAction, + ControllerStateChangedEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; +import { areUint8ArraysEqual, stringToBytes } from '@metamask/utils'; +import { randomBytes } from '@noble/ciphers/webcrypto'; + +import { WEBAUTHN_TIMEOUT_MS, CeremonyManager } from './ceremony-manager'; +import { + controllerName, + PasskeyControllerErrorCode, + PasskeyControllerErrorMessage, +} from './constants'; +import { PasskeyControllerError } from './errors'; +import { + deriveKeyFromAuthenticationResponse, + deriveKeyFromRegistrationResponse, +} from './key-derivation'; +import { createModuleLogger, projectLogger } from './logger'; +import type { PasskeyRecord } from './types'; +import { decryptWithKey, encryptWithKey } from './utils/crypto'; +import { base64URLToBytes, bytesToBase64URL } from './utils/encoding'; +import { COSEALG } from './webauthn/constants'; +import { decodeClientDataJSON } from './webauthn/decode-client-data-json'; +import type { + PasskeyAuthenticationOptions, + PasskeyAuthenticationResponse, + PasskeyRegistrationOptions, + PasskeyRegistrationResponse, +} from './webauthn/types'; +import { verifyAuthenticationResponse } from './webauthn/verify-authentication-response'; +import { verifyRegistrationResponse } from './webauthn/verify-registration-response'; + +export type PasskeyControllerState = { + passkeyRecord: PasskeyRecord | null; +}; + +export type PasskeyControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + PasskeyControllerState +>; + +/** + * Actions exposed by {@link PasskeyController} on its messenger. + * + * Only `:getState` is exposed. Derived enrollment status is available via + * {@link passkeyControllerSelectors.selectIsPasskeyEnrolled}, and lifecycle + * methods ({@link PasskeyController.generateRegistrationOptions}, + * {@link PasskeyController.protectVaultKeyWithPasskey}, etc.) accept or + * return non-`Json` runtime values (WebAuthn `PublicKeyCredential` objects + * and the vault key string), so they require a direct controller reference. + */ +export type PasskeyControllerActions = PasskeyControllerGetStateAction; + +export type PasskeyControllerStateChangedEvent = ControllerStateChangedEvent< + typeof controllerName, + PasskeyControllerState +>; + +export type PasskeyControllerEvents = PasskeyControllerStateChangedEvent; + +export type PasskeyControllerMessenger = Messenger< + typeof controllerName, + PasskeyControllerActions, + PasskeyControllerEvents +>; + +/** + * Returns the default (empty) state for {@link PasskeyController}. + * + * @returns A fresh state object with no enrolled passkey. + */ +export function getDefaultPasskeyControllerState(): PasskeyControllerState { + return { passkeyRecord: null }; +} + +const passkeyControllerMetadata = { + passkeyRecord: { + persist: true, + includeInDebugSnapshot: false, + includeInStateLogs: false, + usedInUi: true, + }, +} satisfies StateMetadata; + +const log = createModuleLogger(projectLogger, controllerName); + +/** + * Selectors for {@link PasskeyControllerState}. + * + * Use these instead of dedicated getter methods on the controller, so that + * derived values can be consumed from Redux selectors and other places that + * only have access to a state object. + */ +export const passkeyControllerSelectors = { + selectIsPasskeyEnrolled: (state: PasskeyControllerState): boolean => + state.passkeyRecord !== null, +}; + +/** + * Passkey-based protection for the vault encryption key (WebAuthn). + * + * Uses PRF-backed derivation when available; otherwise uses the credential + * `userHandle`. + */ +export class PasskeyController extends BaseController< + typeof controllerName, + PasskeyControllerState, + PasskeyControllerMessenger +> { + readonly #ceremonyManager = new CeremonyManager(); + + readonly #rpID: string; + + readonly #rpName: string; + + readonly #expectedOrigin: string | string[]; + + readonly #userName: string; + + readonly #userDisplayName: string; + + /** + * Constructs a new {@link PasskeyController}. + * + * @param args - The constructor arguments. + * @param args.messenger - The messenger suited for this controller. + * @param args.state - Initial state. Missing properties are filled in with + * defaults from {@link getDefaultPasskeyControllerState}. + * @param args.rpID - WebAuthn Relying Party ID (typically the eTLD+1 of the + * client origin, or `localhost` in dev). + * @param args.rpName - Human-readable Relying Party name shown by the OS + * passkey UI. + * @param args.expectedOrigin - One or more acceptable origins for the + * `clientDataJSON.origin` check (e.g. `chrome-extension://...`). + * @param args.userName - Optional `user.name` shown by the OS passkey UI. + * Defaults to `rpName` so client builds (Stable, Flask, etc.) can + * differentiate without changes here. + * @param args.userDisplayName - Optional `user.displayName` shown by the OS + * passkey UI. Defaults to `rpName`. + */ + constructor({ + messenger, + state = {}, + rpID, + rpName, + expectedOrigin, + userName, + userDisplayName, + }: { + messenger: PasskeyControllerMessenger; + state?: Partial; + rpID: string; + rpName: string; + expectedOrigin: string | string[]; + userName?: string; + userDisplayName?: string; + }) { + super({ + messenger, + metadata: passkeyControllerMetadata, + name: controllerName, + state: { ...getDefaultPasskeyControllerState(), ...state }, + }); + + this.#rpID = rpID; + this.#rpName = rpName; + this.#expectedOrigin = expectedOrigin; + this.#userName = userName ?? rpName; + this.#userDisplayName = userDisplayName ?? rpName; + } + + #requireEnrolled(): PasskeyRecord { + const record = this.state.passkeyRecord; + if (!record) { + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.NotEnrolled, + { + code: PasskeyControllerErrorCode.NotEnrolled, + }, + ); + } + return record; + } + + #getChallengeFromClientData(clientDataJSON: string): string { + return decodeClientDataJSON(clientDataJSON).challenge; + } + + /** + * Checks if the passkey is enrolled. + * + * @returns Whether the passkey is enrolled. + */ + isPasskeyEnrolled(): boolean { + return passkeyControllerSelectors.selectIsPasskeyEnrolled(this.state); + } + + /** + * Registration options for enrolling a passkey. + * + * Call before {@link protectVaultKeyWithPasskey}. + * + * @param creationOptionsConfig - Optional configuration. + * @param creationOptionsConfig.prfAvailable - Omit PRF when `false`. Default `true`. + * @returns Options for `navigator.credentials.create()`. + */ + generateRegistrationOptions(creationOptionsConfig?: { + prfAvailable?: boolean; + }): PasskeyRegistrationOptions { + const includePrf = creationOptionsConfig?.prfAvailable !== false; + const prfSalt = includePrf + ? bytesToBase64URL(randomBytes(32).slice()) + : undefined; + const userHandle = bytesToBase64URL(randomBytes(64).slice()); + const challenge = bytesToBase64URL(randomBytes(32).slice()); + + const extensions: Record = {}; + if (prfSalt) { + extensions.prf = { eval: { first: prfSalt } }; + } + + const options: PasskeyRegistrationOptions = { + rp: { name: this.#rpName, id: this.#rpID }, + user: { + id: userHandle, + name: this.#userName, + displayName: this.#userDisplayName, + }, + challenge, + pubKeyCredParams: [ + { alg: COSEALG.EdDSA, type: 'public-key' }, + { alg: COSEALG.ES256, type: 'public-key' }, + { alg: COSEALG.RS256, type: 'public-key' }, + ], + timeout: WEBAUTHN_TIMEOUT_MS, + authenticatorSelection: { + userVerification: 'preferred', + authenticatorAttachment: 'platform', + residentKey: 'preferred', + }, + hints: ['client-device', 'hybrid'], + attestation: 'none', + ...(Object.keys(extensions).length > 0 ? { extensions } : {}), + }; + + this.#ceremonyManager.saveRegistrationCeremony(challenge, { + userHandle, + prfSalt: prfSalt ?? '', + challenge, + createdAt: Date.now(), + }); + + return options; + } + + /** + * WebAuthn request options for authenticating with the enrolled passkey. + * + * Call before {@link retrieveVaultKeyWithPasskey}, + * {@link verifyPasskeyAuthentication}, or {@link renewVaultKeyProtection}. + * + * @returns Options for `navigator.credentials.get()`. + */ + generateAuthenticationOptions(): PasskeyAuthenticationOptions { + const record = this.#requireEnrolled(); + + const challenge = bytesToBase64URL(randomBytes(32).slice()); + + const extensions: Record = {}; + if (record.keyDerivation.method === 'prf') { + extensions.prf = { eval: { first: record.keyDerivation.prfSalt } }; + } + + const options: PasskeyAuthenticationOptions = { + challenge, + rpId: this.#rpID, + allowCredentials: [ + { + id: record.credential.id, + type: 'public-key', + transports: record.credential.transports, + }, + ], + userVerification: 'preferred', + hints: ['client-device', 'hybrid'], + timeout: WEBAUTHN_TIMEOUT_MS, + extensions, + }; + + this.#ceremonyManager.saveAuthenticationCeremony(challenge, { + challenge, + createdAt: Date.now(), + }); + + return options; + } + + /** + * Completes enrollment and binds the vault key to the new passkey. + * + * @param params - Protection parameters. + * @param params.registrationResponse - Credential from `navigator.credentials.create()`. + * @param params.vaultKey - Vault encryption key to protect. + */ + async protectVaultKeyWithPasskey(params: { + registrationResponse: PasskeyRegistrationResponse; + vaultKey: string; + }): Promise { + const { registrationResponse, vaultKey } = params; + + // get challenge + const challenge = this.#getChallengeFromClientData( + registrationResponse.response.clientDataJSON, + ); + const registrationCeremony = + this.#ceremonyManager.getRegistrationCeremony(challenge); + if (!registrationCeremony) { + log('No active passkey registration ceremony for challenge'); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.NoRegistrationCeremony, + { code: PasskeyControllerErrorCode.NoRegistrationCeremony }, + ); + } + + try { + // verify registration response + const { verified, registrationInfo } = await verifyRegistrationResponse({ + response: registrationResponse, + expectedChallenge: registrationCeremony.challenge, + expectedOrigin: this.#expectedOrigin, + expectedRPID: this.#rpID, + requireUserVerification: false, + }).catch((error) => { + log('Error verifying passkey registration response', error); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.RegistrationVerificationFailed, + { + code: PasskeyControllerErrorCode.RegistrationVerificationFailed, + cause: error instanceof Error ? error : new Error(String(error)), + }, + ); + }); + if (!verified || !registrationInfo) { + log( + 'Passkey registration verification returned unverified or missing registration info', + ); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.RegistrationVerificationFailed, + { code: PasskeyControllerErrorCode.RegistrationVerificationFailed }, + ); + } + + // derive key + const { encKey, keyDerivation } = deriveKeyFromRegistrationResponse( + registrationResponse, + registrationCeremony, + registrationInfo.credentialId, + ); + + // encrypt vault key + const { ciphertext, iv } = encryptWithKey(vaultKey, encKey); + + // persist passkey record + this.update((state) => { + state.passkeyRecord = { + credential: { + id: registrationInfo.credentialId, + publicKey: bytesToBase64URL(registrationInfo.publicKey), + counter: registrationInfo.counter, + transports: registrationInfo.transports, + aaguid: registrationInfo.aaguid, + }, + encryptedVaultKey: { ciphertext, iv }, + keyDerivation, + }; + }); + } finally { + this.#ceremonyManager.deleteRegistrationCeremony(challenge); + } + } + + /** + * Returns the decrypted vault encryption key from the passkey authentication + * response. + * + * @param authenticationResponse - Credential from `navigator.credentials.get()`. + * @returns The vault encryption key. + */ + async retrieveVaultKeyWithPasskey( + authenticationResponse: PasskeyAuthenticationResponse, + ): Promise { + // verify authentication response + await this.#verifyAuthenticationResponse(authenticationResponse); + + // derive key (#verifyAuthenticationResponse guarantees enrolled) + const passkeyRecord = this.#requireEnrolled(); + const encKey = deriveKeyFromAuthenticationResponse( + authenticationResponse, + passkeyRecord, + ); + + // decrypt vault key + let vaultKey: string; + try { + vaultKey = decryptWithKey( + passkeyRecord.encryptedVaultKey.ciphertext, + passkeyRecord.encryptedVaultKey.iv, + encKey, + ); + } catch (cause) { + log( + 'Error decrypting vault key with passkey', + cause instanceof Error ? cause : new Error(String(cause)), + ); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.VaultKeyDecryptionFailed, + { + code: PasskeyControllerErrorCode.VaultKeyDecryptionFailed, + cause: cause instanceof Error ? cause : new Error(String(cause)), + }, + ); + } + + return vaultKey; + } + + /** + * Returns whether passkey authentication succeeds for this credential (same + * work as {@link retrieveVaultKeyWithPasskey} without exposing the vault key). + * + * Returns `false` only when the failure is a {@link PasskeyControllerError} + * with a defined `code`. Unexpected errors (e.g. malformed `clientDataJSON`, + * internal bugs) are rethrown. + * + * @param authenticationResponse - Credential from `navigator.credentials.get()`. + * @returns `true` if authentication succeeds, otherwise `false`. + */ + async verifyPasskeyAuthentication( + authenticationResponse: PasskeyAuthenticationResponse, + ): Promise { + try { + await this.retrieveVaultKeyWithPasskey(authenticationResponse); + return true; + } catch (error: unknown) { + if (error instanceof PasskeyControllerError && error.code !== undefined) { + return false; + } + throw error; + } + } + + /** + * Updates the vault encryption key for the same passkey (e.g. after a password change). + * + * Caller MUST first verify the assertion via {@link verifyPasskeyAuthentication} + * or {@link retrieveVaultKeyWithPasskey}. This method does not re-verify + * because the ceremony is single-use (deleted on verify) and the signature + * counter is advanced (replay would be rejected). Authentication here is + * enforced by the prior verification plus the `oldVaultKey` match below. + * + * @param params - Renewal parameters. + * @param params.authenticationResponse - Credential from `navigator.credentials.get()`, + * already verified by the caller. + * @param params.oldVaultKey - Expected current vault key. + * @param params.newVaultKey - New vault key to protect. + */ + async renewVaultKeyProtection(params: { + authenticationResponse: PasskeyAuthenticationResponse; + oldVaultKey: string; + newVaultKey: string; + }): Promise { + const { authenticationResponse } = params; + const passkeyRecord = this.#requireEnrolled(); + + // derive key + const encKey = deriveKeyFromAuthenticationResponse( + authenticationResponse, + passkeyRecord, + ); + + // decrypt vault key + let decryptedVaultKey: string; + try { + decryptedVaultKey = decryptWithKey( + passkeyRecord.encryptedVaultKey.ciphertext, + passkeyRecord.encryptedVaultKey.iv, + encKey, + ); + } catch (error) { + log( + 'Error decrypting vault key during passkey vault key renewal', + error instanceof Error ? error : new Error(String(error)), + ); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.VaultKeyDecryptionFailed, + { + code: PasskeyControllerErrorCode.VaultKeyDecryptionFailed, + cause: error instanceof Error ? error : new Error(String(error)), + }, + ); + } + + // check if vault key matches + const { oldVaultKey, newVaultKey } = params; + if ( + !areUint8ArraysEqual( + stringToBytes(decryptedVaultKey), + stringToBytes(oldVaultKey), + ) + ) { + log( + 'Passkey renewal rejected: decrypted vault key does not match oldVaultKey', + ); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.VaultKeyMismatch, + { code: PasskeyControllerErrorCode.VaultKeyMismatch }, + ); + } + + // encrypt new vault key + const { ciphertext, iv } = encryptWithKey(newVaultKey, encKey); + + // persist passkey record (mutate current state only for vault key material) + this.update((state) => { + if (!state.passkeyRecord) { + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.NotEnrolled, + { + code: PasskeyControllerErrorCode.NotEnrolled, + }, + ); + } + state.passkeyRecord.encryptedVaultKey = { ciphertext, iv }; + }); + } + + /** + * Unenrolls the passkey, removing the protected vault key material. + */ + removePasskey(): void { + this.update(() => getDefaultPasskeyControllerState()); + this.#ceremonyManager.clear(); + } + + /** + * Resets state and clears in-flight registration/authentication ceremonies. + */ + clearState(): void { + this.removePasskey(); + } + + /** + * Releases all in-flight ceremony state and tears down the messenger. + */ + destroy(): void { + this.#ceremonyManager.clear(); + super.destroy(); + } + + /** + * Verifies an authentication response for the enrolled passkey. + * + * @param authenticationResponse - Authentication result JSON. + */ + async #verifyAuthenticationResponse( + authenticationResponse: PasskeyAuthenticationResponse, + ): Promise { + let challenge: string | undefined; + try { + // get challenge + challenge = this.#getChallengeFromClientData( + authenticationResponse.response.clientDataJSON, + ); + + // get passkey record + const record = this.#requireEnrolled(); + + // get authentication ceremony + const authenticationCeremony = + this.#ceremonyManager.getAuthenticationCeremony(challenge); + if (!authenticationCeremony) { + log('No active passkey authentication ceremony for challenge'); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.NoAuthenticationCeremony, + { code: PasskeyControllerErrorCode.NoAuthenticationCeremony }, + ); + } + + // verify authentication response + const result = await verifyAuthenticationResponse({ + response: authenticationResponse, + expectedChallenge: authenticationCeremony.challenge, + expectedOrigin: this.#expectedOrigin, + expectedRPID: this.#rpID, + credential: { + id: record.credential.id, + publicKey: base64URLToBytes(record.credential.publicKey), + counter: record.credential.counter, + transports: record.credential.transports, + }, + // UV optional for device compatibility; vault key remains password-gated. + requireUserVerification: false, + }).catch((error) => { + log( + 'Error verifying passkey authentication response', + error instanceof Error ? error : new Error(String(error)), + ); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.AuthenticationVerificationFailed, + { + code: PasskeyControllerErrorCode.AuthenticationVerificationFailed, + cause: error instanceof Error ? error : new Error(String(error)), + }, + ); + }); + if (!result.verified) { + log('Passkey authentication verification returned unverified'); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.AuthenticationVerificationFailed, + { + code: PasskeyControllerErrorCode.AuthenticationVerificationFailed, + }, + ); + } + + // persist passkey record with updated counter without clobbering concurrent updates + this.update((state) => { + if (!state.passkeyRecord) { + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.NotEnrolled, + { code: PasskeyControllerErrorCode.NotEnrolled }, + ); + } + const latest = state.passkeyRecord; + latest.credential.counter = Math.max( + result.authenticationInfo.newCounter, + latest.credential.counter, + ); + }); + } finally { + if (challenge) { + this.#ceremonyManager.deleteAuthenticationCeremony(challenge); + } + } + } +} diff --git a/packages/passkey-controller/src/ceremony-manager.test.ts b/packages/passkey-controller/src/ceremony-manager.test.ts new file mode 100644 index 00000000000..d5df52e8c0c --- /dev/null +++ b/packages/passkey-controller/src/ceremony-manager.test.ts @@ -0,0 +1,178 @@ +import { + CEREMONY_MAX_AGE_MS, + CeremonyManager, + MAX_CONCURRENT_PASSKEY_CEREMONIES, +} from './ceremony-manager'; + +describe('CeremonyManager', () => { + const baseReg = { userHandle: 'u', prfSalt: '' }; + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('registration flow', () => { + it('save stores ceremony state retrievable by challenge', () => { + const manager = new CeremonyManager(); + const now = 1_000_000; + jest.setSystemTime(now); + manager.saveRegistrationCeremony('chal1', { + ...baseReg, + challenge: 'chal1', + createdAt: now, + }); + expect(manager.getRegistrationCeremony('chal1')).toMatchObject({ + challenge: 'chal1', + createdAt: now, + }); + }); + + it('getRegistrationCeremony prunes entries older than CEREMONY_MAX_AGE_MS before lookup', () => { + const manager = new CeremonyManager(); + const tOld = 100_000; + const tNew = 150_000; + const pruneAt = 200_000; + jest.setSystemTime(tOld); + manager.saveRegistrationCeremony('old', { + ...baseReg, + challenge: 'old', + createdAt: tOld, + }); + jest.setSystemTime(tNew); + manager.saveRegistrationCeremony('new', { + ...baseReg, + challenge: 'new', + createdAt: tNew, + }); + jest.setSystemTime(pruneAt); + expect(pruneAt - tOld).toBeGreaterThan(CEREMONY_MAX_AGE_MS); + expect(manager.getRegistrationCeremony('new')).toMatchObject({ + challenge: 'new', + createdAt: tNew, + }); + expect(manager.getRegistrationCeremony('old')).toBeUndefined(); + }); + + it('evicts oldest createdAt when at capacity', () => { + const manager = new CeremonyManager(); + const cap = MAX_CONCURRENT_PASSKEY_CEREMONIES; + for (let i = 0; i <= cap; i += 1) { + const createdAt = 10 + i; + jest.setSystemTime(createdAt); + manager.saveRegistrationCeremony(`k${i}`, { + ...baseReg, + challenge: `k${i}`, + createdAt, + }); + } + expect(manager.getRegistrationCeremony('k0')).toBeUndefined(); + expect(manager.getRegistrationCeremony('k1')).toMatchObject({ + challenge: 'k1', + createdAt: 11, + }); + }); + + it('still saves when at capacity and all existing ceremonies have NaN createdAt', () => { + const manager = new CeremonyManager(); + const cap = MAX_CONCURRENT_PASSKEY_CEREMONIES; + for (let i = 0; i < cap; i += 1) { + jest.setSystemTime(1000 + i); + manager.saveRegistrationCeremony(`k${i}`, { + ...baseReg, + challenge: `k${i}`, + createdAt: Number.NaN, + }); + } + jest.setSystemTime(5000); + manager.saveRegistrationCeremony('newest', { + ...baseReg, + challenge: 'newest', + createdAt: 5000, + }); + expect(manager.getRegistrationCeremony('newest')).toMatchObject({ + challenge: 'newest', + createdAt: 5000, + }); + }); + + it('delete removes a single entry', () => { + const manager = new CeremonyManager(); + jest.setSystemTime(0); + manager.saveRegistrationCeremony('x', { + ...baseReg, + challenge: 'x', + createdAt: 0, + }); + expect(manager.deleteRegistrationCeremony('x')).toBe(true); + expect(manager.getRegistrationCeremony('x')).toBeUndefined(); + expect(manager.deleteRegistrationCeremony('missing')).toBe(false); + }); + + it('clear removes registration entries', () => { + const manager = new CeremonyManager(); + jest.setSystemTime(0); + manager.saveRegistrationCeremony('a', { + ...baseReg, + challenge: 'a', + createdAt: 0, + }); + manager.saveRegistrationCeremony('b', { + ...baseReg, + challenge: 'b', + createdAt: 0, + }); + manager.clear(); + expect(manager.getRegistrationCeremony('a')).toBeUndefined(); + expect(manager.getRegistrationCeremony('b')).toBeUndefined(); + }); + }); + + it('registration and authentication maps are independent', () => { + const manager = new CeremonyManager(); + const now = 1_000_000; + jest.setSystemTime(now); + + manager.saveRegistrationCeremony('reg-chal', { + userHandle: 'uh', + prfSalt: '', + challenge: 'reg-chal', + createdAt: now, + }); + manager.saveAuthenticationCeremony('auth-chal', { + challenge: 'auth-chal', + createdAt: now, + }); + + expect(manager.getRegistrationCeremony('reg-chal')).toBeDefined(); + expect(manager.getAuthenticationCeremony('auth-chal')).toBeDefined(); + + jest.setSystemTime(now + CEREMONY_MAX_AGE_MS + 1); + expect(manager.getRegistrationCeremony('reg-chal')).toBeUndefined(); + + jest.setSystemTime(now); + manager.saveRegistrationCeremony('reg2', { + userHandle: 'uh2', + prfSalt: '', + challenge: 'reg2', + createdAt: now, + }); + jest.setSystemTime(now + CEREMONY_MAX_AGE_MS + 1); + expect(manager.getAuthenticationCeremony('auth-chal')).toBeUndefined(); + + jest.setSystemTime(now); + manager.saveAuthenticationCeremony('auth2', { + challenge: 'auth2', + createdAt: now, + }); + expect(manager.deleteRegistrationCeremony('reg2')).toBe(true); + expect(manager.deleteAuthenticationCeremony('auth2')).toBe(true); + + manager.clear(); + expect(manager.getRegistrationCeremony('reg2')).toBeUndefined(); + expect(manager.getAuthenticationCeremony('auth2')).toBeUndefined(); + }); +}); diff --git a/packages/passkey-controller/src/ceremony-manager.ts b/packages/passkey-controller/src/ceremony-manager.ts new file mode 100644 index 00000000000..8b735ba5cb3 --- /dev/null +++ b/packages/passkey-controller/src/ceremony-manager.ts @@ -0,0 +1,170 @@ +import type { + PasskeyAuthenticationCeremony, + PasskeyRegistrationCeremony, +} from './types'; + +/** WebAuthn `timeout` for credential creation and assertion (ms). */ +export const WEBAUTHN_TIMEOUT_MS = 60_000; + +/** + * Extra allowance beyond {@link WEBAUTHN_TIMEOUT_MS} before in-memory + * ceremony state is discarded (covers slow UX and clock skew). + */ +export const CEREMONY_TTL_SLACK_MS = 15_000; + +/** + * Maximum age for in-flight registration or authentication ceremony state + * (between options and verified response). This bounds the lifetime of a + * single WebAuthn ceremony only; it is not a user login session timeout. + */ +export const CEREMONY_MAX_AGE_MS = WEBAUTHN_TIMEOUT_MS + CEREMONY_TTL_SLACK_MS; + +/** + * Upper bound on concurrent in-memory ceremonies per flow type (registration + * vs authentication), for abuse / leak protection. + */ +export const MAX_CONCURRENT_PASSKEY_CEREMONIES = 16; + +type CeremonyFlow = 'registration' | 'authentication'; + +/** + * In-memory store for in-flight WebAuthn ceremonies (registration vs authentication), + * keyed by base64url challenge. Enforces TTL and a per-flow size cap; not user session state. + */ +export class CeremonyManager { + readonly #registrationMap = new Map(); + + readonly #authenticationMap = new Map< + string, + PasskeyAuthenticationCeremony + >(); + + /** + * Challenge-keyed map for prune/capacity helpers. + * + * @param ceremonyType - Which in-flight ceremony map to use. + * @returns The registration or authentication ceremony map for the given flow. + */ + #getMap( + ceremonyType: CeremonyFlow, + ): Map { + return ceremonyType === 'registration' + ? this.#registrationMap + : this.#authenticationMap; + } + + #pruneExpired(ceremonyType: CeremonyFlow): void { + const now = Date.now(); + const map = this.#getMap(ceremonyType); + for (const [key, ceremony] of map) { + if (now - ceremony.createdAt > CEREMONY_MAX_AGE_MS) { + map.delete(key); + } + } + } + + /** + * Removes the oldest entry (by `createdAt`) until size is below the cap. + * + * @param ceremonyType - Which in-flight ceremony map to evict from. + */ + #enforceCapacity(ceremonyType: CeremonyFlow): void { + const map = this.#getMap(ceremonyType); + while (map.size >= MAX_CONCURRENT_PASSKEY_CEREMONIES) { + let oldestKey: string | undefined; + let oldestTime = Infinity; + for (const [mapKey, ceremony] of map) { + if (ceremony.createdAt < oldestTime) { + oldestTime = ceremony.createdAt; + oldestKey = mapKey; + } + } + if (oldestKey === undefined) { + break; + } + map.delete(oldestKey); + } + } + + /** + * Records registration ceremony state after pruning expired rows and evicting oldest if at cap. + * + * @param challenge - Same base64url challenge as in the creation options `challenge` field. + * @param ceremony - Payload to retrieve when the registration response returns. + */ + saveRegistrationCeremony( + challenge: string, + ceremony: PasskeyRegistrationCeremony, + ): void { + this.#pruneExpired('registration'); + this.#enforceCapacity('registration'); + this.#registrationMap.set(challenge, ceremony); + } + + /** + * Records authentication ceremony state after pruning expired rows and evicting oldest if at cap. + * + * @param challenge - Same base64url challenge as in the request options `challenge` field. + * @param ceremony - Payload to retrieve when the assertion response returns. + */ + saveAuthenticationCeremony( + challenge: string, + ceremony: PasskeyAuthenticationCeremony, + ): void { + this.#pruneExpired('authentication'); + this.#enforceCapacity('authentication'); + this.#authenticationMap.set(challenge, ceremony); + } + + /** + * Returns registration ceremony for a challenge, pruning expired entries on this map first. + * + * @param challenge - Base64url challenge from decoded `clientDataJSON` (matches stored key). + * @returns Stored ceremony, or `undefined` if none or expired. + */ + getRegistrationCeremony( + challenge: string, + ): PasskeyRegistrationCeremony | undefined { + this.#pruneExpired('registration'); + return this.#registrationMap.get(challenge); + } + + /** + * Returns authentication ceremony for a challenge, pruning expired entries on this map first. + * + * @param challenge - Base64url challenge from decoded `clientDataJSON` (matches stored key). + * @returns Stored ceremony, or `undefined` if none or expired. + */ + getAuthenticationCeremony( + challenge: string, + ): PasskeyAuthenticationCeremony | undefined { + this.#pruneExpired('authentication'); + return this.#authenticationMap.get(challenge); + } + + /** + * Removes a registration ceremony by challenge. + * + * @param challenge - Map key for the ceremony to remove. + * @returns Whether an entry was deleted. + */ + deleteRegistrationCeremony(challenge: string): boolean { + return this.#registrationMap.delete(challenge); + } + + /** + * Removes an authentication ceremony by challenge. + * + * @param challenge - Map key for the ceremony to remove. + * @returns Whether an entry was deleted. + */ + deleteAuthenticationCeremony(challenge: string): boolean { + return this.#authenticationMap.delete(challenge); + } + + /** Drops all in-flight registration and authentication ceremonies. */ + clear(): void { + this.#registrationMap.clear(); + this.#authenticationMap.clear(); + } +} diff --git a/packages/passkey-controller/src/constants.ts b/packages/passkey-controller/src/constants.ts new file mode 100644 index 00000000000..595a8549120 --- /dev/null +++ b/packages/passkey-controller/src/constants.ts @@ -0,0 +1,33 @@ +export const controllerName = 'PasskeyController'; + +/** + * Stable programmatic codes for {@link PasskeyControllerError}. + * Use these instead of matching `message` strings. + */ +export const PasskeyControllerErrorCode = { + NotEnrolled: 'not_enrolled', + NoRegistrationCeremony: 'no_registration_ceremony', + RegistrationVerificationFailed: 'registration_verification_failed', + NoAuthenticationCeremony: 'no_authentication_ceremony', + AuthenticationVerificationFailed: 'authentication_verification_failed', + MissingKeyMaterial: 'missing_key_material', + VaultKeyDecryptionFailed: 'vault_key_decryption_failed', + VaultKeyMismatch: 'vault_key_mismatch', +} as const; + +export type PasskeyControllerErrorCode = + (typeof PasskeyControllerErrorCode)[keyof typeof PasskeyControllerErrorCode]; + +/** + * Human-readable messages for {@link PasskeyControllerError}. + */ +export enum PasskeyControllerErrorMessage { + NotEnrolled = `${controllerName} - Passkey is not enrolled`, + NoRegistrationCeremony = `${controllerName} - No active passkey registration ceremony`, + RegistrationVerificationFailed = `${controllerName} - Passkey registration verification failed`, + NoAuthenticationCeremony = `${controllerName} - No active passkey authentication ceremony`, + AuthenticationVerificationFailed = `${controllerName} - Passkey authentication verification failed`, + MissingKeyMaterial = `${controllerName} - Passkey assertion missing required key material`, + VaultKeyDecryptionFailed = `${controllerName} - Passkey vault key decryption failed`, + VaultKeyMismatch = `${controllerName} - Passkey authentication does not match the current vault key`, +} diff --git a/packages/passkey-controller/src/errors.test.ts b/packages/passkey-controller/src/errors.test.ts new file mode 100644 index 00000000000..ccd249f4d01 --- /dev/null +++ b/packages/passkey-controller/src/errors.test.ts @@ -0,0 +1,79 @@ +import { + PasskeyControllerErrorCode, + PasskeyControllerErrorMessage, +} from './constants'; +import { PasskeyControllerError } from './errors'; + +describe('PasskeyControllerError', () => { + it('sets code and cause from options', () => { + const cause = new Error('inner'); + const controllerError = new PasskeyControllerError( + PasskeyControllerErrorMessage.VaultKeyDecryptionFailed, + { + code: PasskeyControllerErrorCode.VaultKeyDecryptionFailed, + cause, + }, + ); + expect(controllerError.code).toBe( + PasskeyControllerErrorCode.VaultKeyDecryptionFailed, + ); + expect(controllerError.cause).toBe(cause); + expect(controllerError.toJSON()).toMatchObject({ + name: 'PasskeyControllerError', + code: PasskeyControllerErrorCode.VaultKeyDecryptionFailed, + message: PasskeyControllerErrorMessage.VaultKeyDecryptionFailed, + }); + }); + + it('supports Error as second argument for cause', () => { + const inner = new Error('x'); + const controllerError = new PasskeyControllerError('msg', inner); + expect(controllerError.cause).toBe(inner); + }); + + it('sets context from options', () => { + const controllerError = new PasskeyControllerError('msg', { + code: PasskeyControllerErrorCode.NotEnrolled, + context: { detail: 'x' }, + }); + expect(controllerError.context).toStrictEqual({ detail: 'x' }); + expect(controllerError.toJSON().context).toStrictEqual({ detail: 'x' }); + }); + + it('serializes cause in toJSON', () => { + const cause = new Error('inner'); + const controllerError = new PasskeyControllerError('msg', { + code: PasskeyControllerErrorCode.NotEnrolled, + cause, + }); + expect(controllerError.toJSON().cause).toMatchObject({ + name: 'Error', + message: 'inner', + }); + }); + + it('toString includes code when set', () => { + const controllerError = new PasskeyControllerError('msg', { + code: PasskeyControllerErrorCode.NotEnrolled, + }); + expect(controllerError.toString()).toContain('[not_enrolled]'); + }); + + it('toString includes cause when set', () => { + const cause = new Error('inner'); + const controllerError = new PasskeyControllerError('msg', { cause }); + expect(controllerError.toString()).toContain('Caused by:'); + expect(controllerError.toString()).toContain('inner'); + }); + + it('toString includes code and cause when both are set', () => { + const cause = new Error('inner'); + const controllerError = new PasskeyControllerError('msg', { + code: PasskeyControllerErrorCode.NotEnrolled, + cause, + }); + const text = controllerError.toString(); + expect(text).toContain('[not_enrolled]'); + expect(text).toContain('Caused by:'); + }); +}); diff --git a/packages/passkey-controller/src/errors.ts b/packages/passkey-controller/src/errors.ts new file mode 100644 index 00000000000..e0bbf6ebbe4 --- /dev/null +++ b/packages/passkey-controller/src/errors.ts @@ -0,0 +1,86 @@ +import type { PasskeyControllerErrorCode as PasskeyControllerErrorCodeType } from './constants'; + +/** + * Options for creating a {@link PasskeyControllerError}. + */ +export type PasskeyControllerErrorOptions = { + /** + * The underlying error that caused this error (for error chaining). + */ + cause?: Error; + /** + * Stable code for programmatic handling (see {@link PasskeyControllerErrorCode}). + */ + code?: PasskeyControllerErrorCodeType; + /** + * Additional context for debugging or reporting. + */ + context?: Record; +}; + +/** + * Error class for PasskeyController-related errors. + */ +export class PasskeyControllerError extends Error { + code?: PasskeyControllerErrorCodeType; + + context?: Record; + + cause?: Error; + + /** + * @param message - The error message. + * @param options - Error options or an `Error` instance used as `cause` (Keyring-style overload). + */ + constructor( + message: string, + options?: PasskeyControllerErrorOptions | Error, + ) { + super(message); + this.name = 'PasskeyControllerError'; + + const cause = options instanceof Error ? options : options?.cause; + const code = options instanceof Error ? undefined : options?.code; + const context = options instanceof Error ? undefined : options?.context; + + if (cause) { + this.cause = cause; + } + if (code) { + this.code = code; + } + if (context) { + this.context = context; + } + + Object.setPrototypeOf(this, PasskeyControllerError.prototype); + } + + toJSON(): Record { + return { + name: this.name, + message: this.message, + code: this.code, + context: this.context, + stack: this.stack, + cause: this.cause + ? { + name: this.cause.name, + message: this.cause.message, + stack: this.cause.stack, + } + : undefined, + }; + } + + override toString(): string { + let result = `${this.name}: ${this.message}`; + if (this.code) { + result += ` [${this.code}]`; + } + if (this.cause) { + result += `\n Caused by: ${this.cause}`; + } + return result; + } +} diff --git a/packages/passkey-controller/src/index.ts b/packages/passkey-controller/src/index.ts new file mode 100644 index 00000000000..013ec299595 --- /dev/null +++ b/packages/passkey-controller/src/index.ts @@ -0,0 +1,32 @@ +export { + PasskeyControllerErrorCode, + PasskeyControllerErrorMessage, +} from './constants'; +export { PasskeyControllerError } from './errors'; +export { + PasskeyController, + getDefaultPasskeyControllerState, + passkeyControllerSelectors, +} from './PasskeyController'; +export type { + PasskeyControllerState, + PasskeyControllerMessenger, + PasskeyControllerGetStateAction, + PasskeyControllerActions, + PasskeyControllerStateChangedEvent, + PasskeyControllerEvents, +} from './PasskeyController'; +export type { + PasskeyCredentialInfo, + PasskeyDerivationMethod, + PasskeyKeyDerivation, + PasskeyRecord, + PrfEvalExtension, + PrfClientExtensionResults, +} from './types'; +export type { + PasskeyRegistrationOptions, + PasskeyRegistrationResponse, + PasskeyAuthenticationOptions, + PasskeyAuthenticationResponse, +} from './webauthn/types'; diff --git a/packages/passkey-controller/src/key-derivation.test.ts b/packages/passkey-controller/src/key-derivation.test.ts new file mode 100644 index 00000000000..a47b89766df --- /dev/null +++ b/packages/passkey-controller/src/key-derivation.test.ts @@ -0,0 +1,271 @@ +import { PasskeyControllerErrorMessage } from './constants'; +import { + deriveKeyFromAuthenticationResponse, + deriveKeyFromRegistrationResponse, +} from './key-derivation'; +import type { PasskeyRecord, PasskeyRegistrationCeremony } from './types'; +import type { + PasskeyAuthenticationResponse, + PasskeyRegistrationResponse, +} from './webauthn/types'; + +function b64url(str: string): string { + return btoa(str) + .replace(/\+/gu, '-') + .replace(/\//gu, '_') + .replace(/[=]+$/u, ''); +} + +const CREDENTIAL_ID = b64url('credential-id-bytes'); +const USER_HANDLE = b64url('user-handle-bytes'); +const PRF_SALT = b64url('prf-salt-bytes'); +const PRF_FIRST = b64url('prf-output-bytes'); + +function makeRegistrationCeremony(): PasskeyRegistrationCeremony { + return { + userHandle: USER_HANDLE, + prfSalt: PRF_SALT, + challenge: b64url('challenge'), + createdAt: 0, + }; +} + +function makeRegistrationResponse( + extensionResults: Record, +): PasskeyRegistrationResponse { + return { + id: CREDENTIAL_ID, + rawId: CREDENTIAL_ID, + type: 'public-key', + response: { + clientDataJSON: '', + attestationObject: '', + }, + clientExtensionResults: extensionResults, + }; +} + +function makeAuthenticationResponse( + extensionResults: Record, + userHandle?: string, +): PasskeyAuthenticationResponse { + return { + id: CREDENTIAL_ID, + rawId: CREDENTIAL_ID, + type: 'public-key', + response: { + clientDataJSON: '', + authenticatorData: '', + signature: '', + userHandle, + }, + clientExtensionResults: extensionResults, + }; +} + +function makeRecord(derivationMethod: 'prf' | 'userHandle'): PasskeyRecord { + return { + credential: { + id: CREDENTIAL_ID, + publicKey: 'pubkey', + counter: 0, + }, + encryptedVaultKey: { + ciphertext: 'ciphertext', + iv: 'iv', + }, + keyDerivation: + derivationMethod === 'prf' + ? { method: 'prf', prfSalt: PRF_SALT } + : { method: 'userHandle' }, + }; +} + +describe('deriveKeyFromRegistrationResponse', () => { + it('uses PRF output when prf.results.first is present', () => { + const response = makeRegistrationResponse({ + prf: { results: { first: PRF_FIRST } }, + }); + + const { encKey, keyDerivation } = deriveKeyFromRegistrationResponse( + response, + makeRegistrationCeremony(), + CREDENTIAL_ID, + ); + + expect(keyDerivation).toStrictEqual({ method: 'prf', prfSalt: PRF_SALT }); + expect(encKey).toBeInstanceOf(Uint8Array); + expect(encKey).toHaveLength(32); + }); + + it('uses PRF output when prf.enabled is true and results.first is present', () => { + const response = makeRegistrationResponse({ + prf: { enabled: true, results: { first: PRF_FIRST } }, + }); + + const { keyDerivation } = deriveKeyFromRegistrationResponse( + response, + makeRegistrationCeremony(), + CREDENTIAL_ID, + ); + + expect(keyDerivation).toStrictEqual({ method: 'prf', prfSalt: PRF_SALT }); + }); + + it('falls back to userHandle when prf.enabled is true but results.first is absent', () => { + const response = makeRegistrationResponse({ + prf: { enabled: true }, + }); + + const { keyDerivation } = deriveKeyFromRegistrationResponse( + response, + makeRegistrationCeremony(), + CREDENTIAL_ID, + ); + + expect(keyDerivation).toStrictEqual({ method: 'userHandle' }); + }); + + it('falls back to userHandle when PRF is absent', () => { + const response = makeRegistrationResponse({}); + + const { encKey, keyDerivation } = deriveKeyFromRegistrationResponse( + response, + makeRegistrationCeremony(), + CREDENTIAL_ID, + ); + + expect(keyDerivation).toStrictEqual({ method: 'userHandle' }); + expect(encKey).toBeInstanceOf(Uint8Array); + expect(encKey).toHaveLength(32); + }); + + it('falls back to userHandle when prf.results.first is empty string', () => { + const response = makeRegistrationResponse({ + prf: { results: { first: '' } }, + }); + + const { keyDerivation } = deriveKeyFromRegistrationResponse( + response, + makeRegistrationCeremony(), + CREDENTIAL_ID, + ); + + expect(keyDerivation).toStrictEqual({ method: 'userHandle' }); + }); + + it('produces different keys for different verified credential IDs', () => { + const response = makeRegistrationResponse({}); + const ceremony = makeRegistrationCeremony(); + + const { encKey: key1 } = deriveKeyFromRegistrationResponse( + response, + ceremony, + CREDENTIAL_ID, + ); + const { encKey: key2 } = deriveKeyFromRegistrationResponse( + response, + ceremony, + b64url('different-cred-id'), + ); + + expect(key1).not.toStrictEqual(key2); + }); + + it('produces different keys for PRF vs userHandle', () => { + const registrationCeremony = makeRegistrationCeremony(); + + const responseWithPrf = makeRegistrationResponse({ + prf: { results: { first: PRF_FIRST } }, + }); + const responseWithoutPrf = makeRegistrationResponse({}); + + const { encKey: prfKey } = deriveKeyFromRegistrationResponse( + responseWithPrf, + registrationCeremony, + CREDENTIAL_ID, + ); + const { encKey: uhKey } = deriveKeyFromRegistrationResponse( + responseWithoutPrf, + registrationCeremony, + CREDENTIAL_ID, + ); + + expect(prfKey).not.toStrictEqual(uhKey); + }); +}); + +describe('deriveKeyFromAuthenticationResponse', () => { + it('uses PRF output when keyDerivation.method is prf', () => { + const response = makeAuthenticationResponse( + { prf: { results: { first: PRF_FIRST } } }, + USER_HANDLE, + ); + + const encKey = deriveKeyFromAuthenticationResponse( + response, + makeRecord('prf'), + ); + + expect(encKey).toBeInstanceOf(Uint8Array); + expect(encKey).toHaveLength(32); + }); + + it('uses userHandle when keyDerivation.method is userHandle', () => { + const response = makeAuthenticationResponse({}, USER_HANDLE); + + const encKey = deriveKeyFromAuthenticationResponse( + response, + makeRecord('userHandle'), + ); + + expect(encKey).toBeInstanceOf(Uint8Array); + expect(encKey).toHaveLength(32); + }); + + it('throws when userHandle derivation is needed but userHandle is missing', () => { + const response = makeAuthenticationResponse({}); + + expect(() => + deriveKeyFromAuthenticationResponse(response, makeRecord('userHandle')), + ).toThrow(PasskeyControllerErrorMessage.MissingKeyMaterial); + }); + + it('throws when PRF derivation is needed but PRF output is missing', () => { + const response = makeAuthenticationResponse({}); + + expect(() => + deriveKeyFromAuthenticationResponse(response, makeRecord('prf')), + ).toThrow(PasskeyControllerErrorMessage.MissingKeyMaterial); + }); + + it('throws when PRF derivation is needed but prf.results.first is empty', () => { + const response = makeAuthenticationResponse({ + prf: { results: { first: '' } }, + }); + + expect(() => + deriveKeyFromAuthenticationResponse(response, makeRecord('prf')), + ).toThrow(PasskeyControllerErrorMessage.MissingKeyMaterial); + }); + + it('produces consistent keys across registration and authentication', () => { + const regResponse = makeRegistrationResponse({}); + const registrationCeremony = makeRegistrationCeremony(); + + const { encKey: regKey } = deriveKeyFromRegistrationResponse( + regResponse, + registrationCeremony, + CREDENTIAL_ID, + ); + + const authResponse = makeAuthenticationResponse({}, USER_HANDLE); + + const authKey = deriveKeyFromAuthenticationResponse( + authResponse, + makeRecord('userHandle'), + ); + + expect(regKey).toStrictEqual(authKey); + }); +}); diff --git a/packages/passkey-controller/src/key-derivation.ts b/packages/passkey-controller/src/key-derivation.ts new file mode 100644 index 00000000000..001ab5fe8df --- /dev/null +++ b/packages/passkey-controller/src/key-derivation.ts @@ -0,0 +1,111 @@ +import { + PasskeyControllerErrorCode, + PasskeyControllerErrorMessage, +} from './constants'; +import { PasskeyControllerError } from './errors'; +import type { + PasskeyKeyDerivation, + PasskeyRecord, + PasskeyRegistrationCeremony, + PrfClientExtensionResults, +} from './types'; +import { deriveEncryptionKey } from './utils/crypto'; +import { base64URLToBytes } from './utils/encoding'; +import type { + PasskeyAuthenticationResponse, + PasskeyRegistrationResponse, +} from './webauthn/types'; + +/** + * Derives an AES-256 wrapping key from a WebAuthn registration ceremony + * response. + * + * Uses the PRF output as HKDF input key material when + * `clientExtensionResults.prf.results.first` is a non-empty string; + * otherwise falls back to the random `userHandle` created during option + * generation (including when PRF is enabled but no output is present). + * + * @param registrationResponse - The registration credential result from + * `navigator.credentials.create()`. + * @param registrationCeremony - In-flight registration ceremony state from + * when `generateRegistrationOptions()` was called. + * @param verifiedCredentialId - Base64url credential id from verified + * authenticator data (same value as persisted `credential.id` after + * `verifyRegistrationResponse`), not the client wrapper field alone. + * @returns The derived 32-byte AES wrapping key and the + * {@link PasskeyKeyDerivation} parameters needed to reproduce it. + */ +export function deriveKeyFromRegistrationResponse( + registrationResponse: PasskeyRegistrationResponse, + registrationCeremony: PasskeyRegistrationCeremony, + verifiedCredentialId: string, +): { + encKey: Uint8Array; + keyDerivation: PasskeyKeyDerivation; +} { + const prfFirst = ( + registrationResponse.clientExtensionResults as PrfClientExtensionResults + )?.prf?.results?.first; + const hasPrfOutput = typeof prfFirst === 'string' && prfFirst.length > 0; + + const keyDerivation: PasskeyKeyDerivation = hasPrfOutput + ? { method: 'prf', prfSalt: registrationCeremony.prfSalt } + : { method: 'userHandle' }; + const ikm = + keyDerivation.method === 'prf' + ? base64URLToBytes(prfFirst as string) + : base64URLToBytes(registrationCeremony.userHandle); + const encKey = deriveEncryptionKey( + ikm, + base64URLToBytes(verifiedCredentialId), + ); + + return { encKey, keyDerivation }; +} + +/** + * Derives an AES-256 wrapping key from a WebAuthn authentication ceremony + * response. + * + * The derivation method is determined by `record.keyDerivation`: + * - `prf` -- uses the PRF evaluation result from `clientExtensionResults`. + * - `userHandle` -- uses the `userHandle` returned in the assertion. + * + * @param authenticationResponse - The authentication credential result + * from `navigator.credentials.get()`. + * @param record - The persisted passkey record that was created during + * enrollment. + * @returns The derived 32-byte AES wrapping key. + * @throws {@link PasskeyControllerError} with code `missing_key_material` if the + * required key material (PRF result or userHandle) is missing from the response. + */ +export function deriveKeyFromAuthenticationResponse( + authenticationResponse: PasskeyAuthenticationResponse, + record: PasskeyRecord, +): Uint8Array { + const { userHandle } = authenticationResponse.response; + const prfFirst = ( + authenticationResponse.clientExtensionResults as PrfClientExtensionResults + )?.prf?.results?.first; + const hasPrfOutput = typeof prfFirst === 'string' && prfFirst.length > 0; + + let ikm: Uint8Array; + if (record.keyDerivation.method === 'prf') { + if (!hasPrfOutput) { + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.MissingKeyMaterial, + { code: PasskeyControllerErrorCode.MissingKeyMaterial }, + ); + } + ikm = base64URLToBytes(prfFirst); + } else if (userHandle) { + ikm = base64URLToBytes(userHandle); + } else { + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.MissingKeyMaterial, + { code: PasskeyControllerErrorCode.MissingKeyMaterial }, + ); + } + + return deriveEncryptionKey(ikm, base64URLToBytes(record.credential.id)); +} diff --git a/packages/passkey-controller/src/logger.ts b/packages/passkey-controller/src/logger.ts new file mode 100644 index 00000000000..81fa93678f7 --- /dev/null +++ b/packages/passkey-controller/src/logger.ts @@ -0,0 +1,9 @@ +/* istanbul ignore file */ + +import { createProjectLogger, createModuleLogger } from '@metamask/utils'; + +import { controllerName } from './constants'; + +export const projectLogger = createProjectLogger(controllerName); + +export { createModuleLogger }; diff --git a/packages/passkey-controller/src/types.ts b/packages/passkey-controller/src/types.ts new file mode 100644 index 00000000000..68eefe8de2a --- /dev/null +++ b/packages/passkey-controller/src/types.ts @@ -0,0 +1,109 @@ +export type Base64String = string; + +export type Base64URLString = string; + +export type AuthenticatorTransportFuture = + | 'ble' + | 'cable' + | 'hybrid' + | 'internal' + | 'nfc' + | 'smart-card' + | 'usb'; + +/** + * WebAuthn credential metadata used to identify the passkey and verify + * subsequent assertions. + */ +export type PasskeyCredentialInfo = { + /** WebAuthn credential ID (base64url). */ + id: Base64URLString; + /** COSE-encoded credential public key (base64url) used to verify assertions. */ + publicKey: Base64URLString; + /** Authenticator signature counter for replay/clone detection. */ + counter: number; + /** Authenticator transports hint for `allowCredentials`. */ + transports?: AuthenticatorTransportFuture[]; + /** Authenticator AAGUID captured from attested credential data at registration. */ + aaguid: string; +}; + +/** + * Vault key wrapped under the passkey-derived AES-256-GCM key. + */ +export type EncryptedVaultKey = { + /** Base64-encoded AES-256-GCM ciphertext of the vault key. */ + ciphertext: Base64String; + /** Base64-encoded AES-GCM IV used during encryption. */ + iv: Base64String; +}; + +/** + * Parameters needed to reproduce the AES-256 wrapping key at unlock time. + * + * Encoded as a discriminated union so PRF-only fields (e.g. `prfSalt`) can + * only exist on the PRF branch, removing the "optional but actually + * required" footgun. + */ +export type PasskeyKeyDerivation = + | { + method: 'prf'; + /** + * PRF salt sent in `get()` extension options to reproduce the same PRF + * output that was generated at registration. + */ + prfSalt: Base64URLString; + } + | { method: 'userHandle' }; + +/** Discriminator value for {@link PasskeyKeyDerivation}. */ +export type PasskeyDerivationMethod = PasskeyKeyDerivation['method']; + +export type PasskeyRecord = { + /** WebAuthn credential metadata used for assertion verification & re-discovery. */ + credential: PasskeyCredentialInfo; + /** Vault key wrapped under the passkey-derived key. */ + encryptedVaultKey: EncryptedVaultKey; + /** How the wrapping key is reconstructed at unlock time. */ + keyDerivation: PasskeyKeyDerivation; +}; + +/** + * In-memory state for one **in-flight** WebAuthn **registration** ceremony + * (from `create()` options until `protectVaultKeyWithPasskey` completes). This is + * not a user login session; it is keyed by challenge and distinct from the full + * spec ceremony (which includes the authenticator round-trip). + */ +export type PasskeyRegistrationCeremony = { + userHandle: Base64URLString; + prfSalt: Base64URLString; + challenge: Base64URLString; + /** When this ceremony was started (ms since epoch); used for TTL pruning. */ + createdAt: number; +}; + +/** + * In-memory state for one **in-flight** WebAuthn **authentication** ceremony + * (`get()` options until the assertion is verified). Not a user login session. + */ +export type PasskeyAuthenticationCeremony = { + challenge: Base64URLString; + /** When this ceremony was started (ms since epoch); used for TTL pruning. */ + createdAt: number; +}; + +/** + * PRF extension types not covered by DOM typings. + */ +export type PrfEvalExtension = { + eval: { + first: Base64URLString; + }; +}; + +export type PrfClientExtensionResults = { + prf?: { + enabled?: boolean; + results?: { first?: Base64URLString }; + }; +}; diff --git a/packages/passkey-controller/src/utils/crypto.test.ts b/packages/passkey-controller/src/utils/crypto.test.ts new file mode 100644 index 00000000000..754a272c1cb --- /dev/null +++ b/packages/passkey-controller/src/utils/crypto.test.ts @@ -0,0 +1,31 @@ +import { decryptWithKey, deriveEncryptionKey, encryptWithKey } from './crypto'; + +describe('crypto', () => { + describe('encryptWithKey / decryptWithKey', () => { + it('round-trips the encryption key with a derived key', () => { + const ikm = new Uint8Array(32); + ikm.fill(11); + const credentialId = new Uint8Array(16); + credentialId.fill(22); + + const key = deriveEncryptionKey(ikm, credentialId); + const plaintext = 'vault-encryption-key-material'; + const { ciphertext, iv } = encryptWithKey(plaintext, key); + const recovered = decryptWithKey(ciphertext, iv, key); + expect(recovered).toBe(plaintext); + }); + + it('fails decryption when a different key is used', () => { + const keyA = deriveEncryptionKey( + new Uint8Array(32).fill(1), + new Uint8Array(8).fill(2), + ); + const keyB = deriveEncryptionKey( + new Uint8Array(32).fill(3), + new Uint8Array(8).fill(4), + ); + const { ciphertext, iv } = encryptWithKey('secret', keyA); + expect(() => decryptWithKey(ciphertext, iv, keyB)).toThrow('aes/gcm'); + }); + }); +}); diff --git a/packages/passkey-controller/src/utils/crypto.ts b/packages/passkey-controller/src/utils/crypto.ts new file mode 100644 index 00000000000..eb331609aae --- /dev/null +++ b/packages/passkey-controller/src/utils/crypto.ts @@ -0,0 +1,65 @@ +import { bytesToBase64, base64ToBytes } from '@metamask/utils'; +import { gcm } from '@noble/ciphers/aes'; +import { randomBytes } from '@noble/ciphers/webcrypto'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha2'; + +const PASSKEY_HKDF_INFO = 'metamask:passkey:encryption-key:v1'; + +const AES_GCM_IV_LENGTH = 12; + +/** + * Derives an AES-256 encryption key from input key material and a credential ID + * using HKDF-SHA256. + * + * @param ikm - Input key material (e.g. PRF output or userHandle). + * @param salt - HKDF salt. + * @returns 32-byte derived encryption key. + */ +export function deriveEncryptionKey( + ikm: Uint8Array, + salt: Uint8Array, +): Uint8Array { + return hkdf(sha256, ikm, salt, PASSKEY_HKDF_INFO, 32); +} + +/** + * Encrypts plaintext with an AES-256-GCM key. + * + * @param plaintext - UTF-8 string to encrypt. + * @param key - 32-byte AES-256 key from {@link deriveEncryptionKey}. + * @returns Base64-encoded ciphertext and IV. + */ +export function encryptWithKey( + plaintext: string, + key: Uint8Array, +): { ciphertext: string; iv: string } { + const iv = randomBytes(AES_GCM_IV_LENGTH); + const encoded = new TextEncoder().encode(plaintext); + const ciphertextBytes = gcm(key, iv).encrypt(encoded); + + return { + ciphertext: bytesToBase64(ciphertextBytes), + iv: bytesToBase64(iv), + }; +} + +/** + * Decrypts AES-256-GCM ciphertext with the given key. + * + * @param ciphertext - Base64-encoded ciphertext. + * @param iv - Base64-encoded initialization vector. + * @param key - 32-byte AES-256 key from {@link deriveEncryptionKey}. + * @returns Decrypted UTF-8 string. + */ +export function decryptWithKey( + ciphertext: string, + iv: string, + key: Uint8Array, +): string { + const ciphertextBytes = base64ToBytes(ciphertext); + const ivBytes = base64ToBytes(iv); + const plaintext = gcm(key, ivBytes).decrypt(ciphertextBytes); + + return new TextDecoder().decode(plaintext); +} diff --git a/packages/passkey-controller/src/utils/encoding.test.ts b/packages/passkey-controller/src/utils/encoding.test.ts new file mode 100644 index 00000000000..beed5b7a572 --- /dev/null +++ b/packages/passkey-controller/src/utils/encoding.test.ts @@ -0,0 +1,48 @@ +import { bytesToBase64URL, base64URLToBytes } from './encoding'; + +describe('encoding', () => { + describe('bytesToBase64URL', () => { + it('encodes an empty array', () => { + expect(bytesToBase64URL(new Uint8Array([]))).toBe(''); + }); + + it('encodes bytes without padding', () => { + const bytes = new Uint8Array([72, 101, 108, 108, 111]); + expect(bytesToBase64URL(bytes)).toBe('SGVsbG8'); + }); + + it('uses url-safe characters', () => { + const bytes = new Uint8Array([0xff, 0xfe, 0xfd]); + const result = bytesToBase64URL(bytes); + expect(result).not.toContain('+'); + expect(result).not.toContain('/'); + expect(result).not.toContain('='); + }); + }); + + describe('base64URLToBytes', () => { + it('decodes a base64url string', () => { + const original = new Uint8Array([72, 101, 108, 108, 111]); + const encoded = bytesToBase64URL(original); + const decoded = base64URLToBytes(encoded); + expect(new Uint8Array(decoded)).toStrictEqual(original); + }); + + it('handles url-safe characters', () => { + const original = new Uint8Array([0xff, 0xfe, 0xfd]); + const encoded = bytesToBase64URL(original); + const decoded = base64URLToBytes(encoded); + expect(new Uint8Array(decoded)).toStrictEqual(original); + }); + + it('round-trips arbitrary bytes', () => { + const original = new Uint8Array(256); + for (let i = 0; i < 256; i++) { + original[i] = i; + } + const encoded = bytesToBase64URL(original); + const decoded = base64URLToBytes(encoded); + expect(new Uint8Array(decoded)).toStrictEqual(original); + }); + }); +}); diff --git a/packages/passkey-controller/src/utils/encoding.ts b/packages/passkey-controller/src/utils/encoding.ts new file mode 100644 index 00000000000..03f2ba14ef0 --- /dev/null +++ b/packages/passkey-controller/src/utils/encoding.ts @@ -0,0 +1,38 @@ +import { bytesToBase64, base64ToBytes } from '@metamask/utils'; + +/** + * Encode a byte array as a base64url string (RFC 4648 §5). + * + * @param bytes - The bytes to encode. + * @returns Base64url-encoded string without padding. + */ +export function bytesToBase64URL(bytes: Uint8Array): string { + return bytesToBase64(bytes) + .replace(/\+/gu, '-') + .replace(/\//gu, '_') + .replace(/[=]+$/u, ''); +} + +/** + * Decode a base64url string (RFC 4648 §5) into bytes. + * + * @param value - Base64url-encoded string. + * @returns Decoded bytes. + */ +export function base64URLToBytes(value: string): Uint8Array { + const standard = value.replace(/-/gu, '+').replace(/_/gu, '/'); + const padLength = (4 - (standard.length % 4)) % 4; + return Uint8Array.from(base64ToBytes(standard + '='.repeat(padLength))); +} + +/** + * Encode a byte array as a hexadecimal string. + * + * @param bytes - The bytes to encode. + * @returns Hex-encoded string. + */ +export function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); +} diff --git a/packages/passkey-controller/src/webauthn/constants.ts b/packages/passkey-controller/src/webauthn/constants.ts new file mode 100644 index 00000000000..13c3ee706fb --- /dev/null +++ b/packages/passkey-controller/src/webauthn/constants.ts @@ -0,0 +1,72 @@ +/** + * COSE Algorithms + * + * @see https://www.iana.org/assignments/cose/cose.xhtml#algorithms + */ +export enum COSEALG { + ES256 = -7, + EdDSA = -8, + ES384 = -35, + ES512 = -36, + PS256 = -37, + PS384 = -38, + PS512 = -39, + ES256K = -47, + RS256 = -257, + RS384 = -258, + RS512 = -259, + RS1 = -65535, +} + +/** + * COSE Key Types + * + * @see https://www.iana.org/assignments/cose/cose.xhtml#key-type + */ +export enum COSEKTY { + OKP = 1, + EC2 = 2, + RSA = 3, +} + +/** + * COSE Curves + * + * @see https://www.iana.org/assignments/cose/cose.xhtml#elliptic-curves + */ +export enum COSECRV { + P256 = 1, + P384 = 2, + P521 = 3, + ED25519 = 6, + SECP256K1 = 8, +} + +/** + * COSE Key common and type-specific parameter labels. + * + * EC2 and RSA re-use the same numeric labels (-1, -2, -3) with different + * semantics, so this is a plain object instead of an enum to avoid + * duplicate-value violations. + * + * @see https://www.iana.org/assignments/cose/cose.xhtml#key-common-parameters + * @see https://www.iana.org/assignments/cose/cose.xhtml#key-type-parameters + */ +export const COSEKEYS = { + /** Key Type (common) */ + Kty: 1, + /** Algorithm (common) */ + Alg: 3, + + /** EC2 / OKP: curve identifier */ + Crv: -1, + /** EC2: x-coordinate / OKP: public key */ + X: -2, + /** EC2: y-coordinate */ + Y: -3, + + /** RSA: modulus n (shares numeric label with Crv) */ + N: -1, + /** RSA: exponent e (shares numeric label with X) */ + E: -2, +} as const; diff --git a/packages/passkey-controller/src/webauthn/decode-attestation-object.test.ts b/packages/passkey-controller/src/webauthn/decode-attestation-object.test.ts new file mode 100644 index 00000000000..f4532c505a4 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/decode-attestation-object.test.ts @@ -0,0 +1,46 @@ +import { base64URLToBytes } from '../utils/encoding'; +import { decodeAttestationObject } from './decode-attestation-object'; + +describe('decodeAttestationObject', () => { + it('decodes base64url-encoded indirect attestationObject', () => { + const decoded = decodeAttestationObject( + base64URLToBytes( + 'o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjEAbElFazplpnc037DORGDZNjDq86cN9vm6' + + '+APoAM20wtBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQKmPuEwByQJ3e89TccUSrCGDkNWquhevjLLn/' + + 'KNZZaxQQ0steueoG2g12dvnUNbiso8kVJDyLa+6UiA34eniujWlAQIDJiABIVggiUk8wN2j' + + '+3fkKI7KSiLBkKzs3FfhPZxHgHPnGLvOY/YiWCBv7+XyTqArnMVtQ947/8Xk8fnVCdLMRWJGM1VbNevVcQ==', + ), + ); + + expect(decoded.get('fmt')).toBe('none'); + expect(decoded.get('attStmt')).toStrictEqual(new Map()); + expect(Boolean(decoded.get('authData'))).toBe(true); + }); + + it('decodes base64url-encoded direct attestationObject', () => { + const decoded = decodeAttestationObject( + base64URLToBytes( + 'o2NmbXRoZmlkby11MmZnYXR0U3RtdKJjc2lnWEgwRgIhAK40WxA0t7py7AjEXvwGwTlmqlvrOk' + + 's5g9lf+9zXzRiVAiEA3bv60xyXveKDOusYzniD7CDSostCet9PYK7FLdnTdZNjeDVjgVkCwTCCAr0wggGloAMCAQICBCrn' + + 'YmMwDQYJKoZIhvcNAQELBQAwLjEsMCoGA1UEAxMjWXViaWNvIFUyRiBSb290IENBIFNlcmlhbCA0NTcyMDA2MzEwIBcNMT' + + 'QwODAxMDAwMDAwWhgPMjA1MDA5MDQwMDAwMDBaMG4xCzAJBgNVBAYTAlNFMRIwEAYDVQQKDAlZdWJpY28gQUIxIjAgBgNV' + + 'BAsMGUF1dGhlbnRpY2F0b3IgQXR0ZXN0YXRpb24xJzAlBgNVBAMMHll1YmljbyBVMkYgRUUgU2VyaWFsIDcxOTgwNzA3NT' + + 'BZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABCoDhl5gQ9meEf8QqiVUV4S/Ca+Oax47MhcpIW9VEhqM2RDTmd3HaL3+SnvH' + + '49q8YubSRp/1Z1uP+okMynSGnj+jbDBqMCIGCSsGAQQBgsQKAgQVMS4zLjYuMS40LjEuNDE0ODIuMS4xMBMGCysGAQQBgu' + + 'UcAgEBBAQDAgQwMCEGCysGAQQBguUcAQEEBBIEEG1Eupv27C5JuTAMj+kgy3MwDAYDVR0TAQH/BAIwADANBgkqhkiG9w0B' + + 'AQsFAAOCAQEAclfQPNzD4RVphJDW+A75W1MHI3PZ5kcyYysR3Nx3iuxr1ZJtB+F7nFQweI3jL05HtFh2/4xVIgKb6Th4eV' + + 'cjMecncBaCinEbOcdP1sEli9Hk2eVm1XB5A0faUjXAPw/+QLFCjgXG6ReZ5HVUcWkB7riLsFeJNYitiKrTDXFPLy+sNtVN' + + 'utcQnFsCerDKuM81TvEAigkIbKCGlq8M/NvBg5j83wIxbCYiyV7mIr3RwApHieShzLdJo1S6XydgQjC+/64G5r8C+8AVvN' + + 'FR3zXXCpio5C3KRIj88HEEIYjf6h1fdLfqeIsq+cUUqbq5T+c4nNoZUZCysTB9v5EY4akp+GhhdXRoRGF0YVjEAbElFazp' + + 'lpnc037DORGDZNjDq86cN9vm6+APoAM20wtBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQGFYevaR71ptU5YtXOSnVzPQTsGgK+' + + 'gLiBKnqPWBmZXNRvjISqlLxiwApzlrfkTc3lEMYMatjeACCnsijOkNEGOlAQIDJiABIVggdWLG6UvGyHFw/k/bv6/k6z/L' + + 'LgSO5KXzXw2EcUxkEX8iWCBeaVLz/cbyoKvRIg/q+q7tan0VN+i3WR0BOBCcuNP7yw==', + ), + ); + + expect(decoded.get('fmt')).toBe('fido-u2f'); + expect(Boolean(decoded.get('attStmt').get('sig'))).toBe(true); + expect(Boolean(decoded.get('attStmt').get('x5c'))).toBe(true); + expect(Boolean(decoded.get('authData'))).toBe(true); + }); +}); diff --git a/packages/passkey-controller/src/webauthn/decode-attestation-object.ts b/packages/passkey-controller/src/webauthn/decode-attestation-object.ts new file mode 100644 index 00000000000..21c3a4da218 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/decode-attestation-object.ts @@ -0,0 +1,18 @@ +import { decodePartialCBOR } from '@levischuck/tiny-cbor'; + +import type { AttestationObject } from './types'; + +/** + * CBOR-decode an attestationObject buffer into a Map with `fmt`, `attStmt`, + * and `authData` entries. + * + * @param attestationObject - Raw attestation object bytes. + * @returns Decoded AttestationObject map. + */ +export function decodeAttestationObject( + attestationObject: Uint8Array, +): AttestationObject { + const copy = new Uint8Array(attestationObject); + const [decoded] = decodePartialCBOR(copy, 0) as [AttestationObject, number]; + return decoded; +} diff --git a/packages/passkey-controller/src/webauthn/decode-client-data-json.test.ts b/packages/passkey-controller/src/webauthn/decode-client-data-json.test.ts new file mode 100644 index 00000000000..7c6e72404f4 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/decode-client-data-json.test.ts @@ -0,0 +1,16 @@ +import { decodeClientDataJSON } from './decode-client-data-json'; + +describe('decodeClientDataJSON', () => { + it('converts base64url-encoded attestation clientDataJSON to JSON', () => { + expect( + decodeClientDataJSON( + 'eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiWko0YW12QnpOUGVMb3lLVE04bDlqamFmMDhXc0V0TG5OSENGZnhacGEybjlfU21NUnR5VjZlYlNPSUFfUGNsOHBaUjl5Y1ZhaW5SdV9rUDhRaTZiemciLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIn0', + ), + ).toStrictEqual({ + type: 'webauthn.create', + challenge: + 'ZJ4amvBzNPeLoyKTM8l9jjaf08WsEtLnNHCFfxZpa2n9_SmMRtyV6ebSOIA_Pcl8pZR9ycVainRu_kP8Qi6bzg', + origin: 'https://webauthn.io', + }); + }); +}); diff --git a/packages/passkey-controller/src/webauthn/decode-client-data-json.ts b/packages/passkey-controller/src/webauthn/decode-client-data-json.ts new file mode 100644 index 00000000000..e3881b30331 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/decode-client-data-json.ts @@ -0,0 +1,14 @@ +import { base64URLToBytes } from '../utils/encoding'; +import type { ClientDataJSON } from './types'; + +/** + * Decode an authenticator's base64url-encoded clientDataJSON to JSON. + * + * @param data - Base64url-encoded clientDataJSON string. + * @returns Parsed ClientDataJSON object. + */ +export function decodeClientDataJSON(data: string): ClientDataJSON { + const bytes = base64URLToBytes(data); + const text = new TextDecoder().decode(bytes); + return JSON.parse(text) as ClientDataJSON; +} diff --git a/packages/passkey-controller/src/webauthn/match-expected-rp-id.test.ts b/packages/passkey-controller/src/webauthn/match-expected-rp-id.test.ts new file mode 100644 index 00000000000..c746504d2c6 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/match-expected-rp-id.test.ts @@ -0,0 +1,33 @@ +import { sha256 } from '@noble/hashes/sha2'; + +import { matchExpectedRPID } from './match-expected-rp-id'; + +describe('matchExpectedRPID', () => { + it('throws when no RP ID matches', () => { + const rpIdHash = sha256(new TextEncoder().encode('example.com')); + expect(() => matchExpectedRPID(rpIdHash, ['wrong.com'])).toThrow( + 'Unexpected RP ID hash', + ); + }); + + it('returns matching RP ID', () => { + const rpIdHash = sha256(new TextEncoder().encode('example.com')); + expect(matchExpectedRPID(rpIdHash, ['example.com'])).toBe('example.com'); + }); + + it('constant-time compare rejects different lengths', () => { + // Pass a 16-byte rpIdHash to trigger the areEqual length-mismatch branch + // (sha256 always produces 32 bytes, so the comparison short-circuits) + const shortHash = new Uint8Array(16).fill(0xaa); + expect(() => matchExpectedRPID(shortHash, ['example.com'])).toThrow( + 'Unexpected RP ID hash', + ); + }); + + it('matches second candidate in array', () => { + const rpIdHash = sha256(new TextEncoder().encode('example.com')); + expect(matchExpectedRPID(rpIdHash, ['wrong.com', 'example.com'])).toBe( + 'example.com', + ); + }); +}); diff --git a/packages/passkey-controller/src/webauthn/match-expected-rp-id.ts b/packages/passkey-controller/src/webauthn/match-expected-rp-id.ts new file mode 100644 index 00000000000..8faba44d6d5 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/match-expected-rp-id.ts @@ -0,0 +1,26 @@ +import { areUint8ArraysEqual } from '@metamask/utils'; +import { sha256 } from '@noble/hashes/sha2'; + +import { bytesToHex } from '../utils/encoding'; + +/** + * Verify that an authenticator data rpIdHash matches one of the expected + * RP IDs by SHA-256 hashing each candidate and comparing. + * + * @param rpIdHash - The rpIdHash from authenticatorData (32 bytes). + * @param expectedRPIDs - One or more RP ID strings to check against. + * @returns The matching RP ID string. + * @throws If no expected RP ID matches. + */ +export function matchExpectedRPID( + rpIdHash: Uint8Array, + expectedRPIDs: string[], +): string { + for (const rpID of expectedRPIDs) { + const expectedHash = sha256(new TextEncoder().encode(rpID)); + if (areUint8ArraysEqual(rpIdHash, expectedHash)) { + return rpID; + } + } + throw new Error(`Unexpected RP ID hash: received ${bytesToHex(rpIdHash)}`); +} diff --git a/packages/passkey-controller/src/webauthn/parse-authenticator-data.test.ts b/packages/passkey-controller/src/webauthn/parse-authenticator-data.test.ts new file mode 100644 index 00000000000..825215db4e7 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/parse-authenticator-data.test.ts @@ -0,0 +1,144 @@ +import { encodeCBOR } from '@levischuck/tiny-cbor'; +import { base64ToBytes, bytesToBase64 } from '@metamask/utils'; +import { sha256 } from '@noble/hashes/sha2'; + +import { bytesToBase64URL } from '../utils/encoding'; +import { parseAuthenticatorData } from './parse-authenticator-data'; + +/** + * Conformance vectors from SimpleWebAuthn `parseAuthenticatorData.test.ts` + * (base64-decoded the same way: `isoBase64URL.toBuffer(..., 'base64')`). + * + * The Firefox 117 malformed COSE case from upstream is omitted here: that + * parser patches bad CBOR and re-encodes the public key; this implementation + * does not, and throws "Leftover bytes detected..." on that buffer. + */ + +// Includes attested credential data (AT) +const authDataWithAT = Uint8Array.from( + base64ToBytes( + 'SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2NBAAAAJch83ZdWwUm4niTLNjZU81AAIHa7Ksm5br3hAh3UjxP9+4rqu8BEsD+7SZ2xWe1/yHv6pAEDAzkBACBZAQDcxA7Ehs9goWB2Hbl6e9v+aUub9rvy2M7Hkvf+iCzMGE63e3sCEW5Ru33KNy4um46s9jalcBHtZgtEnyeRoQvszis+ws5o4Da0vQfuzlpBmjWT1dV6LuP+vs9wrfObW4jlA5bKEIhv63+jAxOtdXGVzo75PxBlqxrmrr5IR9n8Fw7clwRsDkjgRHaNcQVbwq/qdNwU5H3hZKu9szTwBS5NGRq01EaDF2014YSTFjwtAmZ3PU1tcO/QD2U2zg6eB5grfWDeAJtRE8cbndDWc8aLL0aeC37Q36+TVsGe6AhBgHEw6eO3I3NW5r9v/26CqMPBDwmEundeq1iGyKfMloobIUMBAAE=', + ), +); + +// Includes extension data (ED) +const authDataWithED = Uint8Array.from( + base64ToBytes( + 'SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2OBAAAAjaFxZXhhbXBsZS5leHRlbnNpb254dlRoaXMgaXMgYW4gZXhhbXBsZSBleHRlbnNpb24hIElmIHlvdSByZWFkIHRoaXMgbWVzc2FnZSwgeW91IHByb2JhYmx5IHN1Y2Nlc3NmdWxseSBwYXNzaW5nIGNvbmZvcm1hbmNlIHRlc3RzLiBHb29kIGpvYiE=', + ), +); + +const TEST_RP_ID = 'example.com'; + +describe('parseAuthenticatorData', () => { + it('parses flags', () => { + const parsed = parseAuthenticatorData(authDataWithED); + const { flags } = parsed; + + expect(flags.up).toBe(true); + expect(flags.uv).toBe(false); + expect(flags.be).toBe(false); + expect(flags.bs).toBe(false); + expect(flags.at).toBe(false); + expect(flags.ed).toBe(true); + }); + + it('parses attestation data', () => { + const parsed = parseAuthenticatorData(authDataWithAT); + const { credentialID, credentialPublicKey, aaguid, counter } = parsed; + + if ( + credentialID === undefined || + credentialPublicKey === undefined || + aaguid === undefined + ) { + throw new Error('expected credentialID, credentialPublicKey, and aaguid'); + } + + expect(bytesToBase64URL(credentialID)).toBe( + 'drsqybluveECHdSPE_37iuq7wESwP7tJnbFZ7X_Ie_o', + ); + expect(bytesToBase64(credentialPublicKey)).toBe( + 'pAEDAzkBACBZAQDcxA7Ehs9goWB2Hbl6e9v+aUub9rvy2M7Hkvf+iCzMGE63e3sCEW5Ru33KNy4um46s9jalcBHtZgtEnyeRoQvszis+ws5o4Da0vQfuzlpBmjWT1dV6LuP+vs9wrfObW4jlA5bKEIhv63+jAxOtdXGVzo75PxBlqxrmrr5IR9n8Fw7clwRsDkjgRHaNcQVbwq/qdNwU5H3hZKu9szTwBS5NGRq01EaDF2014YSTFjwtAmZ3PU1tcO/QD2U2zg6eB5grfWDeAJtRE8cbndDWc8aLL0aeC37Q36+TVsGe6AhBgHEw6eO3I3NW5r9v/26CqMPBDwmEundeq1iGyKfMloobIUMBAAE=', + ); + expect(bytesToBase64(aaguid)).toBe('yHzdl1bBSbieJMs2NlTzUA=='); + expect(counter).toBe(37); + }); + + it('parses extension data', () => { + const parsed = parseAuthenticatorData(authDataWithED); + const { extensionsData } = parsed; + + expect(extensionsData).toStrictEqual( + new Map([ + [ + 'example.extension', + 'This is an example extension! If you read this message, you probably successfully passing conformance tests. Good job!', + ], + ]), + ); + }); +}); + +describe('parseAuthenticatorData edge cases', () => { + it('throws for authenticator data shorter than 37 bytes', () => { + expect(() => parseAuthenticatorData(new Uint8Array(36))).toThrow( + 'authenticatorData is 36 bytes, expected at least 37', + ); + }); + + it('parses extension data when ED flag is set', () => { + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + const flags = 0x81; + const counter = new Uint8Array(4); + + const extMap = new Map(); + extMap.set('credProtect', 2); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const extCBOR = encodeCBOR(extMap as any); + + const authData = new Uint8Array(37 + extCBOR.length); + authData.set(rpIdHash, 0); + authData[32] = flags; + authData.set(counter, 33); + authData.set(extCBOR, 37); + + const result = parseAuthenticatorData(authData); + expect(result.flags.ed).toBe(true); + expect(result.extensionsData).toBeDefined(); + expect(result.extensionsData?.get('credProtect')).toBe(2); + }); + + it('throws on leftover bytes after parsing', () => { + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + const authData = new Uint8Array(42); + authData.set(rpIdHash, 0); + authData[32] = 0x01; + authData.set(new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0x00]), 37); + + expect(() => parseAuthenticatorData(authData)).toThrow( + 'Leftover bytes detected while parsing authenticator data', + ); + }); + + it('parses authenticator data without attested credential or extensions', () => { + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + const authData = new Uint8Array(37); + authData.set(rpIdHash, 0); + authData[32] = 0x05; + + const counterView = new DataView(authData.buffer, 33, 4); + counterView.setUint32(0, 42, false); + + const result = parseAuthenticatorData(authData); + expect(result.flags.up).toBe(true); + expect(result.flags.uv).toBe(true); + expect(result.flags.at).toBe(false); + expect(result.flags.ed).toBe(false); + expect(result.counter).toBe(42); + expect(result.aaguid).toBeUndefined(); + expect(result.credentialID).toBeUndefined(); + expect(result.credentialPublicKey).toBeUndefined(); + expect(result.extensionsData).toBeUndefined(); + }); +}); diff --git a/packages/passkey-controller/src/webauthn/parse-authenticator-data.ts b/packages/passkey-controller/src/webauthn/parse-authenticator-data.ts new file mode 100644 index 00000000000..a60840690c1 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/parse-authenticator-data.ts @@ -0,0 +1,100 @@ +import { decodePartialCBOR } from '@levischuck/tiny-cbor'; + +import type { ParsedAuthenticatorData, AuthenticatorDataFlags } from './types'; + +/* eslint-disable no-bitwise */ + +/** + * Parse an authenticator data buffer per §6.1 of the WebAuthn spec. + * + * @param authData - Raw authenticator data bytes. + * @returns Parsed authenticator data with flags, rpIdHash, counter, and + * optional attested credential data. + */ +export function parseAuthenticatorData( + authData: Uint8Array, +): ParsedAuthenticatorData { + if (authData.byteLength < 37) { + throw new Error( + `authenticatorData is ${authData.byteLength} bytes, expected at least 37`, + ); + } + + let pointer = 0; + + const rpIdHash = authData.slice(pointer, pointer + 32); + pointer += 32; + + const flagsByte = authData[pointer]; + const flags: AuthenticatorDataFlags = { + up: Boolean(flagsByte & (1 << 0)), + uv: Boolean(flagsByte & (1 << 2)), + be: Boolean(flagsByte & (1 << 3)), + bs: Boolean(flagsByte & (1 << 4)), + at: Boolean(flagsByte & (1 << 6)), + ed: Boolean(flagsByte & (1 << 7)), + flagsByte, + }; + pointer += 1; + + const counterView = new DataView( + authData.buffer, + authData.byteOffset + pointer, + 4, + ); + const counter = counterView.getUint32(0, false); + pointer += 4; + + const result: ParsedAuthenticatorData = { + rpIdHash, + flags, + counter, + }; + + if (flags.at) { + const aaguid = authData.slice(pointer, pointer + 16); + pointer += 16; + + const credIDLenView = new DataView( + authData.buffer, + authData.byteOffset + pointer, + 2, + ); + const credIDLen = credIDLenView.getUint16(0, false); + pointer += 2; + + const credentialID = authData.slice(pointer, pointer + credIDLen); + pointer += credIDLen; + + const pubKeyBytes = authData.slice(pointer); + const [, nextOffset] = decodePartialCBOR( + new Uint8Array(pubKeyBytes), + 0, + ) as [unknown, number]; + const credentialPublicKey = authData.slice(pointer, pointer + nextOffset); + pointer += nextOffset; + + result.aaguid = aaguid; + result.credentialID = credentialID; + result.credentialPublicKey = credentialPublicKey; + } + + if (flags.ed) { + const remaining = authData.slice(pointer); + const [decoded, consumed] = decodePartialCBOR( + new Uint8Array(remaining), + 0, + ) as [Map, number]; + result.extensionsData = decoded; + result.extensionsDataBuffer = remaining.slice(0, consumed); + pointer += consumed; + } + + if (authData.byteLength > pointer) { + throw new Error('Leftover bytes detected while parsing authenticator data'); + } + + return result; +} + +/* eslint-enable no-bitwise */ diff --git a/packages/passkey-controller/src/webauthn/types.ts b/packages/passkey-controller/src/webauthn/types.ts new file mode 100644 index 00000000000..e4e28108172 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/types.ts @@ -0,0 +1,131 @@ +import type { + AuthenticatorTransportFuture, + Base64URLString as Base64URL, +} from '../types'; + +export type PublicKeyCredentialDescriptorJSON = { + id: Base64URL; + type: 'public-key'; + transports?: AuthenticatorTransportFuture[]; +}; + +export type PublicKeyCredentialHint = + | 'hybrid' + | 'security-key' + | 'client-device'; + +export type PasskeyRegistrationOptions = { + rp: { name: string; id: string }; + user: { + id: Base64URL; + name: string; + displayName: string; + }; + challenge: Base64URL; + pubKeyCredParams: { alg: number; type: 'public-key' }[]; + timeout?: number; + excludeCredentials?: PublicKeyCredentialDescriptorJSON[]; + authenticatorSelection?: { + authenticatorAttachment?: 'cross-platform' | 'platform'; + residentKey?: 'discouraged' | 'preferred' | 'required'; + requireResidentKey?: boolean; + userVerification?: 'discouraged' | 'preferred' | 'required'; + }; + hints?: PublicKeyCredentialHint[]; + attestation?: 'direct' | 'enterprise' | 'indirect' | 'none'; + extensions?: Record; +}; + +export type PasskeyRegistrationResponse = { + id: Base64URL; + rawId: Base64URL; + type: 'public-key'; + response: { + clientDataJSON: Base64URL; + attestationObject: Base64URL; + transports?: string[]; + publicKeyAlgorithm?: number; + publicKey?: Base64URL; + authenticatorData?: Base64URL; + }; + authenticatorAttachment?: 'cross-platform' | 'platform'; + clientExtensionResults: Record; +}; + +export type PasskeyAuthenticationOptions = { + challenge: Base64URL; + timeout?: number; + rpId?: string; + allowCredentials?: PublicKeyCredentialDescriptorJSON[]; + userVerification?: 'discouraged' | 'preferred' | 'required'; + hints?: PublicKeyCredentialHint[]; + extensions?: Record; +}; + +export type PasskeyAuthenticationResponse = { + id: Base64URL; + rawId: Base64URL; + type: 'public-key'; + response: { + clientDataJSON: Base64URL; + authenticatorData: Base64URL; + signature: Base64URL; + userHandle?: Base64URL; + }; + authenticatorAttachment?: 'cross-platform' | 'platform'; + clientExtensionResults: Record; +}; + +export type ClientDataJSON = { + type: string; + challenge: string; + origin: string; + crossOrigin?: boolean; + tokenBinding?: { + id?: string; + status: 'present' | 'supported' | 'not-supported'; + }; +}; + +export type AttestationFormat = + | 'fido-u2f' + | 'packed' + | 'android-safetynet' + | 'android-key' + | 'tpm' + | 'apple' + | 'none'; + +export type AttestationObject = { + get(key: 'fmt'): AttestationFormat; + get(key: 'attStmt'): AttestationStatement; + get(key: 'authData'): Uint8Array; +}; + +export type AttestationStatement = { + get(key: 'sig'): Uint8Array | undefined; + get(key: 'x5c'): Uint8Array[] | undefined; + get(key: 'alg'): number | undefined; + readonly size: number; +}; + +export type AuthenticatorDataFlags = { + up: boolean; + uv: boolean; + be: boolean; + bs: boolean; + at: boolean; + ed: boolean; + flagsByte: number; +}; + +export type ParsedAuthenticatorData = { + rpIdHash: Uint8Array; + flags: AuthenticatorDataFlags; + counter: number; + aaguid?: Uint8Array; + credentialID?: Uint8Array; + credentialPublicKey?: Uint8Array; + extensionsData?: Map; + extensionsDataBuffer?: Uint8Array; +}; diff --git a/packages/passkey-controller/src/webauthn/verify-authentication-response.test.ts b/packages/passkey-controller/src/webauthn/verify-authentication-response.test.ts new file mode 100644 index 00000000000..acb86d85f38 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/verify-authentication-response.test.ts @@ -0,0 +1,422 @@ +import { bytesToBase64URL, base64URLToBytes } from '../utils/encoding'; +import { decodeClientDataJSON } from './decode-client-data-json'; +import type { PasskeyAuthenticationResponse } from './types'; +import { verifyAuthenticationResponse } from './verify-authentication-response'; + +const EXPECTED_ORIGIN = 'https://dev.dontneeda.pw'; +const EXPECTED_RP_ID = 'dev.dontneeda.pw'; + +const assertionResponse: PasskeyAuthenticationResponse = { + id: 'KEbWNCc7NgaYnUyrNeFGX9_3Y-8oJ3KwzjnaiD1d1LVTxR7v3CaKfCz2Vy_g_MHSh7yJ8yL0Pxg6jo_o0hYiew', + rawId: + 'KEbWNCc7NgaYnUyrNeFGX9_3Y-8oJ3KwzjnaiD1d1LVTxR7v3CaKfCz2Vy_g_MHSh7yJ8yL0Pxg6jo_o0hYiew', + response: { + authenticatorData: 'PdxHEOnAiLIp26idVjIguzn3Ipr_RlsKZWsa-5qK-KABAAAAkA==', + clientDataJSON: + 'eyJjaGFsbGVuZ2UiOiJkRzkwWVd4c2VWVnVhWEYxWlZaaGJIVmxSWFpsY25sVWFXMWwiLCJj' + + 'bGllbnRFeHRlbnNpb25zIjp7fSwiaGFzaEFsZ29yaXRobSI6IlNIQS0yNTYiLCJvcmlnaW4iOiJodHRwczovL2Rldi5k' + + 'b250bmVlZGEucHciLCJ0eXBlIjoid2ViYXV0aG4uZ2V0In0=', + signature: + 'MEUCIQDYXBOpCWSWq2Ll4558GJKD2RoWg958lvJSB_GdeokxogIgWuEVQ7ee6AswQY0OsuQ6y8Ks6' + + 'jhd45bDx92wjXKs900=', + }, + clientExtensionResults: {}, + type: 'public-key', +}; + +const credential = { + publicKey: base64URLToBytes( + 'pQECAyYgASFYIIheFp-u6GvFT2LNGovf3ZrT0iFVBsA_76rRysxRG9A1Ilgg8WGeA6hPmnab0HAViUYVRkwTNcN77QBf_RR0dv3lIvQ', + ), + id: 'KEbWNCc7NgaYnUyrNeFGX9_3Y-8oJ3KwzjnaiD1d1LVTxR7v3CaKfCz2Vy_g_MHSh7yJ8yL0Pxg6jo_o0hYiew', + counter: 143, +}; + +const assertionFirstTimeUsedResponse: PasskeyAuthenticationResponse = { + id: 'wSisR0_4hlzw3Y1tj4uNwwifIhRa-ZxWJwWbnfror0pVK9qPdBPO5pW3gasPqn6wXHb0LNhXB_IrA1nFoSQJ9A', + rawId: + 'wSisR0_4hlzw3Y1tj4uNwwifIhRa-ZxWJwWbnfror0pVK9qPdBPO5pW3gasPqn6wXHb0LNhXB_IrA1nFoSQJ9A', + response: { + authenticatorData: 'PdxHEOnAiLIp26idVjIguzn3Ipr_RlsKZWsa-5qK-KABAAAAAA', + clientDataJSON: + 'eyJjaGFsbGVuZ2UiOiJkRzkwWVd4c2VWVnVhWEYxWlZaaGJIVmxSWFpsY25sQmMzTmxjblJwYjI0IiwiY2xpZW50RXh0ZW5zaW9ucyI6e30sImhhc2hBbGdvcml0aG0iOiJTSEEtMjU2Iiwib3JpZ2luIjoiaHR0cHM6Ly9kZXYuZG9udG5lZWRhLnB3IiwidHlwZSI6IndlYmF1dGhuLmdldCJ9', + signature: + 'MEQCIBu6M-DGzu1O8iocGHEj0UaAZm0HmxTeRIE6-nS3_CPjAiBDsmIzy5sacYwwzgpXqfwRt_2vl5yiQZ_OAqWJQBGVsQ', + }, + type: 'public-key', + clientExtensionResults: {}, +}; + +const authenticatorFirstTimeUsed = { + publicKey: base64URLToBytes( + 'pQECAyYgASFYIGmaxR4mBbukc2QhtW2ldhAAd555r-ljlGQN8MbcTnPPIlgg9CyUlE-0AB2fbzZbNgBvJuRa7r6o2jPphOmtyNPR_kY', + ), + id: 'wSisR0_4hlzw3Y1tj4uNwwifIhRa-ZxWJwWbnfror0pVK9qPdBPO5pW3gasPqn6wXHb0LNhXB_IrA1nFoSQJ9A', + counter: 0, +}; + +const assertionChallenge = decodeClientDataJSON( + assertionResponse.response.clientDataJSON, +).challenge; + +const assertionFirstTimeUsedChallenge = decodeClientDataJSON( + assertionFirstTimeUsedResponse.response.clientDataJSON, +).challenge; + +describe('verifyAuthenticationResponse', () => { + it('verifies an assertion response', async () => { + const verification = await verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential, + requireUserVerification: false, + }); + + expect(verification.verified).toBe(true); + }); + + it('returns authenticator info after verification', async () => { + const verification = await verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential, + requireUserVerification: false, + }); + + expect(verification.verified).toBe(true); + if (!verification.verified) { + return; + } + expect(verification.authenticationInfo.newCounter).toBe(144); + expect(verification.authenticationInfo.credentialId).toBe(credential.id); + expect(verification.authenticationInfo.origin).toBe(EXPECTED_ORIGIN); + expect(verification.authenticationInfo.rpID).toBe(EXPECTED_RP_ID); + }); + + it('throws when response challenge is not expected value', async () => { + await expect( + verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: 'shouldhavebeenthisvalue', + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential, + }), + ).rejects.toThrow('Unexpected authentication response challenge'); + }); + + it('throws when response origin is not expected value', async () => { + await expect( + verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: 'https://different.address', + expectedRPID: EXPECTED_RP_ID, + credential, + }), + ).rejects.toThrow('Unexpected authentication response origin'); + }); + + it('returns { verified: false } when signature does not verify', async () => { + const signatureBytes = base64URLToBytes( + assertionResponse.response.signature, + ); + signatureBytes[0] = ((signatureBytes[0] ?? 0) + 1) % 256; + + const result = await verifyAuthenticationResponse({ + response: { + ...assertionResponse, + response: { + ...assertionResponse.response, + signature: bytesToBase64URL(signatureBytes), + }, + }, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential, + }); + + expect(result.verified).toBe(false); + expect(result.authenticationInfo).toBeUndefined(); + }); + + it('throws when authentication type is not webauthn.get', async () => { + const badTypeClientData = bytesToBase64URL( + new TextEncoder().encode( + JSON.stringify({ + ...decodeClientDataJSON(assertionResponse.response.clientDataJSON), + type: 'webauthn.badtype', + }), + ), + ); + + await expect( + verifyAuthenticationResponse({ + response: { + ...assertionResponse, + response: { + ...assertionResponse.response, + clientDataJSON: badTypeClientData, + }, + }, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential, + }), + ).rejects.toThrow('Unexpected authentication response type'); + }); + + it('throws when RP ID is not expected value', async () => { + await expect( + verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: 'wrong-rp.com', + credential, + }), + ).rejects.toThrow('Unexpected RP ID hash'); + }); + + it('throws when credential ID is missing in response', async () => { + await expect( + verifyAuthenticationResponse({ + response: { + ...assertionResponse, + id: '', + rawId: '', + }, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential, + }), + ).rejects.toThrow('Missing credential ID'); + }); + + it('throws when id and rawId differ', async () => { + await expect( + verifyAuthenticationResponse({ + response: { + ...assertionResponse, + rawId: 'different-raw-id', + }, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential, + }), + ).rejects.toThrow('Credential ID was not base64url-encoded'); + }); + + it('throws when credential type is not public-key', async () => { + await expect( + verifyAuthenticationResponse({ + response: { + ...assertionResponse, + type: 'not-public-key', + } as unknown as PasskeyAuthenticationResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential, + }), + ).rejects.toThrow('Unexpected credential type'); + }); + + it('throws error if user was not present', async () => { + const authData = base64URLToBytes( + assertionResponse.response.authenticatorData, + ); + authData[32] = 0x00; + + await expect( + verifyAuthenticationResponse({ + response: { + ...assertionResponse, + response: { + ...assertionResponse.response, + authenticatorData: bytesToBase64URL(authData), + }, + }, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential, + }), + ).rejects.toThrow('User not present during authentication'); + }); + + it('throws error when response counter equals stored counter and monotonicity applies', async () => { + await expect( + verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential: { + ...credential, + counter: 144, + }, + requireUserVerification: false, + }), + ).rejects.toThrow( + 'Response counter value 144 must be greater than stored counter 144', + ); + }); + + it('throws error when response counter is lower than stored counter', async () => { + await expect( + verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential: { + ...credential, + counter: 200, + }, + requireUserVerification: false, + }), + ).rejects.toThrow( + 'Response counter value 144 must be greater than stored counter 200', + ); + }); + + it('does not compare counters if both are 0', async () => { + const verification = await verifyAuthenticationResponse({ + response: assertionFirstTimeUsedResponse, + expectedChallenge: assertionFirstTimeUsedChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential: authenticatorFirstTimeUsed, + requireUserVerification: false, + }); + + expect(verification.verified).toBe(true); + }); + + it('throws if user verification is required but uv is false', async () => { + await expect( + verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential, + requireUserVerification: true, + }), + ).rejects.toThrow( + 'User verification required, but user could not be verified', + ); + }); + + it('accepts expectedOrigin as array', async () => { + const verification = await verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: ['https://other.com', EXPECTED_ORIGIN], + expectedRPID: EXPECTED_RP_ID, + credential, + requireUserVerification: false, + }); + + expect(verification.verified).toBe(true); + }); + + it('throws when clientDataJSON is not a string', async () => { + await expect( + verifyAuthenticationResponse({ + response: { + ...assertionResponse, + response: { + ...assertionResponse.response, + clientDataJSON: 1 as unknown as string, + }, + }, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential, + }), + ).rejects.toThrow('Credential response clientDataJSON was not a string'); + }); + + it('throws when userHandle is not a string', async () => { + await expect( + verifyAuthenticationResponse({ + response: { + ...assertionResponse, + response: { + ...assertionResponse.response, + userHandle: 1 as unknown as string, + }, + }, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential, + }), + ).rejects.toThrow('Credential response userHandle was not a string'); + }); + + it('throws when tokenBinding is not an object', async () => { + const clientDataJSON = bytesToBase64URL( + new TextEncoder().encode( + JSON.stringify({ + ...decodeClientDataJSON(assertionResponse.response.clientDataJSON), + tokenBinding: 'invalid', + }), + ), + ); + + await expect( + verifyAuthenticationResponse({ + response: { + ...assertionResponse, + response: { + ...assertionResponse.response, + clientDataJSON, + }, + }, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential, + }), + ).rejects.toThrow('ClientDataJSON tokenBinding was not an object'); + }); + + it('throws when tokenBinding status is invalid', async () => { + const clientDataJSON = bytesToBase64URL( + new TextEncoder().encode( + JSON.stringify({ + ...decodeClientDataJSON(assertionResponse.response.clientDataJSON), + tokenBinding: { status: 'invalid-status' }, + }), + ), + ); + + await expect( + verifyAuthenticationResponse({ + response: { + ...assertionResponse, + response: { + ...assertionResponse.response, + clientDataJSON, + }, + }, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential, + }), + ).rejects.toThrow('Unexpected tokenBinding status'); + }); +}); diff --git a/packages/passkey-controller/src/webauthn/verify-authentication-response.ts b/packages/passkey-controller/src/webauthn/verify-authentication-response.ts new file mode 100644 index 00000000000..c7b5acd0bf3 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/verify-authentication-response.ts @@ -0,0 +1,216 @@ +import { decodePartialCBOR } from '@levischuck/tiny-cbor'; +import { concatBytes } from '@metamask/utils'; +import { sha256 } from '@noble/hashes/sha2'; + +import type { AuthenticatorTransportFuture } from '../types'; +import { base64URLToBytes } from '../utils/encoding'; +import { decodeClientDataJSON } from './decode-client-data-json'; +import { matchExpectedRPID } from './match-expected-rp-id'; +import { parseAuthenticatorData } from './parse-authenticator-data'; +import type { ParsedAuthenticatorData } from './types'; +import type { PasskeyAuthenticationResponse } from './types'; +import { verifySignature } from './verify-signature'; + +export type VerifiedAuthenticationResponse = + | { verified: false; authenticationInfo?: never } + | { + verified: true; + authenticationInfo: { + credentialId: string; + newCounter: number; + userVerified: boolean; + origin: string; + rpID: string; + }; + }; + +/** + * Verifies a WebAuthn authentication (assertion) response per + * W3C WebAuthn Level 3 §7.2. + * + * Performs the following checks in order: + * 1. Credential ID presence, base64url consistency, and type. + * 2. `clientDataJSON` -- type is `"webauthn.get"`, challenge and origin + * match. + * 3. `authenticatorData` -- RP ID hash matches, user-presence flag is + * set, and optional user-verification flag is checked. + * 4. Signature verification -- `signature` is verified over + * `authData || SHA-256(clientDataJSON)` using the stored credential + * public key (COSE-encoded). + * 5. Counter monotonicity -- if either the stored or returned counter + * is non-zero, the new counter must exceed the stored value. + * + * @param opts - Verification options. + * @param opts.response - The `PublicKeyCredential` result from + * `navigator.credentials.get()`, serialized as JSON. + * @param opts.expectedChallenge - The base64url challenge that was issued + * for this ceremony. + * @param opts.expectedOrigin - One or more acceptable origins. + * @param opts.expectedRPID - The Relying Party ID domain. + * @param opts.credential - The stored credential record to verify against. + * @param opts.credential.id - The credential ID (base64url). + * @param opts.credential.publicKey - The COSE-encoded public key bytes + * persisted during registration. + * @param opts.credential.counter - The last known signature counter value. + * @param opts.credential.transports - Optional authenticator transports. + * @param opts.requireUserVerification - When `true`, verification fails + * if the UV flag is not set. Defaults to `false`. + * @returns Verification result containing `verified` status and parsed + * authentication info (new counter, origin, RP ID). + */ +export async function verifyAuthenticationResponse(opts: { + response: PasskeyAuthenticationResponse; + expectedChallenge: string; + expectedOrigin: string | string[]; + expectedRPID: string; + credential: { + id: string; + publicKey: Uint8Array; + counter: number; + transports?: AuthenticatorTransportFuture[]; + }; + requireUserVerification?: boolean; +}): Promise { + const { + response, + expectedChallenge, + expectedOrigin, + expectedRPID, + credential, + requireUserVerification = false, + } = opts; + + const { + id, + rawId, + type: credentialType, + response: assertionResponse, + } = response; + + // Ensure credential specified an ID + if (!id) { + throw new Error('Missing credential ID'); + } + + // Ensure ID is base64url-encoded + if (id !== rawId) { + throw new Error('Credential ID was not base64url-encoded'); + } + + // Make sure credential type is public-key + if (credentialType !== 'public-key') { + throw new Error( + `Unexpected credential type ${String(credentialType)}, expected "public-key"`, + ); + } + + if (typeof assertionResponse?.clientDataJSON !== 'string') { + throw new Error('Credential response clientDataJSON was not a string'); + } + + const clientDataJSON = decodeClientDataJSON(assertionResponse.clientDataJSON); + const { type, challenge, origin, tokenBinding } = clientDataJSON; + + // Make sure we're handling an authentication + if (type !== 'webauthn.get') { + throw new Error(`Unexpected authentication response type: ${type}`); + } + + // Ensure the device provided the challenge we gave it + if (challenge !== expectedChallenge) { + throw new Error( + `Unexpected authentication response challenge "${challenge}", expected "${expectedChallenge}"`, + ); + } + + // Check that the origin is our site + const expectedOrigins = Array.isArray(expectedOrigin) + ? expectedOrigin + : [expectedOrigin]; + if (!expectedOrigins.includes(origin)) { + throw new Error( + `Unexpected authentication response origin "${origin}", expected one of: ${expectedOrigins.join(', ')}`, + ); + } + + if ( + assertionResponse.userHandle && + typeof assertionResponse.userHandle !== 'string' + ) { + throw new Error('Credential response userHandle was not a string'); + } + + if (tokenBinding) { + if (typeof tokenBinding !== 'object') { + throw new Error('ClientDataJSON tokenBinding was not an object'); + } + + if ( + !['present', 'supported', 'not-supported'].includes(tokenBinding.status) + ) { + throw new Error(`Unexpected tokenBinding status ${tokenBinding.status}`); + } + } + + const authDataBuffer = base64URLToBytes(assertionResponse.authenticatorData); + const parsedAuthData: ParsedAuthenticatorData = + parseAuthenticatorData(authDataBuffer); + const { rpIdHash, flags, counter } = parsedAuthData; + + // Make sure the response's RP ID is ours + const matchedRPID = matchExpectedRPID(rpIdHash, [expectedRPID]); + + // WebAuthn only requires the user presence flag be true + if (!flags.up) { + throw new Error('User not present during authentication'); + } + + // Enforce user verification if required + if (requireUserVerification && !flags.uv) { + throw new Error( + 'User verification required, but user could not be verified', + ); + } + + const clientDataHash = sha256( + base64URLToBytes(assertionResponse.clientDataJSON), + ); + const signatureBase = concatBytes([authDataBuffer, clientDataHash]); + + const signature = base64URLToBytes(assertionResponse.signature); + + const cosePublicKey = decodePartialCBOR( + new Uint8Array(credential.publicKey), + 0, + )[0] as Map; + + const verified = await verifySignature({ + cosePublicKey, + signature, + data: signatureBase, + }); + + if (!verified) { + return { verified: false }; + } + + if ( + (counter > 0 || credential.counter > 0) && + counter <= credential.counter + ) { + throw new Error( + `Response counter value ${counter} must be greater than stored counter ${credential.counter}`, + ); + } + + return { + verified: true, + authenticationInfo: { + credentialId: credential.id, + newCounter: counter, + userVerified: flags.uv, + origin: clientDataJSON.origin, + rpID: matchedRPID, + }, + }; +} diff --git a/packages/passkey-controller/src/webauthn/verify-registration-response.test.ts b/packages/passkey-controller/src/webauthn/verify-registration-response.test.ts new file mode 100644 index 00000000000..ae70ac73d20 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/verify-registration-response.test.ts @@ -0,0 +1,969 @@ +import { encodeCBOR } from '@levischuck/tiny-cbor'; +import { p256 } from '@noble/curves/p256'; +import { sha256 } from '@noble/hashes/sha2'; + +import { base64URLToBytes } from '../utils/encoding'; +import { bytesToBase64URL } from '../utils/encoding'; +import { COSEALG, COSECRV, COSEKEYS, COSEKTY } from './constants'; +import { decodeClientDataJSON } from './decode-client-data-json'; +import * as parseAuthenticatorDataModule from './parse-authenticator-data'; +import type { PasskeyRegistrationResponse } from './types'; +import { verifyRegistrationResponse } from './verify-registration-response'; + +const EXPECTED_ORIGIN = 'https://dev.dontneeda.pw'; +const EXPECTED_RP_ID = 'dev.dontneeda.pw'; + +const attestationNone: PasskeyRegistrationResponse = { + id: 'AdKXJEch1aV5Wo7bj7qLHskVY4OoNaj9qu8TPdJ7kSAgUeRxWNngXlcNIGt4gexZGKVGcqZpqqWordXb_he1izY', + rawId: + 'AdKXJEch1aV5Wo7bj7qLHskVY4OoNaj9qu8TPdJ7kSAgUeRxWNngXlcNIGt4gexZGKVGcqZpqqWordXb_he1izY', + response: { + attestationObject: + 'o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjFPdxHEOnAiLIp26idVjIguzn3I' + + 'pr_RlsKZWsa-5qK-KBFAAAAAAAAAAAAAAAAAAAAAAAAAAAAQQHSlyRHIdWleVqO24-6ix7JFWODqDWo_arvEz3Se' + + '5EgIFHkcVjZ4F5XDSBreIHsWRilRnKmaaqlqK3V2_4XtYs2pQECAyYgASFYID5PQTZQQg6haZFQWFzqfAOyQ_ENs' + + 'MH8xxQ4GRiNPsqrIlggU8IVUOV8qpgk_Jh-OTaLuZL52KdX1fTht07X4DiQPow', + clientDataJSON: + 'eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiYUVWalkxQlhkWHBw' + + 'VURBd1NEQndOV2Q0YURKZmRUVmZVRU0wVG1WWloyUSIsIm9yaWdpbiI6Imh0dHBzOlwvXC9kZXYuZG9udG5lZWRh' + + 'LnB3IiwiYW5kcm9pZFBhY2thZ2VOYW1lIjoib3JnLm1vemlsbGEuZmlyZWZveCJ9', + transports: [], + }, + type: 'public-key', + clientExtensionResults: {}, +}; + +const attestationFIDOU2F: PasskeyRegistrationResponse = { + id: 'VHzbxaYaJu2P8m1Y2iHn2gRNHrgK0iYbn9E978L3Qi7Q-chFeicIHwYCRophz5lth2nCgEVKcgWirxlgidgbUQ', + rawId: + 'VHzbxaYaJu2P8m1Y2iHn2gRNHrgK0iYbn9E978L3Qi7Q-chFeicIHwYCRophz5lth2nCgEVKcgWirxlgidgbUQ', + response: { + attestationObject: + 'o2NmbXRoZmlkby11MmZnYXR0U3RtdKJjc2lnWEcwRQIgRYUftNUmhT0VWTZmIgDmrOoP26Pcre-kL3DLnCrXbegCIQCOu_x5gqp-Rej76zeBuXlk8e7J-9WM_i-wZmCIbIgCGmN4NWOBWQLBMIICvTCCAaWgAwIBAgIEKudiYzANBgkqhkiG9w0BAQsFADAuMSwwKgYDVQQDEyNZdWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwMDYzMTAgFw0xNDA4MDEwMDAwMDBaGA8yMDUwMDkwNDAwMDAwMFowbjELMAkGA1UEBhMCU0UxEjAQBgNVBAoMCVl1YmljbyBBQjEiMCAGA1UECwwZQXV0aGVudGljYXRvciBBdHRlc3RhdGlvbjEnMCUGA1UEAwweWXViaWNvIFUyRiBFRSBTZXJpYWwgNzE5ODA3MDc1MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEKgOGXmBD2Z4R_xCqJVRXhL8Jr45rHjsyFykhb1USGozZENOZ3cdovf5Ke8fj2rxi5tJGn_VnW4_6iQzKdIaeP6NsMGowIgYJKwYBBAGCxAoCBBUxLjMuNi4xLjQuMS40MTQ4Mi4xLjEwEwYLKwYBBAGC5RwCAQEEBAMCBDAwIQYLKwYBBAGC5RwBAQQEEgQQbUS6m_bsLkm5MAyP6SDLczAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQByV9A83MPhFWmEkNb4DvlbUwcjc9nmRzJjKxHc3HeK7GvVkm0H4XucVDB4jeMvTke0WHb_jFUiApvpOHh5VyMx5ydwFoKKcRs5x0_WwSWL0eTZ5WbVcHkDR9pSNcA_D_5AsUKOBcbpF5nkdVRxaQHuuIuwV4k1iK2IqtMNcU8vL6w21U261xCcWwJ6sMq4zzVO8QCKCQhsoIaWrwz828GDmPzfAjFsJiLJXuYivdHACkeJ5KHMt0mjVLpfJ2BCML7_rgbmvwL7wBW80VHfNdcKmKjkLcpEiPzwcQQhiN_qHV90t-p4iyr5xRSpurlP5zic2hlRkLKxMH2_kRjhqSn4aGF1dGhEYXRhWMQ93EcQ6cCIsinbqJ1WMiC7Ofcimv9GWwplaxr7mor4oEEAAAAAAAAAAAAAAAAAAAAAAAAAAABAVHzbxaYaJu2P8m1Y2iHn2gRNHrgK0iYbn9E978L3Qi7Q-chFeicIHwYCRophz5lth2nCgEVKcgWirxlgidgbUaUBAgMmIAEhWCDIkcsOaVKDIQYwq3EDQ-pST2kRwNH_l1nCgW-WcFpNXiJYIBSbummp-KO3qZeqmvZ_U_uirCDL2RNj3E5y4_KzefIr', + clientDataJSON: + 'eyJjaGFsbGVuZ2UiOiJkRzkwWVd4c2VWVnVhWEYxWlZaaGJIVmxSWFpsY25sQmRIUmxjM1JoZEdsdmJnIiwiY2xpZW50RXh0ZW5zaW9ucyI6e30sImhhc2hBbGdvcml0aG0iOiJTSEEtMjU2Iiwib3JpZ2luIjoiaHR0cHM6Ly9kZXYuZG9udG5lZWRhLnB3IiwidHlwZSI6IndlYmF1dGhuLmNyZWF0ZSJ9', + transports: [], + }, + type: 'public-key', + clientExtensionResults: {}, +}; + +const attestationPacked: PasskeyRegistrationResponse = { + id: 'AYThY1csINY4JrbHyGmqTl1nL_F1zjAF3hSAIngz8kAcjugmAMNVvxZRwqpEH-bNHHAIv291OX5ko9eDf_5mu3UB2BvsScr2K-ppM4owOpGsqwg5tZglqqmxIm1Q', + rawId: + 'AYThY1csINY4JrbHyGmqTl1nL_F1zjAF3hSAIngz8kAcjugmAMNVvxZRwqpEH-bNHHAIv291OX5ko9eDf_5mu3UB2BvsScr2K-ppM4owOpGsqwg5tZglqqmxIm1Q', + response: { + attestationObject: + 'o2NmbXRmcGFja2VkZ2F0dFN0bXSiY2FsZyZjc2lnWEcwRQIhANvrPZMUFrl_rvlgR' + + 'qz6lCPlF6B4y885FYUCCrhrzAYXAiAb4dQKXbP3IimsTTadkwXQlrRVdxzlbmPXt847-Oh6r2hhdXRoRGF0YVjhP' + + 'dxHEOnAiLIp26idVjIguzn3Ipr_RlsKZWsa-5qK-KBFXsOO-a3OAAI1vMYKZIsLJfHwVQMAXQGE4WNXLCDWOCa2x' + + '8hpqk5dZy_xdc4wBd4UgCJ4M_JAHI7oJgDDVb8WUcKqRB_mzRxwCL9vdTl-ZKPXg3_-Zrt1Adgb7EnK9ivqaTOKM' + + 'DqRrKsIObWYJaqpsSJtUKUBAgMmIAEhWCBKMVVaivqCBpqqAxMjuCo5jMeUdh3jDOC0EF4fLBNNTyJYILc7rqDDe' + + 'X1pwCLrl3ZX7IThrtZNwKQVLQyfHiorqP-n', + clientDataJSON: + 'eyJjaGFsbGVuZ2UiOiJjelpRU1dKQ2JsQlFibkpIVGxOQ2VFNWtkRVJ5VkRkVmNsWlpT' + + 'a3M1U0UwIiwib3JpZ2luIjoiaHR0cHM6Ly9kZXYuZG9udG5lZWRhLnB3IiwidHlwZSI6IndlYmF1dGhuLmNyZWF0' + + 'ZSJ9', + transports: [], + }, + clientExtensionResults: {}, + type: 'public-key', +}; + +const attestationPackedX5C: PasskeyRegistrationResponse = { + id: '4rrvMciHCkdLQ2HghazIp1sMc8TmV8W8RgoX-x8tqV_1AmlqWACqUK8mBGLandr-htduQKPzgb2yWxOFV56Tlg', + rawId: + '4rrvMciHCkdLQ2HghazIp1sMc8TmV8W8RgoX-x8tqV_1AmlqWACqUK8mBGLandr-htduQKPzgb2yWxOFV56Tlg', + response: { + attestationObject: + 'o2NmbXRmcGFja2VkZ2F0dFN0bXSjY2FsZyZjc2lnWEcwRQIhAIMt_hGMtdgpIVIwMOeKK' + + 'w0IkUUFkXSY8arKh3Q0c5QQAiB9Sv9JavAEmppeH_XkZjB7TFM3jfxsgl97iIkvuJOUImN4NWOBWQLBMIICvTCCAaWgA' + + 'wIBAgIEKudiYzANBgkqhkiG9w0BAQsFADAuMSwwKgYDVQQDEyNZdWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwM' + + 'DYzMTAgFw0xNDA4MDEwMDAwMDBaGA8yMDUwMDkwNDAwMDAwMFowbjELMAkGA1UEBhMCU0UxEjAQBgNVBAoMCVl1Ymljb' + + 'yBBQjEiMCAGA1UECwwZQXV0aGVudGljYXRvciBBdHRlc3RhdGlvbjEnMCUGA1UEAwweWXViaWNvIFUyRiBFRSBTZXJpY' + + 'WwgNzE5ODA3MDc1MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEKgOGXmBD2Z4R_xCqJVRXhL8Jr45rHjsyFykhb1USG' + + 'ozZENOZ3cdovf5Ke8fj2rxi5tJGn_VnW4_6iQzKdIaeP6NsMGowIgYJKwYBBAGCxAoCBBUxLjMuNi4xLjQuMS40MTQ4M' + + 'i4xLjEwEwYLKwYBBAGC5RwCAQEEBAMCBDAwIQYLKwYBBAGC5RwBAQQEEgQQbUS6m_bsLkm5MAyP6SDLczAMBgNVHRMBA' + + 'f8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQByV9A83MPhFWmEkNb4DvlbUwcjc9nmRzJjKxHc3HeK7GvVkm0H4XucVDB4j' + + 'eMvTke0WHb_jFUiApvpOHh5VyMx5ydwFoKKcRs5x0_WwSWL0eTZ5WbVcHkDR9pSNcA_D_5AsUKOBcbpF5nkdVRxaQHuu' + + 'IuwV4k1iK2IqtMNcU8vL6w21U261xCcWwJ6sMq4zzVO8QCKCQhsoIaWrwz828GDmPzfAjFsJiLJXuYivdHACkeJ5KHMt' + + '0mjVLpfJ2BCML7_rgbmvwL7wBW80VHfNdcKmKjkLcpEiPzwcQQhiN_qHV90t-p4iyr5xRSpurlP5zic2hlRkLKxMH2_k' + + 'RjhqSn4aGF1dGhEYXRhWMQ93EcQ6cCIsinbqJ1WMiC7Ofcimv9GWwplaxr7mor4oEEAAAAcbUS6m_bsLkm5MAyP6SDLc' + + 'wBA4rrvMciHCkdLQ2HghazIp1sMc8TmV8W8RgoX-x8tqV_1AmlqWACqUK8mBGLandr-htduQKPzgb2yWxOFV56TlqUBA' + + 'gMmIAEhWCBsJbGAjckW-AA_XMk8OnB-VUvrs35ZpjtVJXRhnvXiGiJYIL2ncyg_KesCi44GH8UcZXYwjBkVdGMjNd6LF' + + 'myiD6xf', + clientDataJSON: + 'eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiZEc5MFlXeHNlVlZ1YVhG' + + 'MVpWWmhiSFZsUlhabGNubFVhVzFsIiwib3JpZ2luIjoiaHR0cHM6Ly9kZXYuZG9udG5lZWRhLnB3In0=', + transports: [], + }, + type: 'public-key', + clientExtensionResults: {}, +}; + +const noneChallenge = decodeClientDataJSON( + attestationNone.response.clientDataJSON, +).challenge; + +const fidoU2fChallenge = decodeClientDataJSON( + attestationFIDOU2F.response.clientDataJSON, +).challenge; + +const packedChallenge = decodeClientDataJSON( + attestationPacked.response.clientDataJSON, +).challenge; + +const packedX5cChallenge = decodeClientDataJSON( + attestationPackedX5C.response.clientDataJSON, +).challenge; + +const TEST_RP_ID = 'example.com'; +const TEST_ORIGIN = 'https://example.com'; +const TEST_CHALLENGE = bytesToBase64URL(new Uint8Array(32).fill(0xab)); + +function makeClientDataJSON( + overrides?: Partial<{ + type: string; + challenge: string; + origin: string; + }>, +): string { + const json = JSON.stringify({ + type: overrides?.type ?? 'webauthn.create', + challenge: overrides?.challenge ?? TEST_CHALLENGE, + origin: overrides?.origin ?? TEST_ORIGIN, + }); + return bytesToBase64URL(new TextEncoder().encode(json)); +} + +function buildCosePublicKeyMap( + pubKeyBytes: Uint8Array, +): Map { + const map = new Map(); + map.set(COSEKEYS.Kty, COSEKTY.EC2); + map.set(COSEKEYS.Alg, COSEALG.ES256); + map.set(COSEKEYS.Crv, COSECRV.P256); + map.set(COSEKEYS.X, pubKeyBytes.slice(1, 33)); + map.set(COSEKEYS.Y, pubKeyBytes.slice(33, 65)); + return map; +} + +function generateES256KeyPair(): { + privateKey: Uint8Array; + cosePublicKeyCBOR: Uint8Array; +} { + const privateKey = p256.utils.randomPrivateKey(); + const publicKeyRaw = p256.getPublicKey(privateKey, false); + const coseMap = buildCosePublicKeyMap(publicKeyRaw); + const cosePublicKeyCBOR = encodeCBOR(coseMap); + return { privateKey, cosePublicKeyCBOR }; +} + +function buildAuthenticatorData(opts: { + rpIdHash: Uint8Array; + flags: number; + counter: number; + aaguid?: Uint8Array; + credentialID?: Uint8Array; + credentialPublicKey?: Uint8Array; +}): Uint8Array { + const parts: Uint8Array[] = []; + parts.push(opts.rpIdHash); + parts.push(new Uint8Array([opts.flags])); + + const counterBuf = new Uint8Array(4); + new DataView(counterBuf.buffer).setUint32(0, opts.counter, false); + parts.push(counterBuf); + + if (opts.aaguid && opts.credentialID && opts.credentialPublicKey) { + parts.push(opts.aaguid); + + const credIDLen = new Uint8Array(2); + new DataView(credIDLen.buffer).setUint16( + 0, + opts.credentialID.length, + false, + ); + parts.push(credIDLen); + parts.push(opts.credentialID); + parts.push(opts.credentialPublicKey); + } + + let totalLength = 0; + for (const part of parts) { + totalLength += part.length; + } + const result = new Uint8Array(totalLength); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.length; + } + return result; +} + +function buildAttestationObject( + authData: Uint8Array, + fmt: string = 'none', + attStmt: Map = new Map(), +): Uint8Array { + const map = new Map(); + map.set('fmt', fmt); + map.set('attStmt', attStmt); + map.set('authData', authData); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return encodeCBOR(map as any); +} + +function buildRegistrationResponse( + authData: Uint8Array, + credentialId: string, + fmt: string = 'none', + attStmt: Map = new Map(), + clientDataJSONOverrides?: Partial<{ + type: string; + challenge: string; + origin: string; + }>, +): PasskeyRegistrationResponse { + const attestationObject = buildAttestationObject(authData, fmt, attStmt); + return { + id: credentialId, + rawId: credentialId, + type: 'public-key', + response: { + clientDataJSON: makeClientDataJSON(clientDataJSONOverrides), + attestationObject: bytesToBase64URL(attestationObject), + }, + clientExtensionResults: {}, + }; +} + +describe('verifyRegistrationResponse', () => { + it('verifies none attestation', async () => { + const verification = await verifyRegistrationResponse({ + response: attestationNone, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + }); + + expect(verification.verified).toBe(true); + if (!verification.verified) { + return; + } + + const { registrationInfo } = verification; + expect(registrationInfo.attestationFormat).toBe('none'); + expect(registrationInfo.counter).toBe(0); + expect(registrationInfo.publicKey).toStrictEqual( + base64URLToBytes( + 'pQECAyYgASFYID5PQTZQQg6haZFQWFzqfAOyQ_ENsMH8xxQ4GRiNPsqrIlggU8IVUOV8qpgk_Jh-OTaLuZL52KdX1fTht07X4DiQPow', + ), + ); + expect(registrationInfo.credentialId).toBe( + 'AdKXJEch1aV5Wo7bj7qLHskVY4OoNaj9qu8TPdJ7kSAgUeRxWNngXlcNIGt4gexZGKVGcqZpqqWordXb_he1izY', + ); + expect(registrationInfo.aaguid).toBe( + '00000000-0000-0000-0000-000000000000', + ); + // authData flags byte is 0x45 (UP | UV | AT); UV is set in this vector. + expect(registrationInfo.userVerified).toBe(true); + }); + + it('verifies packed self-attestation (SimpleWebAuthn conformance vector)', async () => { + const verification = await verifyRegistrationResponse({ + response: attestationPacked, + expectedChallenge: packedChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + }); + + expect(verification.verified).toBe(true); + if (!verification.verified) { + return; + } + + expect(verification.registrationInfo.attestationFormat).toBe('packed'); + expect(verification.registrationInfo.counter).toBe(1589874425); + expect(verification.registrationInfo.publicKey).toStrictEqual( + base64URLToBytes( + 'pQECAyYgASFYIEoxVVqK-oIGmqoDEyO4KjmMx5R2HeMM4LQQXh8sE01PIlggtzuuoMN5fWnAIuuXdlfshOGu1k3ApBUtDJ8eKiuo_6c', + ), + ); + expect(verification.registrationInfo.credentialId).toBe( + 'AYThY1csINY4JrbHyGmqTl1nL_F1zjAF3hSAIngz8kAcjugmAMNVvxZRwqpEH-bNHHAIv291OX5ko9eDf_5mu3UB2BvsScr2K-ppM4owOpGsqwg5tZglqqmxIm1Q', + ); + }); + + it('rejects when response challenge is not expected value', async () => { + await expect( + verifyRegistrationResponse({ + response: attestationNone, + expectedChallenge: 'shouldhavebeenthisvalue', + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + }), + ).rejects.toThrow('Unexpected registration response challenge'); + }); + + it('rejects when response origin is not expected value', async () => { + await expect( + verifyRegistrationResponse({ + response: attestationNone, + expectedChallenge: noneChallenge, + expectedOrigin: 'https://different.address', + expectedRPID: EXPECTED_RP_ID, + }), + ).rejects.toThrow('Unexpected registration response origin'); + }); + + it('rejects when RP ID is not expected value', async () => { + await expect( + verifyRegistrationResponse({ + response: attestationNone, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: 'wrong-rp.com', + }), + ).rejects.toThrow('Unexpected RP ID hash'); + }); + + it('rejects wrong clientDataJSON type', async () => { + const badTypeClientDataJSON = btoa( + JSON.stringify({ + ...decodeClientDataJSON(attestationNone.response.clientDataJSON), + type: 'webauthn.get', + }), + ) + .replace(/\+/gu, '-') + .replace(/\//gu, '_') + .replace(/[=]+$/u, ''); + + await expect( + verifyRegistrationResponse({ + response: { + ...attestationNone, + response: { + ...attestationNone.response, + clientDataJSON: badTypeClientDataJSON, + }, + }, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + }), + ).rejects.toThrow('Unexpected registration response type'); + }); + + it('rejects missing credential ID', async () => { + await expect( + verifyRegistrationResponse({ + response: { + ...attestationNone, + id: '', + rawId: '', + }, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + }), + ).rejects.toThrow('Missing credential ID'); + }); + + it('rejects fido-u2f attestation as unsupported format', async () => { + await expect( + verifyRegistrationResponse({ + response: attestationFIDOU2F, + expectedChallenge: fidoU2fChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + requireUserVerification: false, + }), + ).rejects.toThrow('Unsupported attestation format: fido-u2f'); + }); + + it('rejects packed attestation with x5c certificate chain', async () => { + await expect( + verifyRegistrationResponse({ + response: attestationPackedX5C, + expectedChallenge: packedX5cChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + requireUserVerification: false, + }), + ).rejects.toThrow( + 'Packed attestation with certificate chain (x5c) is not supported', + ); + }); +}); + +describe('verifyRegistrationResponse edge cases', () => { + it('rejects id !== rawId', async () => { + const response: PasskeyRegistrationResponse = { + id: 'id1', + rawId: 'id2', + type: 'public-key', + response: { + clientDataJSON: makeClientDataJSON(), + attestationObject: bytesToBase64URL(new Uint8Array([0])), + }, + clientExtensionResults: {}, + }; + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }), + ).rejects.toThrow('Credential ID was not base64url-encoded'); + }); + + it('rejects wrong credential type', async () => { + const response = { + id: 'abc', + rawId: 'abc', + type: 'not-public-key', + response: { + clientDataJSON: makeClientDataJSON(), + attestationObject: bytesToBase64URL(new Uint8Array([0])), + }, + clientExtensionResults: {}, + } as unknown as PasskeyRegistrationResponse; + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }), + ).rejects.toThrow('Unexpected credential type'); + }); + + it('rejects user verification not met when required', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x30); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: cosePublicKeyCBOR, + }); + + const credentialIdB64 = bytesToBase64URL(credentialID); + const response = buildRegistrationResponse(authData, credentialIdB64); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + requireUserVerification: true, + }), + ).rejects.toThrow('User verification was required'); + }); + + it('rejects user not present', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x31); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x40, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: cosePublicKeyCBOR, + }); + + const credentialIdB64 = bytesToBase64URL(credentialID); + const response = buildRegistrationResponse(authData, credentialIdB64); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }), + ).rejects.toThrow('User presence was required'); + }); + + it('rejects credential id not matching authenticator data', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x30); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: cosePublicKeyCBOR, + }); + + const wrongWrapperId = bytesToBase64URL(new Uint8Array(16).fill(0x42)); + const response = buildRegistrationResponse(authData, wrongWrapperId); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }), + ).rejects.toThrow( + 'Credential id does not match the credential id in authenticator data', + ); + }); + + it('rejects unsupported public key algorithm', async () => { + const unsupportedMap = new Map(); + unsupportedMap.set(COSEKEYS.Kty, COSEKTY.EC2); + unsupportedMap.set(COSEKEYS.Alg, -999); + unsupportedMap.set(COSEKEYS.Crv, COSECRV.P256); + unsupportedMap.set(COSEKEYS.X, new Uint8Array(32).fill(0x01)); + unsupportedMap.set(COSEKEYS.Y, new Uint8Array(32).fill(0x02)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const unsupportedKeyCBOR = encodeCBOR(unsupportedMap as any); + + const credentialID = new Uint8Array(16).fill(0x32); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: unsupportedKeyCBOR, + }); + + const credentialIdB64 = bytesToBase64URL(credentialID); + const response = buildRegistrationResponse(authData, credentialIdB64); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }), + ).rejects.toThrow('Unexpected public key alg'); + }); + + it('rejects packed attestation with missing alg', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x61); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: cosePublicKeyCBOR, + }); + + const attStmt = new Map(); + attStmt.set('sig', new Uint8Array(64)); + + const credentialIdB64 = bytesToBase64URL(credentialID); + const response = buildRegistrationResponse( + authData, + credentialIdB64, + 'packed', + attStmt, + ); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }), + ).rejects.toThrow('Packed attestation statement missing alg'); + }); + + it('rejects packed attestation with mismatched alg', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x62); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: cosePublicKeyCBOR, + }); + + const attStmt = new Map(); + attStmt.set('alg', COSEALG.RS256); + attStmt.set('sig', new Uint8Array(64)); + + const credentialIdB64 = bytesToBase64URL(credentialID); + const response = buildRegistrationResponse( + authData, + credentialIdB64, + 'packed', + attStmt, + ); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }), + ).rejects.toThrow('does not match credential alg'); + }); + + it('rejects packed attestation with missing signature', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x35); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: cosePublicKeyCBOR, + }); + + const attStmt = new Map(); + attStmt.set('alg', COSEALG.ES256); + + const credentialIdB64 = bytesToBase64URL(credentialID); + const response = buildRegistrationResponse( + authData, + credentialIdB64, + 'packed', + attStmt, + ); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }), + ).rejects.toThrow('Packed attestation missing signature'); + }); + + it('rejects none attestation with non-empty attStmt', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x37); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: cosePublicKeyCBOR, + }); + + const attStmt = new Map(); + attStmt.set('unexpected', 'value'); + + const credentialIdB64 = bytesToBase64URL(credentialID); + const response = buildRegistrationResponse( + authData, + credentialIdB64, + 'none', + attStmt, + ); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }), + ).rejects.toThrow('None attestation had unexpected attestation statement'); + }); + + it('accepts expectedOrigin as array', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x38); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: cosePublicKeyCBOR, + }); + + const credentialIdB64 = bytesToBase64URL(credentialID); + const response = buildRegistrationResponse(authData, credentialIdB64); + + const result = await verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: ['https://other.com', TEST_ORIGIN], + expectedRPID: TEST_RP_ID, + }); + + expect(result.verified).toBe(true); + }); + + it('rejects tokenBinding that is not an object', async () => { + const clientDataJSON = bytesToBase64URL( + new TextEncoder().encode( + JSON.stringify({ + ...decodeClientDataJSON(attestationNone.response.clientDataJSON), + tokenBinding: 'invalid', + }), + ), + ); + + await expect( + verifyRegistrationResponse({ + response: { + ...attestationNone, + response: { + ...attestationNone.response, + clientDataJSON, + }, + }, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + }), + ).rejects.toThrow('ClientDataJSON tokenBinding was not an object'); + }); + + it('rejects tokenBinding with invalid status', async () => { + const clientDataJSON = bytesToBase64URL( + new TextEncoder().encode( + JSON.stringify({ + ...decodeClientDataJSON(attestationNone.response.clientDataJSON), + tokenBinding: { status: 'invalid-status' }, + }), + ), + ); + + await expect( + verifyRegistrationResponse({ + response: { + ...attestationNone, + response: { + ...attestationNone.response, + clientDataJSON, + }, + }, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + }), + ).rejects.toThrow('Unexpected tokenBinding.status value'); + }); + + it('rejects missing attested credential data', async () => { + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x01, + counter: 0, + }); + + const response = buildRegistrationResponse(authData, 'missing-attested'); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }), + ).rejects.toThrow('No credential ID was provided by authenticator'); + }); + + it('returns verified false for packed attestation with invalid signature', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x91); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: cosePublicKeyCBOR, + }); + + const attStmt = new Map(); + attStmt.set('alg', COSEALG.ES256); + attStmt.set('sig', new Uint8Array(64).fill(0xff)); + + const response = buildRegistrationResponse( + authData, + bytesToBase64URL(credentialID), + 'packed', + attStmt, + ); + + const verification = await verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }); + + expect(verification).toStrictEqual({ verified: false }); + }); + + const mockParsedAuthBase = { + rpIdHash: sha256(new TextEncoder().encode(EXPECTED_RP_ID)), + flags: { + up: true, + uv: false, + be: false, + bs: false, + at: true, + ed: false, + flagsByte: 0x41, + }, + counter: 0, + } as const; + + it('throws when parsed authenticator data has no credential ID', async () => { + const spy = jest + .spyOn(parseAuthenticatorDataModule, 'parseAuthenticatorData') + .mockReturnValueOnce({ + ...mockParsedAuthBase, + }); + + await expect( + verifyRegistrationResponse({ + response: attestationNone, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + }), + ).rejects.toThrow('No credential ID was provided by authenticator'); + + spy.mockRestore(); + }); + + it('throws when parsed authenticator data has no credential public key', async () => { + const spy = jest + .spyOn(parseAuthenticatorDataModule, 'parseAuthenticatorData') + .mockReturnValueOnce({ + ...mockParsedAuthBase, + credentialID: new Uint8Array([1]), + }); + + const credentialIdB64 = bytesToBase64URL(new Uint8Array([1])); + await expect( + verifyRegistrationResponse({ + response: { + ...attestationNone, + id: credentialIdB64, + rawId: credentialIdB64, + }, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + }), + ).rejects.toThrow('No public key was provided by authenticator'); + + spy.mockRestore(); + }); + + it('throws when parsed authenticator data has no AAGUID', async () => { + const spy = jest + .spyOn(parseAuthenticatorDataModule, 'parseAuthenticatorData') + .mockReturnValueOnce({ + ...mockParsedAuthBase, + credentialID: new Uint8Array([1]), + credentialPublicKey: new Uint8Array([0xa1]), + }); + + const credentialIdB64 = bytesToBase64URL(new Uint8Array([1])); + await expect( + verifyRegistrationResponse({ + response: { + ...attestationNone, + id: credentialIdB64, + rawId: credentialIdB64, + }, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + }), + ).rejects.toThrow('No AAGUID was present during registration'); + + spy.mockRestore(); + }); +}); + +describe('verifyRegistrationResponse missing public key fields', () => { + it('rejects public key missing alg field', async () => { + const coseMapNoAlg = new Map(); + coseMapNoAlg.set(COSEKEYS.Kty, COSEKTY.EC2); + coseMapNoAlg.set(COSEKEYS.Crv, COSECRV.P256); + coseMapNoAlg.set(COSEKEYS.X, new Uint8Array(32).fill(0x01)); + coseMapNoAlg.set(COSEKEYS.Y, new Uint8Array(32).fill(0x02)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const coseNoAlgCBOR = encodeCBOR(coseMapNoAlg as any); + + const credentialID = new Uint8Array(16).fill(0x40); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: coseNoAlgCBOR, + }); + + const credentialIdB64 = bytesToBase64URL(credentialID); + const response = buildRegistrationResponse(authData, credentialIdB64); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }), + ).rejects.toThrow('Credential public key was missing numeric alg'); + }); +}); diff --git a/packages/passkey-controller/src/webauthn/verify-registration-response.ts b/packages/passkey-controller/src/webauthn/verify-registration-response.ts new file mode 100644 index 00000000000..ce8eebf6fd0 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/verify-registration-response.ts @@ -0,0 +1,317 @@ +import { decodePartialCBOR } from '@levischuck/tiny-cbor'; +import { concatBytes } from '@metamask/utils'; +import { sha256 } from '@noble/hashes/sha2'; + +import type { AuthenticatorTransportFuture } from '../types'; +import { + base64URLToBytes, + bytesToBase64URL, + bytesToHex, +} from '../utils/encoding'; +import { COSEALG, COSEKEYS } from './constants'; +import { decodeAttestationObject } from './decode-attestation-object'; +import { decodeClientDataJSON } from './decode-client-data-json'; +import { matchExpectedRPID } from './match-expected-rp-id'; +import { parseAuthenticatorData } from './parse-authenticator-data'; +import type { PasskeyRegistrationResponse } from './types'; +import { verifySignature } from './verify-signature'; + +export type VerifiedRegistrationResponse = + | { verified: false; registrationInfo?: never } + | { + verified: true; + registrationInfo: { + credentialId: string; + publicKey: Uint8Array; + counter: number; + transports?: AuthenticatorTransportFuture[]; + aaguid: string; + attestationFormat: string; + userVerified: boolean; + }; + }; + +/** + * Verifies a WebAuthn registration (attestation) response per + * W3C WebAuthn Level 3 §7.1. + * + * Performs the following checks in order: + * 1. Credential ID presence and base64url consistency (`id === rawId`), and + * that `id` matches the credential id inside parsed authenticator data. + * 2. Credential type is `"public-key"`. + * 3. `clientDataJSON` -- type is `"webauthn.create"`, challenge and origin + * match the expected values. + * 4. Attestation object -- CBOR-decodes and parses `authData` to verify + * the RP ID hash, user-presence flag, optional user-verification flag, + * and the attested credential public key algorithm. + * 5. Attestation statement -- supports `"none"` (no signature) and + * `"packed"` self-attestation (signature verified against the + * credential's own public key). + * + * @param opts - Verification options. + * @param opts.response - The `PublicKeyCredential` result from + * `navigator.credentials.create()`, serialized as JSON. + * @param opts.expectedChallenge - The base64url challenge that was passed + * to the authenticator (must match `clientDataJSON.challenge`). + * @param opts.expectedOrigin - One or more acceptable origins (e.g. + * `"chrome-extension://..."` or `"https://metamask.io"`). + * @param opts.expectedRPID - The Relying Party ID domain. The + * authenticator's `rpIdHash` is compared against `SHA-256(expectedRPID)`. + * @param opts.requireUserVerification - When `true`, verification fails + * if the UV flag is not set. Defaults to `false`. + * @param opts.supportedAlgorithmIDs - COSE algorithm identifiers accepted + * for the credential public key. Defaults to EdDSA, ES256, and RS256. + * @returns On success, `{ verified: true, registrationInfo }` with the + * parsed credential ID, public key, counter, AAGUID, and transport + * hints. On failure, `{ verified: false }`. + */ +export async function verifyRegistrationResponse(opts: { + response: PasskeyRegistrationResponse; + expectedChallenge: string; + expectedOrigin: string | string[]; + expectedRPID: string; + requireUserVerification?: boolean; + supportedAlgorithmIDs?: number[]; +}): Promise { + const { + response, + expectedChallenge, + expectedOrigin, + expectedRPID, + requireUserVerification = false, + supportedAlgorithmIDs = [COSEALG.EdDSA, COSEALG.ES256, COSEALG.RS256], + } = opts; + + const { + id, + rawId, + type: credentialType, + response: attestationResponse, + } = response; + + // Ensure credential specified an ID + if (!id) { + throw new Error('Missing credential ID'); + } + + // Ensure ID is base64url-encoded + if (id !== rawId) { + throw new Error('Credential ID was not base64url-encoded'); + } + + // Make sure credential type is public-key + if (credentialType !== 'public-key') { + throw new Error( + `Unexpected credential type ${String(credentialType)}, expected "public-key"`, + ); + } + + const clientDataJSON = decodeClientDataJSON( + attestationResponse.clientDataJSON, + ); + const { type, challenge, origin, tokenBinding } = clientDataJSON; + + // Make sure we're handling an registration + if (type !== 'webauthn.create') { + throw new Error(`Unexpected registration response type: ${type}`); + } + + // Ensure the device provided the challenge we gave it + if (challenge !== expectedChallenge) { + throw new Error( + `Unexpected registration response challenge "${challenge}", expected "${expectedChallenge}"`, + ); + } + + // Check that the origin is our site + const expectedOrigins = Array.isArray(expectedOrigin) + ? expectedOrigin + : [expectedOrigin]; + if (!expectedOrigins.includes(origin)) { + throw new Error( + `Unexpected registration response origin "${origin}", expected one of: ${expectedOrigins.join(', ')}`, + ); + } + + if (tokenBinding) { + if (typeof tokenBinding !== 'object') { + throw new Error('ClientDataJSON tokenBinding was not an object'); + } + + if ( + !['present', 'supported', 'not-supported'].includes(tokenBinding.status) + ) { + throw new Error( + `Unexpected tokenBinding.status value of "${tokenBinding.status}"`, + ); + } + } + + const attestationObjectBytes = base64URLToBytes( + attestationResponse.attestationObject, + ); + const decodedAttObj = decodeAttestationObject(attestationObjectBytes); + const fmt = decodedAttObj.get('fmt'); + const authData = decodedAttObj.get('authData'); + const attStmt = decodedAttObj.get('attStmt'); + + const parsedAuthData = parseAuthenticatorData(authData); + const { + rpIdHash, + flags, + counter, + credentialID, + credentialPublicKey, + aaguid, + } = parsedAuthData; + + matchExpectedRPID(rpIdHash, [expectedRPID]); + + // Make sure someone was physically present + if (!flags.up) { + throw new Error('User presence was required, but user was not present'); + } + + // Enforce user verification if specified + if (requireUserVerification && !flags.uv) { + throw new Error( + 'User verification was required, but user could not be verified', + ); + } + + if (!credentialID) { + throw new Error('No credential ID was provided by authenticator'); + } + + const attestedCredentialId = bytesToBase64URL(credentialID); + if (id !== attestedCredentialId) { + throw new Error( + 'Credential id does not match the credential id in authenticator data', + ); + } + + if (!credentialPublicKey) { + throw new Error('No public key was provided by authenticator'); + } + if (!aaguid) { + throw new Error('No AAGUID was present during registration'); + } + + const decodedPublicKey = decodePartialCBOR( + new Uint8Array(credentialPublicKey), + 0, + )[0] as Map; + const alg = decodedPublicKey.get(COSEKEYS.Alg); + + if (typeof alg !== 'number') { + throw new Error('Credential public key was missing numeric alg'); + } + + // Make sure the key algorithm is one we specified within the registration options + if (!supportedAlgorithmIDs.includes(alg)) { + throw new Error( + `Unexpected public key alg "${alg}", expected one of "${supportedAlgorithmIDs.join(', ')}"`, + ); + } + + let verified = false; + if (fmt === 'none') { + if (attStmt.size > 0) { + throw new Error('None attestation had unexpected attestation statement'); + } + verified = true; + } else if (fmt === 'packed') { + verified = await verifyPackedAttestation( + attStmt, + authData, + attestationResponse.clientDataJSON, + decodedPublicKey, + ); + } else { + throw new Error(`Unsupported attestation format: ${fmt}`); + } + + if (!verified) { + return { verified: false }; + } + + const aaguidHex = bytesToHex(aaguid); + const aaguidStr = [ + aaguidHex.slice(0, 8), + aaguidHex.slice(8, 12), + aaguidHex.slice(12, 16), + aaguidHex.slice(16, 20), + aaguidHex.slice(20), + ].join('-'); + + return { + verified: true, + registrationInfo: { + credentialId: attestedCredentialId, + publicKey: credentialPublicKey, + counter, + transports: + attestationResponse.transports as AuthenticatorTransportFuture[], + aaguid: aaguidStr, + attestationFormat: fmt, + userVerified: flags.uv, + }, + }; +} + +/** + * Verify packed self-attestation per WebAuthn §8.2: no x5c certificate + * chain, signature over `authData || SHA-256(clientDataJSON)` verified + * with the credential's own public key, and `alg` in the attestation + * statement must match the credential key's algorithm. + * + * @param attStmt - The attestation statement map from the attestation + * object. + * @param attStmt.get - Accessor to retrieve statement fields by key. + * @param attStmt.size - Number of entries in the statement. + * @param authData - Raw authenticator data bytes. + * @param clientDataJSONB64url - Base64url-encoded clientDataJSON. + * @param cosePublicKey - Decoded COSE public key map from authenticator + * data. + * @returns Whether the packed attestation signature is valid. + */ +async function verifyPackedAttestation( + attStmt: { get(key: string): unknown; size: number }, + authData: Uint8Array, + clientDataJSONB64url: string, + cosePublicKey: Map, +): Promise { + const attStmtAlg = attStmt.get('alg') as number | undefined; + const signature = attStmt.get('sig') as Uint8Array | undefined; + const x5c = attStmt.get('x5c') as Uint8Array[] | undefined; + + if (typeof attStmtAlg !== 'number') { + throw new Error('Packed attestation statement missing alg'); + } + + if (!signature) { + throw new Error('Packed attestation missing signature'); + } + + if (x5c && x5c.length > 0) { + throw new Error( + 'Packed attestation with certificate chain (x5c) is not supported; only self-attestation is accepted', + ); + } + + const credAlg = cosePublicKey.get(COSEKEYS.Alg) as number; + if (attStmtAlg !== credAlg) { + throw new Error( + `Packed attestation alg ${attStmtAlg} does not match credential alg ${credAlg}`, + ); + } + + const clientDataHash = sha256(base64URLToBytes(clientDataJSONB64url)); + const signatureBase = concatBytes([authData, clientDataHash]); + + return verifySignature({ + cosePublicKey, + signature, + data: signatureBase, + }); +} diff --git a/packages/passkey-controller/src/webauthn/verify-signature.test.ts b/packages/passkey-controller/src/webauthn/verify-signature.test.ts new file mode 100644 index 00000000000..3632ec93cdc --- /dev/null +++ b/packages/passkey-controller/src/webauthn/verify-signature.test.ts @@ -0,0 +1,708 @@ +import { ed25519 } from '@noble/curves/ed25519'; +import { p384, p521 } from '@noble/curves/nist'; +import { sha384, sha512 } from '@noble/hashes/sha2'; +import { webcrypto } from 'node:crypto'; + +import { base64URLToBytes } from '../utils/encoding'; +import { COSEALG, COSECRV, COSEKEYS, COSEKTY } from './constants'; +import { verifySignature } from './verify-signature'; + +function decodeJwkBase64Url(value: string): Uint8Array { + return Uint8Array.from( + atob( + value.replace(/-/gu, '+').replace(/_/gu, '/') + + '='.repeat((4 - (value.length % 4)) % 4), + ), + (char) => char.charCodeAt(0), + ); +} + +describe('verifySignature', () => { + it('verifies P-256 EC2 signature from conformance vector', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.EC2); + coseMap.set(COSEKEYS.Alg, COSEALG.ES256); + coseMap.set(COSEKEYS.Crv, COSECRV.P256); + coseMap.set( + COSEKEYS.X, + base64URLToBytes('_qRi-kwOVobsqJ_1GAHZYfC77QoIdsVFYkx2Mw20UM4'), + ); + coseMap.set( + COSEKEYS.Y, + base64URLToBytes('BXEathwyOK_uQRmlZ_m4wReHLujSXk_-e3-9co5B2MY'), + ); + + const data = base64URLToBytes( + 'Bt81jmu3ieajF4w1at8HmieVOTDymHd7xJguJCUsL-Q', + ); + const signature = base64URLToBytes( + 'MEQCH1h_F7TPTMVh_kwb_ssjD0_2U77bbXazz2ux-P6khLQCIQCutHs9eCBkCIMP3yA9mmNRKEfFd-REmhGY2GbHozaC7w', + ); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + + it('verifies P-384 EC2 signature', async () => { + const privateKey = p384.utils.randomSecretKey(); + const publicKeyRaw = p384.getPublicKey(privateKey, false); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.EC2); + coseMap.set(COSEKEYS.Alg, COSEALG.ES384); + coseMap.set(COSEKEYS.Crv, COSECRV.P384); + coseMap.set(COSEKEYS.X, publicKeyRaw.slice(1, 49)); + coseMap.set(COSEKEYS.Y, publicKeyRaw.slice(49, 97)); + + const data = new Uint8Array(32).fill(0xcc); + const hash = sha384(data); + const ecdsaSig = p384.sign(hash, privateKey); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature: new Uint8Array(ecdsaSig.toDERRawBytes()), + data, + }); + + expect(result).toBe(true); + }); + + it('verifies P-384 EC2 signature from conformance vector', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.EC2); + coseMap.set(COSEKEYS.Alg, COSEALG.ES384); + coseMap.set(COSEKEYS.Crv, COSECRV.P384); + coseMap.set( + COSEKEYS.X, + base64URLToBytes( + 'pm-0exykk1x0O72S9sm6fl-iXxFrGikjQHi1CgONIiEz_yDJdCPxN453qg6HLkOx', + ), + ); + coseMap.set( + COSEKEYS.Y, + base64URLToBytes( + '2B7yW7sgza8Sf7ifznQlGJqmJxgupkAevUqqOJTWaWBZiQ7sAf-TfAaNBukiz12K', + ), + ); + + const data = base64URLToBytes( + 'D7mI8UwWXv4rpfSQUNqtUXAhZEPbRLugmWclPpJ9m7c', + ); + const signature = base64URLToBytes( + 'MGMCL3lZ2Rjxo5WcmTCdWyB6jTE9PVuduOR_AsJu956J9S_mFNbHP_-MbyWem4dfb5iqAjABJhTRltNl5Y0O4XC7YLNsYKq2WxYQ1HFOMGsr6oNkUPsX3UAr2zeeWL_Tp1VgHeM', + ); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + + it('verifies P-521 EC2 signature from conformance vector', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.EC2); + coseMap.set(COSEKEYS.Alg, COSEALG.ES512); + coseMap.set(COSEKEYS.Crv, COSECRV.P521); + coseMap.set( + COSEKEYS.X, + base64URLToBytes( + 'AaLbnrCvCuQivbknRW50FjdqPQv4NRF9tHsN4QuVQ3sw8uSspd33o-NTBfjg5JzX9rnpbkKDigb6NugmrVjzNMNK', + ), + ); + coseMap.set( + COSEKEYS.Y, + base64URLToBytes( + 'AE64axa8L8PkLX5Td0GaX79cLOW9E2-8-ObhL9XT_ih-1XxbGQcA5VhL1gI0xIQq5zYAxgZYey6PmbbqgtcUPRVt', + ), + ); + + const data = base64URLToBytes( + '5p0h9RZTjLoBlnL2nY5pqOnhGy4q60NzbjDe2rVDR7o', + ); + const signature = base64URLToBytes( + 'MIGHAkFRpbGknlgpETORypMprGBXMkJMfuqgJupy3NcgCOaJJdj3Voz74kV2pjPqkLNpuO9FqVtXeEsUw-jYsBHcMqHZhwJCAQ88uFDJS5g81XVBcLMIgf6ro-F-5jgRAmHx3CRVNGdk81MYbFJhT3hd2w9RdhT8qBG0zzRBXYAcHrKo0qJwQZot', + ); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + + it('verifies Ed25519 OKP signature', async () => { + const privateKey = ed25519.utils.randomSecretKey(); + const publicKey = ed25519.getPublicKey(privateKey); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.OKP); + coseMap.set(COSEKEYS.Alg, COSEALG.EdDSA); + coseMap.set(COSEKEYS.Crv, COSECRV.ED25519); + coseMap.set(COSEKEYS.X, publicKey); + + const data = new Uint8Array(32).fill(0xdd); + const signature = ed25519.sign(data, privateKey); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + + it('verifies Ed25519 OKP signature from conformance vector', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.OKP); + coseMap.set(COSEKEYS.Alg, COSEALG.EdDSA); + coseMap.set(COSEKEYS.Crv, COSECRV.ED25519); + coseMap.set( + COSEKEYS.X, + base64URLToBytes('bN-2dTH53XfUq55T1RkvXMpwHV0dRVnMBPxuOBm1-vI'), + ); + + const data = base64URLToBytes( + 'SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NBAAAAMpHf6teVnkR1rSabDUgr4IkAIBqlqljErWIWWTGYn6Lqjsb8p3djr7sVZW7WYoECyh5xpAEBAycgBiFYIGzftnUx-d131KueU9UZL1zKcB1dHUVZzAT8bjgZtfrytEHOGqAdESuKacg0dIwKWfEP8VP4or6CINxkD5qWQYw', + ); + const signature = base64URLToBytes( + 'HdoQloEiGSUHf9dJXbVzyWNbDh0K25tpNQQpj5hrkhCcdfz0pCBPtqChka_4kfIbhf6JyY1EGAuf9pQdwqJVBQ', + ); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + + it('throws for unsupported EC2 curve', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.EC2); + coseMap.set(COSEKEYS.Alg, COSEALG.ES256); + coseMap.set(COSEKEYS.Crv, 99); + coseMap.set(COSEKEYS.X, new Uint8Array(32)); + coseMap.set(COSEKEYS.Y, new Uint8Array(32)); + + await expect( + verifySignature({ + cosePublicKey: coseMap, + signature: new Uint8Array(64), + data: new Uint8Array(32), + }), + ).rejects.toThrow('Unsupported EC2 curve'); + }); + + it('throws for missing EC2 coordinates', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.EC2); + coseMap.set(COSEKEYS.Alg, COSEALG.ES256); + coseMap.set(COSEKEYS.Crv, COSECRV.P256); + + await expect( + verifySignature({ + cosePublicKey: coseMap, + signature: new Uint8Array(64), + data: new Uint8Array(32), + }), + ).rejects.toThrow('EC2 public key missing x or y coordinate'); + }); + + it('throws for missing EC2 alg', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.EC2); + coseMap.set(COSEKEYS.Crv, COSECRV.P256); + coseMap.set(COSEKEYS.X, new Uint8Array(32)); + coseMap.set(COSEKEYS.Y, new Uint8Array(32)); + + await expect( + verifySignature({ + cosePublicKey: coseMap, + signature: new Uint8Array(64), + data: new Uint8Array(32), + }), + ).rejects.toThrow('EC2 public key missing alg'); + }); + + it('throws for missing OKP x coordinate', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.OKP); + coseMap.set(COSEKEYS.Alg, COSEALG.EdDSA); + coseMap.set(COSEKEYS.Crv, COSECRV.ED25519); + + await expect( + verifySignature({ + cosePublicKey: coseMap, + signature: new Uint8Array(64), + data: new Uint8Array(32), + }), + ).rejects.toThrow('OKP public key missing x coordinate'); + }); + + it('throws for unsupported OKP algorithm', async () => { + const privateKey = ed25519.utils.randomSecretKey(); + const publicKey = ed25519.getPublicKey(privateKey); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.OKP); + coseMap.set(COSEKEYS.Alg, COSEALG.ES256); + coseMap.set(COSEKEYS.Crv, COSECRV.ED25519); + coseMap.set(COSEKEYS.X, publicKey); + + await expect( + verifySignature({ + cosePublicKey: coseMap, + signature: ed25519.sign(new Uint8Array(32), privateKey), + data: new Uint8Array(32), + }), + ).rejects.toThrow('Unexpected OKP algorithm'); + }); + + it('throws for unsupported OKP curve', async () => { + const privateKey = ed25519.utils.randomSecretKey(); + const publicKey = ed25519.getPublicKey(privateKey); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.OKP); + coseMap.set(COSEKEYS.Alg, COSEALG.EdDSA); + coseMap.set(COSEKEYS.Crv, COSECRV.P256); + coseMap.set(COSEKEYS.X, publicKey); + + await expect( + verifySignature({ + cosePublicKey: coseMap, + signature: ed25519.sign(new Uint8Array(32), privateKey), + data: new Uint8Array(32), + }), + ).rejects.toThrow('Unsupported OKP curve'); + }); + + it('throws for missing kty', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Alg, COSEALG.ES256); + + await expect( + verifySignature({ + cosePublicKey: coseMap, + signature: new Uint8Array(64), + data: new Uint8Array(32), + }), + ).rejects.toThrow('COSE public key missing kty'); + }); + + it('throws for unsupported key type', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, 99); + coseMap.set(COSEKEYS.Alg, COSEALG.ES256); + + await expect( + verifySignature({ + cosePublicKey: coseMap, + signature: new Uint8Array(64), + data: new Uint8Array(32), + }), + ).rejects.toThrow('Unsupported COSE key type'); + }); + + it('verifies RSA signature via Web Crypto', async () => { + const keyPair = await webcrypto.subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: { name: 'SHA-256' }, + }, + true, + ['sign', 'verify'], + ); + + const data = new Uint8Array(32).fill(0xee); + const signature = new Uint8Array( + await webcrypto.subtle.sign( + 'RSASSA-PKCS1-v1_5', + keyPair.privateKey, + data, + ), + ); + + const jwk = await webcrypto.subtle.exportKey('jwk', keyPair.publicKey); + + const nBytes = decodeJwkBase64Url(jwk.n as string); + const eBytes = decodeJwkBase64Url(jwk.e as string); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.Alg, COSEALG.RS256); + coseMap.set(-1, nBytes); + coseMap.set(-2, eBytes); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + + it('throws for unsupported RSA algorithm', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.Alg, -999); + coseMap.set(-1, new Uint8Array(256)); + coseMap.set(-2, new Uint8Array([1, 0, 1])); + + await expect( + verifySignature({ + cosePublicKey: coseMap, + signature: new Uint8Array(256), + data: new Uint8Array(32), + }), + ).rejects.toThrow('Unsupported RSA algorithm'); + }); + + it('throws for missing RSA n or e', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.Alg, COSEALG.RS256); + + await expect( + verifySignature({ + cosePublicKey: coseMap, + signature: new Uint8Array(256), + data: new Uint8Array(32), + }), + ).rejects.toThrow('RSA public key missing n or e'); + }); + + it('throws for missing RSA alg', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.N, new Uint8Array(256).fill(1)); + coseMap.set(COSEKEYS.E, new Uint8Array([1, 0, 1])); + + await expect( + verifySignature({ + cosePublicKey: coseMap, + signature: new Uint8Array(256), + data: new Uint8Array(32), + }), + ).rejects.toThrow('RSA public key missing alg'); + }); + + it('verifies PS256 signature via Web Crypto', async () => { + const keyPair = await webcrypto.subtle.generateKey( + { + name: 'RSA-PSS', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: { name: 'SHA-256' }, + }, + true, + ['sign', 'verify'], + ); + + const data = new Uint8Array(32).fill(0x9a); + const signature = new Uint8Array( + await webcrypto.subtle.sign( + { name: 'RSA-PSS', saltLength: 32 }, + keyPair.privateKey, + data, + ), + ); + + const jwk = await webcrypto.subtle.exportKey('jwk', keyPair.publicKey); + const nBytes = decodeJwkBase64Url(jwk.n as string); + const eBytes = decodeJwkBase64Url(jwk.e as string); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.Alg, COSEALG.PS256); + coseMap.set(COSEKEYS.N, nBytes); + coseMap.set(COSEKEYS.E, eBytes); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + + it('verifies RS1 signature via Web Crypto', async () => { + const keyPair = await webcrypto.subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: { name: 'SHA-1' }, + }, + true, + ['sign', 'verify'], + ); + + const data = new Uint8Array(32).fill(0x44); + const signature = new Uint8Array( + await webcrypto.subtle.sign( + 'RSASSA-PKCS1-v1_5', + keyPair.privateKey, + data, + ), + ); + + const jwk = await webcrypto.subtle.exportKey('jwk', keyPair.publicKey); + const nBytes = decodeJwkBase64Url(jwk.n as string); + const eBytes = decodeJwkBase64Url(jwk.e as string); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.Alg, COSEALG.RS1); + coseMap.set(COSEKEYS.N, nBytes); + coseMap.set(COSEKEYS.E, eBytes); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + + it('verifies PS384 signature via Web Crypto', async () => { + const keyPair = await webcrypto.subtle.generateKey( + { + name: 'RSA-PSS', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: { name: 'SHA-384' }, + }, + true, + ['sign', 'verify'], + ); + + const data = new Uint8Array(32).fill(0x4a); + const signature = new Uint8Array( + await webcrypto.subtle.sign( + { name: 'RSA-PSS', saltLength: 48 }, + keyPair.privateKey, + data, + ), + ); + + const jwk = await webcrypto.subtle.exportKey('jwk', keyPair.publicKey); + const nBytes = decodeJwkBase64Url(jwk.n as string); + const eBytes = decodeJwkBase64Url(jwk.e as string); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.Alg, COSEALG.PS384); + coseMap.set(COSEKEYS.N, nBytes); + coseMap.set(COSEKEYS.E, eBytes); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + + it('verifies PS512 signature via Web Crypto', async () => { + const keyPair = await webcrypto.subtle.generateKey( + { + name: 'RSA-PSS', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: { name: 'SHA-512' }, + }, + true, + ['sign', 'verify'], + ); + + const data = new Uint8Array(32).fill(0x5a); + const signature = new Uint8Array( + await webcrypto.subtle.sign( + { name: 'RSA-PSS', saltLength: 64 }, + keyPair.privateKey, + data, + ), + ); + + const jwk = await webcrypto.subtle.exportKey('jwk', keyPair.publicKey); + const nBytes = decodeJwkBase64Url(jwk.n as string); + const eBytes = decodeJwkBase64Url(jwk.e as string); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.Alg, COSEALG.PS512); + coseMap.set(COSEKEYS.N, nBytes); + coseMap.set(COSEKEYS.E, eBytes); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + + it('verifies RS256 with subarray buffers', async () => { + const keyPair = await webcrypto.subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: { name: 'SHA-256' }, + }, + true, + ['sign', 'verify'], + ); + + const data = new Uint8Array(32).fill(0x7c); + const signature = new Uint8Array( + await webcrypto.subtle.sign( + 'RSASSA-PKCS1-v1_5', + keyPair.privateKey, + data, + ), + ); + + const signatureContainer = new Uint8Array(signature.length + 8); + signatureContainer.set(signature, 4); + const signatureSubarray = signatureContainer.subarray( + 4, + 4 + signature.length, + ); + + const dataContainer = new Uint8Array(data.length + 10); + dataContainer.set(data, 5); + const dataSubarray = dataContainer.subarray(5, 5 + data.length); + + const jwk = await webcrypto.subtle.exportKey('jwk', keyPair.publicKey); + const nBytes = decodeJwkBase64Url(jwk.n as string); + const eBytes = decodeJwkBase64Url(jwk.e as string); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.Alg, COSEALG.RS256); + coseMap.set(COSEKEYS.N, nBytes); + coseMap.set(COSEKEYS.E, eBytes); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature: signatureSubarray, + data: dataSubarray, + }); + + expect(result).toBe(true); + }); +}); + +describe('verifySignature RSA hash variants', () => { + async function generateRSAKeyPairAndSign( + hashName: string, + alg: number, + ): Promise<{ + coseMap: Map; + signature: Uint8Array; + data: Uint8Array; + }> { + const keyPair = await webcrypto.subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: { name: hashName }, + }, + true, + ['sign', 'verify'], + ); + + const data = new Uint8Array(32).fill(0xff); + const signature = new Uint8Array( + await webcrypto.subtle.sign( + 'RSASSA-PKCS1-v1_5', + keyPair.privateKey, + data, + ), + ); + + const jwk = await webcrypto.subtle.exportKey('jwk', keyPair.publicKey); + const nBytes = decodeJwkBase64Url(jwk.n as string); + const eBytes = decodeJwkBase64Url(jwk.e as string); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.Alg, alg); + coseMap.set(-1, nBytes); + coseMap.set(-2, eBytes); + + return { coseMap, signature, data }; + } + + it('verifies RS384 signature', async () => { + const { coseMap, signature, data } = await generateRSAKeyPairAndSign( + 'SHA-384', + COSEALG.RS384, + ); + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + expect(result).toBe(true); + }); + + it('verifies RS512 signature', async () => { + const { coseMap, signature, data } = await generateRSAKeyPairAndSign( + 'SHA-512', + COSEALG.RS512, + ); + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + expect(result).toBe(true); + }); + + it('verifies ES512/P-521 signature', async () => { + const privateKey = p521.utils.randomSecretKey(); + const publicKeyRaw = p521.getPublicKey(privateKey, false); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.EC2); + coseMap.set(COSEKEYS.Alg, COSEALG.ES512); + coseMap.set(COSEKEYS.Crv, COSECRV.P521); + coseMap.set(COSEKEYS.X, publicKeyRaw.slice(1, 67)); + coseMap.set(COSEKEYS.Y, publicKeyRaw.slice(67, 133)); + + const data = new Uint8Array(32).fill(0xab); + const hash = sha512(data); + const ecdsaSig = p521.sign(hash, privateKey); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature: new Uint8Array(ecdsaSig.toDERRawBytes()), + data, + }); + + expect(result).toBe(true); + }); +}); diff --git a/packages/passkey-controller/src/webauthn/verify-signature.ts b/packages/passkey-controller/src/webauthn/verify-signature.ts new file mode 100644 index 00000000000..b7e1b6f50b8 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/verify-signature.ts @@ -0,0 +1,222 @@ +import { concatBytes } from '@metamask/utils'; +import { ed25519 } from '@noble/curves/ed25519'; +import { p256, p384, p521 } from '@noble/curves/nist'; +import { sha256, sha384, sha512 } from '@noble/hashes/sha2'; + +import { bytesToBase64URL } from '../utils/encoding'; +import { COSEALG, COSECRV, COSEKEYS, COSEKTY } from './constants'; + +type COSEPublicKey = Map; + +/** + * Get the key type from a COSE public key map. + * + * @param cosePublicKey - COSE public key map. + * @returns The COSEKTY value. + */ +function getKeyType(cosePublicKey: COSEPublicKey): number { + const kty = cosePublicKey.get(COSEKEYS.Kty); + if (typeof kty !== 'number') { + throw new Error('COSE public key missing kty'); + } + return kty; +} + +/** + * Verify an EC2 (P-256, P-384, P-521) signature using @noble/curves. + * + * ECDSA requires the data to be hashed with the curve-appropriate + * algorithm before verification: SHA-256 for P-256 and SHA-384 for P-384. + * + * @param cosePublicKey - COSE-encoded EC2 public key. + * @param signature - DER-encoded ECDSA signature. + * @param data - Data that was signed. + * @returns Whether the signature is valid. + */ +function verifyEC2( + cosePublicKey: COSEPublicKey, + signature: Uint8Array, + data: Uint8Array, +): boolean { + const alg = cosePublicKey.get(COSEKEYS.Alg); + const crv = cosePublicKey.get(COSEKEYS.Crv) as number; + const xCoord = cosePublicKey.get(COSEKEYS.X) as Uint8Array; + const yCoord = cosePublicKey.get(COSEKEYS.Y) as Uint8Array; + + if (typeof alg !== 'number') { + throw new Error('EC2 public key missing alg'); + } + + if (!xCoord || !yCoord) { + throw new Error('EC2 public key missing x or y coordinate'); + } + + const uncompressed = concatBytes([new Uint8Array([0x04]), xCoord, yCoord]); + + switch (crv) { + case COSECRV.P256: + return p256.verify(signature, sha256(data), uncompressed); + case COSECRV.P384: + return p384.verify(signature, sha384(data), uncompressed); + case COSECRV.P521: + return p521.verify(signature, sha512(data), uncompressed); + default: + throw new Error(`Unsupported EC2 curve: ${crv}`); + } +} + +/** + * Verify an OKP (Ed25519) signature using @noble/curves. + * + * @param cosePublicKey - COSE-encoded OKP public key. + * @param signature - Raw Ed25519 signature (64 bytes). + * @param data - Data that was signed. + * @returns Whether the signature is valid. + */ +function verifyOKP( + cosePublicKey: COSEPublicKey, + signature: Uint8Array, + data: Uint8Array, +): boolean { + const alg = cosePublicKey.get(COSEKEYS.Alg); + const crv = cosePublicKey.get(COSEKEYS.Crv); + const xCoord = cosePublicKey.get(COSEKEYS.X) as Uint8Array; + + if (alg !== COSEALG.EdDSA) { + throw new Error(`Unexpected OKP algorithm: ${String(alg)}`); + } + + if (crv !== COSECRV.ED25519) { + throw new Error(`Unsupported OKP curve: ${String(crv)}`); + } + + if (!xCoord) { + throw new Error('OKP public key missing x coordinate'); + } + + return ed25519.verify(signature, data, xCoord); +} + +/** + * Verify an RSA signature using Web Crypto API. + * + * @param cosePublicKey - COSE-encoded RSA public key. + * @param signature - RSA PKCS#1 v1.5 signature. + * @param data - Data that was signed. + * @returns Whether the signature is valid. + */ +async function verifyRSA( + cosePublicKey: COSEPublicKey, + signature: Uint8Array, + data: Uint8Array, +): Promise { + const alg = cosePublicKey.get(COSEKEYS.Alg); + const modulus = cosePublicKey.get(COSEKEYS.N) as Uint8Array; + const exponent = cosePublicKey.get(COSEKEYS.E) as Uint8Array; + + if (typeof alg !== 'number') { + throw new Error('RSA public key missing alg'); + } + + if (!modulus || !exponent) { + throw new Error('RSA public key missing n or e'); + } + + let keyAlgorithmName: 'RSASSA-PKCS1-v1_5' | 'RSA-PSS'; + let hashAlg: string; + let saltLength: number | undefined; + switch (alg) { + case COSEALG.RS1: + keyAlgorithmName = 'RSASSA-PKCS1-v1_5'; + hashAlg = 'SHA-1'; + break; + case COSEALG.RS256: + keyAlgorithmName = 'RSASSA-PKCS1-v1_5'; + hashAlg = 'SHA-256'; + break; + case COSEALG.RS384: + keyAlgorithmName = 'RSASSA-PKCS1-v1_5'; + hashAlg = 'SHA-384'; + break; + case COSEALG.RS512: + keyAlgorithmName = 'RSASSA-PKCS1-v1_5'; + hashAlg = 'SHA-512'; + break; + case COSEALG.PS256: + keyAlgorithmName = 'RSA-PSS'; + hashAlg = 'SHA-256'; + saltLength = 32; + break; + case COSEALG.PS384: + keyAlgorithmName = 'RSA-PSS'; + hashAlg = 'SHA-384'; + saltLength = 48; + break; + case COSEALG.PS512: + keyAlgorithmName = 'RSA-PSS'; + hashAlg = 'SHA-512'; + saltLength = 64; + break; + default: + throw new Error(`Unsupported RSA algorithm: ${alg}`); + } + + const key = await globalThis.crypto.subtle.importKey( + 'jwk', + { + kty: 'RSA', + n: bytesToBase64URL(modulus), + e: bytesToBase64URL(exponent), + }, + { name: keyAlgorithmName, hash: { name: hashAlg } }, + false, + ['verify'], + ); + + const verifyAlgorithm = + keyAlgorithmName === 'RSA-PSS' + ? { name: 'RSA-PSS', saltLength: saltLength as number } + : 'RSASSA-PKCS1-v1_5'; + + const signatureBytes = Uint8Array.from(signature); + const dataBytes = Uint8Array.from(data); + return globalThis.crypto.subtle.verify( + verifyAlgorithm, + key, + signatureBytes, + dataBytes, + ); +} + +/** + * Verify a WebAuthn signature using the appropriate algorithm based on + * the COSE key type. + * + * Uses @noble/curves for EC2 and OKP (synchronous, audited, handles DER + * natively). Falls back to Web Crypto API for RSA. + * + * @param opts - Options object. + * @param opts.cosePublicKey - COSE-encoded public key as a Map. + * @param opts.signature - The signature bytes. + * @param opts.data - The data that was signed. + * @returns Whether the signature is valid. + */ +export async function verifySignature(opts: { + cosePublicKey: COSEPublicKey; + signature: Uint8Array; + data: Uint8Array; +}): Promise { + const { cosePublicKey, signature, data } = opts; + const kty = getKeyType(cosePublicKey); + + switch (kty) { + case COSEKTY.EC2: + return verifyEC2(cosePublicKey, signature, data); + case COSEKTY.OKP: + return verifyOKP(cosePublicKey, signature, data); + case COSEKTY.RSA: + return verifyRSA(cosePublicKey, signature, data); + default: + throw new Error(`Unsupported COSE key type: ${kty}`); + } +} diff --git a/packages/passkey-controller/tsconfig.build.json b/packages/passkey-controller/tsconfig.build.json new file mode 100644 index 00000000000..249f327913d --- /dev/null +++ b/packages/passkey-controller/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../base-controller/tsconfig.build.json" + }, + { + "path": "../messenger/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/passkey-controller/tsconfig.json b/packages/passkey-controller/tsconfig.json new file mode 100644 index 00000000000..cb296895b28 --- /dev/null +++ b/packages/passkey-controller/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { + "path": "../base-controller" + }, + { + "path": "../messenger" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/passkey-controller/typedoc.json b/packages/passkey-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/passkey-controller/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/teams.json b/teams.json index f779c0b48a2..40ad60b9147 100644 --- a/teams.json +++ b/teams.json @@ -56,6 +56,7 @@ "metamask/rate-limit-controller": "team-core-platform", "metamask/react-data-query": "team-core-platform", "metamask/profile-metrics-controller": "team-core-platform", + "metamask/passkey-controller": "team-onboarding", "metamask/seedless-onboarding-controller": "team-onboarding", "metamask/shield-controller": "team-shield", "metamask/subscription-controller": "team-shield", diff --git a/tsconfig.build.json b/tsconfig.build.json index 69570a2bffa..ca4262f4f16 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -181,6 +181,9 @@ { "path": "./packages/notification-services-controller/tsconfig.build.json" }, + { + "path": "./packages/passkey-controller/tsconfig.build.json" + }, { "path": "./packages/permission-controller/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index 72a51c2e29e..52c1abf20cc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -173,6 +173,9 @@ { "path": "./packages/notification-services-controller" }, + { + "path": "./packages/passkey-controller" + }, { "path": "./packages/permission-controller" }, diff --git a/yarn.lock b/yarn.lock index 01a5e989046..2cfafc46708 100644 --- a/yarn.lock +++ b/yarn.lock @@ -387,7 +387,7 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.23.0, @babel/types@npm:^7.28.6, @babel/types@npm:^7.29.0, @babel/types@npm:^7.3.3": +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.23.0, @babel/types@npm:^7.28.2, @babel/types@npm:^7.28.6, @babel/types@npm:^7.29.0, @babel/types@npm:^7.3.3": version: 7.29.0 resolution: "@babel/types@npm:7.29.0" dependencies: @@ -2485,6 +2485,13 @@ __metadata: languageName: node linkType: hard +"@levischuck/tiny-cbor@npm:^0.3.3": + version: 0.3.3 + resolution: "@levischuck/tiny-cbor@npm:0.3.3" + checksum: 10/737219a7bb28570043f77aa9d73af800a1dd41a1561c0f816d0ff5b7e95e08351377cd97afa19c4d8581e44951211a4b5f46084944ffdbca2779cddaa1c1fcbc + languageName: node + linkType: hard + "@metamask/7715-permission-types@npm:^0.5.0": version: 0.5.0 resolution: "@metamask/7715-permission-types@npm:0.5.0" @@ -4839,6 +4846,30 @@ __metadata: languageName: node linkType: hard +"@metamask/passkey-controller@workspace:packages/passkey-controller": + version: 0.0.0-use.local + resolution: "@metamask/passkey-controller@workspace:packages/passkey-controller" + dependencies: + "@levischuck/tiny-cbor": "npm:^0.3.3" + "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/messenger": "npm:^1.1.1" + "@metamask/utils": "npm:^11.9.0" + "@noble/ciphers": "npm:^1.3.0" + "@noble/curves": "npm:^1.9.2" + "@noble/hashes": "npm:^1.8.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^29.5.14" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/permission-controller@npm:^12.3.0, @metamask/permission-controller@workspace:packages/permission-controller": version: 0.0.0-use.local resolution: "@metamask/permission-controller@workspace:packages/permission-controller" @@ -6783,11 +6814,11 @@ __metadata: linkType: hard "@types/babel__traverse@npm:*, @types/babel__traverse@npm:^7.0.6": - version: 7.20.6 - resolution: "@types/babel__traverse@npm:7.20.6" + version: 7.28.0 + resolution: "@types/babel__traverse@npm:7.28.0" dependencies: - "@babel/types": "npm:^7.20.7" - checksum: 10/63d13a3789aa1e783b87a8b03d9fb2c2c90078de7782422feff1631b8c2a25db626e63a63ac5a1465d47359201c73069dacb4b52149d17c568187625da3064ae + "@babel/types": "npm:^7.28.2" + checksum: 10/371c5e1b40399ef17570e630b2943617b84fafde2860a56f0ebc113d8edb1d0534ade0175af89eda1ae35160903c33057ed42457e165d4aa287fedab2c82abcf languageName: node linkType: hard @@ -7424,11 +7455,11 @@ __metadata: linkType: hard "acorn@npm:^8.1.0, acorn@npm:^8.11.0, acorn@npm:^8.15.0, acorn@npm:^8.8.1": - version: 8.15.0 - resolution: "acorn@npm:8.15.0" + version: 8.16.0 + resolution: "acorn@npm:8.16.0" bin: acorn: bin/acorn - checksum: 10/77f2de5051a631cf1729c090e5759148459cdb76b5f5c70f890503d629cf5052357b0ce783c0f976dd8a93c5150f59f6d18df1def3f502396a20f81282482fa4 + checksum: 10/690c673bb4d61b38ef82795fab58526471ad7f7e67c0e40c4ff1e10ecd80ce5312554ef633c9995bfc4e6d170cef165711f9ca9e49040b62c0c66fbf2dd3df2b languageName: node linkType: hard @@ -7635,6 +7666,20 @@ __metadata: languageName: node linkType: hard +"async-function@npm:^1.0.0": + version: 1.0.0 + resolution: "async-function@npm:1.0.0" + checksum: 10/1a09379937d846f0ce7614e75071c12826945d4e417db634156bf0e4673c495989302f52186dfa9767a1d9181794554717badd193ca2bbab046ef1da741d8efd + languageName: node + linkType: hard + +"async-generator-function@npm:^1.0.0": + version: 1.0.0 + resolution: "async-generator-function@npm:1.0.0" + checksum: 10/3d49e7acbeee9e84537f4cb0e0f91893df8eba976759875ae8ee9e3d3c82f6ecdebdb347c2fad9926b92596d93cdfc78ecc988bcdf407e40433e8e8e6fe5d78e + languageName: node + linkType: hard + "async-mutex@npm:^0.3.1": version: 0.3.2 resolution: "async-mutex@npm:0.3.2" @@ -7927,12 +7972,12 @@ __metadata: languageName: node linkType: hard -"brace-expansion@npm:^2.0.1": - version: 2.0.1 - resolution: "brace-expansion@npm:2.0.1" +"brace-expansion@npm:^2.0.1, brace-expansion@npm:^2.0.2": + version: 2.1.0 + resolution: "brace-expansion@npm:2.1.0" dependencies: balanced-match: "npm:^1.0.0" - checksum: 10/a61e7cd2e8a8505e9f0036b3b6108ba5e926b4b55089eeb5550cd04a471fe216c96d4fe7e4c7f995c728c554ae20ddfc4244cad10aef255e72b62930afd233d1 + checksum: 10/c77a7a64aabf94b8d5913955adb4f36957917565374461355bb4276830c027a313d981f32410cea9e38f52573e7eb776d02fe05091c3a79a061958d97e4d2b43 languageName: node linkType: hard @@ -10209,6 +10254,13 @@ __metadata: languageName: node linkType: hard +"generator-function@npm:^2.0.0": + version: 2.0.1 + resolution: "generator-function@npm:2.0.1" + checksum: 10/eb7e7eb896c5433f3d40982b2ccacdb3dd990dd3499f14040e002b5d54572476513be8a2e6f9609f6e41ab29f2c4469307611ddbfc37ff4e46b765c326663805 + languageName: node + linkType: hard + "gensync@npm:^1.0.0-beta.2": version: 1.0.0-beta.2 resolution: "gensync@npm:1.0.0-beta.2" @@ -10224,20 +10276,23 @@ __metadata: linkType: hard "get-intrinsic@npm:^1.2.4": - version: 1.3.0 - resolution: "get-intrinsic@npm:1.3.0" + version: 1.3.1 + resolution: "get-intrinsic@npm:1.3.1" dependencies: + async-function: "npm:^1.0.0" + async-generator-function: "npm:^1.0.0" call-bind-apply-helpers: "npm:^1.0.2" es-define-property: "npm:^1.0.1" es-errors: "npm:^1.3.0" es-object-atoms: "npm:^1.1.1" function-bind: "npm:^1.1.2" + generator-function: "npm:^2.0.0" get-proto: "npm:^1.0.1" gopd: "npm:^1.2.0" has-symbols: "npm:^1.1.0" hasown: "npm:^2.0.2" math-intrinsics: "npm:^1.1.0" - checksum: 10/6e9dd920ff054147b6f44cb98104330e87caafae051b6d37b13384a45ba15e71af33c3baeac7cb630a0aaa23142718dcf25b45cfdd86c184c5dcb4e56d953a10 + checksum: 10/bb579dda84caa4a3a41611bdd483dade7f00f246f2a7992eb143c5861155290df3fdb48a8406efa3dfb0b434e2c8fafa4eebd469e409d0439247f85fc3fa2cc1 languageName: node linkType: hard @@ -11942,9 +11997,9 @@ __metadata: linkType: hard "lodash@npm:^4.17.21": - version: 4.17.21 - resolution: "lodash@npm:4.17.21" - checksum: 10/c08619c038846ea6ac754abd6dd29d2568aa705feb69339e836dfa8d8b09abbb2f859371e86863eda41848221f9af43714491467b5b0299122431e202bb0c532 + version: 4.18.1 + resolution: "lodash@npm:4.18.1" + checksum: 10/306fea53dfd39dad1f03d45ba654a2405aebd35797b673077f401edb7df2543623dc44b9effbb98f69b32152295fff725a4cec99c684098947430600c6af0c3f languageName: node linkType: hard @@ -12286,11 +12341,11 @@ __metadata: linkType: hard "minimatch@npm:^9.0.3, minimatch@npm:^9.0.4, minimatch@npm:^9.0.5": - version: 9.0.5 - resolution: "minimatch@npm:9.0.5" + version: 9.0.9 + resolution: "minimatch@npm:9.0.9" dependencies: - brace-expansion: "npm:^2.0.1" - checksum: 10/dd6a8927b063aca6d910b119e1f2df6d2ce7d36eab91de83167dd136bb85e1ebff97b0d3de1cb08bd1f7e018ca170b4962479fefab5b2a69e2ae12cb2edc8348 + brace-expansion: "npm:^2.0.2" + checksum: 10/b91fad937deaffb68a45a2cb731ff3cff1c3baf9b6469c879477ed16f15c8f4ce39d63a3f75c2455107c2fdff0f3ab597d97dc09e2e93b883aafcf926ef0c8f9 languageName: node linkType: hard @@ -13828,11 +13883,11 @@ __metadata: linkType: hard "semver@npm:^7.1.1, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.3, semver@npm:^7.7.3": - version: 7.7.3 - resolution: "semver@npm:7.7.3" + version: 7.7.4 + resolution: "semver@npm:7.7.4" bin: semver: bin/semver.js - checksum: 10/8dbc3168e057a38fc322af909c7f5617483c50caddba135439ff09a754b20bdd6482a5123ff543dad4affa488ecf46ec5fb56d61312ad20bb140199b88dfaea9 + checksum: 10/26bdc6d58b29528f4142d29afb8526bc335f4fc04c4a10f2b98b217f277a031c66736bf82d3d3bb354a2f6a3ae50f18fd62b053c4ac3f294a3d10a61f5075b75 languageName: node linkType: hard