diff --git a/packages/passkey-controller/CHANGELOG.md b/packages/passkey-controller/CHANGELOG.md new file mode 100644 index 00000000000..0af934fdb8b --- /dev/null +++ b/packages/passkey-controller/CHANGELOG.md @@ -0,0 +1,34 @@ +# 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 + +- `PasskeyController.clearState` — resets passkey enrollment and clears in-flight WebAuthn sessions (aligned with other MetaMask controllers' `clearState` naming for lifecycle resets such as wallet reset) +- `PasskeyController` — manages passkey-based vault key protection using WebAuthn, orchestrating the full passkey lifecycle: + - `generateRegistrationOptions` — produces WebAuthn credential creation options for passkey enrollment + - `protectVaultKeyWithPasskey` — verifies a registration response and encrypts the vault key with the new credential + - `generateAuthenticationOptions` — produces WebAuthn credential request options for passkey authentication + - `retrieveVaultKeyWithPasskey` — verifies an authentication response and recovers the vault encryption key + - `renewVaultKeyProtection` — re-encrypts the vault key for password-change flows without re-enrolling the passkey + - `removePasskey` — unenrolls the passkey and clears all stored key material + - `isPasskeyEnrolled` — returns whether a passkey is currently enrolled +- Adaptive key derivation with two strategies selected automatically during enrollment: + - **PRF** — uses the WebAuthn PRF extension output as HKDF input key material + - **userHandle** — falls back to a random userHandle when PRF is unavailable +- Self-contained WebAuthn verification (no Node.js server dependencies): + - `clientDataJSON` verification: `type`, `challenge`, `origin` + - `authenticatorData` verification: `rpIdHash` (SHA-256 comparison), flags (`up`, `uv`), counter monotonicity + - Signature verification against stored credential public key using `@noble/curves` (EC2/EdDSA) and Web Crypto API (RSA fallback) + - Attestation format support: `none` and `packed` self-attestation +- AES-256-GCM encryption utilities for vault key wrapping with HKDF-SHA256 key derivation +- Exported types: `PasskeyControllerState`, `PasskeyControllerMessenger`, `PasskeyControllerGetStateAction`, `PasskeyControllerIsPasskeyEnrolledAction`, `PasskeyControllerActions`, `PasskeyControllerStateChangeEvent`, `PasskeyControllerEvents` +- Self-contained WebAuthn types: `PasskeyRegistrationOptions`, `PasskeyRegistrationResponse`, `PasskeyAuthenticationOptions`, `PasskeyAuthenticationResponse` +- COSE constant enums: `COSEALG`, `COSEKEYS`, `COSEKTY`, `COSECRV` + +[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..c617ab538e9 --- /dev/null +++ b/packages/passkey-controller/README.md @@ -0,0 +1,124 @@ +# `@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 creates an in-memory 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', +}); +``` + +### 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 + +controller.clearState(); // same persisted reset + session drop; use for app lifecycle (e.g. wallet reset) +``` + +## 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 | +| `PasskeyController:isPasskeyEnrolled` | Returns whether a passkey is currently enrolled | + +### Messenger events + +| Event | Payload | +|---|---| +| `PasskeyController:stateChange` | 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..e317e421c58 --- /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: 90, functions: 100, lines: 98, statements: 98 }, + }, +}); diff --git a/packages/passkey-controller/jest.environment.js b/packages/passkey-controller/jest.environment.js new file mode 100644 index 00000000000..08bc740e326 --- /dev/null +++ b/packages/passkey-controller/jest.environment.js @@ -0,0 +1,17 @@ +const NodeEnvironment = require('jest-environment-node'); + +/** + * Passkey orchestration uses the Web Crypto API (`crypto.getRandomValues`) in Node tests. + */ +class CustomTestEnvironment extends NodeEnvironment { + 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..7d131e53feb --- /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": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/passkey-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "MIT", + "sideEffects": false, + "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" + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "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.2.2", + "@metamask/base-controller": "^9.0.0", + "@metamask/messenger": "^0.3.0", + "@metamask/utils": "^11.9.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.8.0", + "@noble/hashes": "^1.8.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^3.4.4", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^27.5.2", + "deepmerge": "^4.2.2", + "jest": "^27.5.1", + "jest-environment-node": "^27.5.1", + "ts-jest": "^27.1.5", + "typedoc": "^0.24.8", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/passkey-controller/src/PasskeyController.test.ts b/packages/passkey-controller/src/PasskeyController.test.ts new file mode 100644 index 00000000000..28657a5db56 --- /dev/null +++ b/packages/passkey-controller/src/PasskeyController.test.ts @@ -0,0 +1,793 @@ +import { Messenger } from '@metamask/messenger'; + +import { + getDefaultPasskeyControllerState, + PasskeyController, +} from './PasskeyController'; +import type { PasskeyControllerMessenger } from './PasskeyController'; +import type { PasskeyRecord, PrfClientExtensionResults } from './types'; +import type { + PasskeyRegistrationResponse, + PasskeyAuthenticationResponse, +} from './webauthn'; + +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', () => ({ + ...jest.requireActual('./webauthn'), + verifyRegistrationResponse: (...args: unknown[]): unknown => + mockVerifyRegistrationResponse(...args), + 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, +): 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: TEST_CHALLENGE, + origin: TEST_ORIGIN, + }), + ), + ), + attestationObject: bytesToBase64URL(new Uint8Array([0, 1, 2])), + }, + clientExtensionResults: {}, + authenticatorAttachment: 'platform', + ...overrides, + } as PasskeyRegistrationResponse; +} + +function minimalAuthenticationResponse( + userHandle?: string, + overrides?: Partial, +): 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: TEST_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 = { + credentialId: TEST_CREDENTIAL_ID, + derivationMethod: 'userHandle', + encryptedVaultKey: 'YQ==', + iv: 'YWFhYWFhYWFhYQ==', + publicKey: TEST_PUBLIC_KEY, + counter: 0, + transports: ['internal'], + }; + 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); + }); + + it('is callable via messenger method action', () => { + const messenger = getPasskeyMessenger(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const controller = new PasskeyController({ + messenger, + rpID: TEST_RP_ID, + rpName: TEST_RP_NAME, + expectedOrigin: TEST_ORIGIN, + }); + expect(messenger.call('PasskeyController: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('direct'); + 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(), + vaultKey, + }); + + expect(controller.state.passkeyRecord?.derivationMethod).toBe( + 'userHandle', + ); + expect(controller.state.passkeyRecord?.prfSalt).toBeUndefined(); + + const authOptions = controller.generateAuthenticationOptions(); + expect(authOptions.extensions).toStrictEqual({}); + + const retrieved = await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse(userHandle), + ); + expect(retrieved).toBe(vaultKey); + }); + }); + + describe('generateAuthenticationOptions', () => { + it('throws when passkey is not enrolled', () => { + const controller = createController(); + expect(() => controller.generateAuthenticationOptions()).toThrow( + 'Passkey is not enrolled', + ); + }); + + it('returns options with PRF for prf-enrolled credentials', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(9)); + const controller = createController(); + + controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse({ + clientExtensionResults: prfResults(prfFirst, true), + }), + 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 session', async () => { + const controller = createController(); + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse(), + vaultKey: 'k', + }), + ).rejects.toThrow('No active passkey registration session'); + }); + + it('throws when verification fails', async () => { + mockVerifyRegistrationResponse.mockResolvedValue({ + verified: false, + }); + + const controller = createController(); + controller.generateRegistrationOptions(); + + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse(), + vaultKey: 'k', + }), + ).rejects.toThrow('Passkey registration verification failed'); + }); + + it('stores passkey record with publicKey after successful verification', async () => { + setupRegistrationMocks(); + const controller = createController(); + controller.generateRegistrationOptions(); + + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse(), + vaultKey: 'test-vault-key', + }); + + expect(controller.isPasskeyEnrolled()).toBe(true); + const record = controller.state.passkeyRecord; + expect(record?.credentialId).toBe(TEST_CREDENTIAL_ID); + expect(record?.publicKey).toBe(TEST_PUBLIC_KEY); + expect(record?.transports).toStrictEqual(['internal']); + expect(record?.derivationMethod).toBe('userHandle'); + }); + + it('uses prf derivation when extension results include PRF output', async () => { + setupRegistrationMocks(); + const controller = createController(); + controller.generateRegistrationOptions(); + + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(9)); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse({ + clientExtensionResults: prfResults(prfFirst, true), + }), + vaultKey: 'vault-key-prf-path', + }); + + expect(controller.state.passkeyRecord?.derivationMethod).toBe('prf'); + expect(controller.state.passkeyRecord?.prfSalt).toBeDefined(); + }); + }); + + describe('retrieveVaultKeyWithPasskey', () => { + it('throws when passkey is not enrolled', async () => { + setupAuthenticationMocks(); + const controller = createController(); + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse('uh'), + ), + ).rejects.toThrow('Passkey is not enrolled'); + }); + + it('throws when there is no authentication session', async () => { + setupRegistrationMocks(); + const controller = createController(); + controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse(), + vaultKey: 'k', + }); + + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse('uh'), + ), + ).rejects.toThrow('No active passkey authentication session'); + }); + + it('throws when verification fails', async () => { + setupRegistrationMocks(); + const controller = createController(); + controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse(), + vaultKey: 'k', + }); + + mockVerifyAuthenticationResponse.mockResolvedValue({ + verified: false, + authenticationInfo: {}, + }); + + controller.generateAuthenticationOptions(); + + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse('uh'), + ), + ).rejects.toThrow('Passkey authentication verification failed'); + }); + + it('clears the authentication session after successful retrieval (prf)', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(99)); + + controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse({ + clientExtensionResults: prfResults(prfFirst), + }), + vaultKey: 'secret', + }); + + controller.generateAuthenticationOptions(); + await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse(undefined, { + clientExtensionResults: prfResults(prfFirst), + }), + ); + + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse(undefined, { + clientExtensionResults: prfResults(prfFirst), + }), + ), + ).rejects.toThrow('No active passkey authentication session'); + }); + }); + + 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'; + + controller.generateRegistrationOptions(); + + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse(), + vaultKey, + }); + + expect(controller.state.passkeyRecord?.derivationMethod).toBe( + 'userHandle', + ); + + controller.generateAuthenticationOptions(); + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse('bWlzbWF0Y2hlZFVzZXJIYW5kbGU'), + ), + ).rejects.toThrow('aes/gcm'); + + controller.generateAuthenticationOptions(); + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse(undefined), + ), + ).rejects.toThrow('Passkey assertion missing required key material'); + }); + }); + + 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'; + + controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse({ + clientExtensionResults: prfResults(prfFirst), + }), + vaultKey, + }); + + controller.generateAuthenticationOptions(); + const out = await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse(undefined, { + clientExtensionResults: prfResults(prfFirst), + }), + ); + + 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('Passkey is not enrolled'); + }); + + 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'; + + controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse({ + clientExtensionResults: prfResults(prfFirst), + }), + vaultKey: beforeKey, + }); + + controller.generateAuthenticationOptions(); + const afterKey = 'vault-key-after-password'; + await controller.renewVaultKeyProtection({ + authenticationResponse: minimalAuthenticationResponse(undefined, { + clientExtensionResults: prfResults(prfFirst), + }), + oldVaultKey: beforeKey, + newVaultKey: afterKey, + }); + + controller.generateAuthenticationOptions(); + const unwrapped = await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse(undefined, { + clientExtensionResults: prfResults(prfFirst), + }), + ); + 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)); + + controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse({ + clientExtensionResults: prfResults(prfFirst), + }), + vaultKey: 'actual-wrapped-key', + }); + + controller.generateAuthenticationOptions(); + + await expect( + controller.renewVaultKeyProtection({ + authenticationResponse: minimalAuthenticationResponse(undefined, { + clientExtensionResults: prfResults(prfFirst), + }), + oldVaultKey: 'wrong-expected-key', + newVaultKey: 'new-key', + }), + ).rejects.toThrow( + 'Passkey authentication does not match the current vault key', + ); + }); + }); + + describe('removePasskey', () => { + it('clears stored record and resets enrollment', async () => { + setupRegistrationMocks(); + const controller = createController(); + controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse(), + 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(); + controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse(), + vaultKey: 'k', + }); + expect(controller.isPasskeyEnrolled()).toBe(true); + + controller.clearState(); + expect(controller.isPasskeyEnrolled()).toBe(false); + expect(controller.state.passkeyRecord).toBeNull(); + }); + }); + + describe('verifyRegistrationResponse parameters', () => { + it('passes expectedOrigin and expectedRPID to verification', async () => { + setupRegistrationMocks(); + const controller = createController({ + rpID: 'custom-rp.com', + expectedOrigin: 'chrome-extension://abc123', + }); + + controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse(), + 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)); + + controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse({ + clientExtensionResults: prfResults(prfFirst), + }), + vaultKey: 'k', + }); + + controller.generateAuthenticationOptions(); + + try { + await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse(undefined, { + clientExtensionResults: prfResults(prfFirst), + }), + ); + } 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)); + + controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse({ + clientExtensionResults: prfResults(prfFirst), + }), + vaultKey: 'k', + }); + + expect(controller.state.passkeyRecord?.counter).toBe(0); + + mockVerifyAuthenticationResponse.mockResolvedValue({ + verified: true, + authenticationInfo: { + credentialId: TEST_CREDENTIAL_ID, + newCounter: 5, + userVerified: true, + origin: TEST_ORIGIN, + rpID: TEST_RP_ID, + }, + }); + + controller.generateAuthenticationOptions(); + await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse(undefined, { + clientExtensionResults: prfResults(prfFirst), + }), + ); + + expect(controller.state.passkeyRecord?.counter).toBe(5); + + mockVerifyAuthenticationResponse.mockResolvedValue({ + verified: true, + authenticationInfo: { + credentialId: TEST_CREDENTIAL_ID, + newCounter: 10, + userVerified: true, + origin: TEST_ORIGIN, + rpID: TEST_RP_ID, + }, + }); + + controller.generateAuthenticationOptions(); + await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse(undefined, { + clientExtensionResults: prfResults(prfFirst), + }), + ); + + expect(mockVerifyAuthenticationResponse).toHaveBeenLastCalledWith( + expect.objectContaining({ + credential: expect.objectContaining({ + counter: 5, + }), + }), + ); + expect(controller.state.passkeyRecord?.counter).toBe(10); + }); + }); +}); diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts new file mode 100644 index 00000000000..3be22051c59 --- /dev/null +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -0,0 +1,479 @@ +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; +import { randomBytes } from '@noble/ciphers/webcrypto'; + +import { + deriveKeyFromAuthenticationResponse, + deriveKeyFromRegistrationResponse, +} from './key-derivation'; +import type { + PasskeyAuthenticationSession, + PasskeyRecord, + PasskeyRegistrationSession, +} from './types'; +import { decryptWithKey, encryptWithKey } from './utils/crypto'; +import { base64URLToBytes, bytesToBase64URL } from './utils/encoding'; +import { + COSEALG, + verifyAuthenticationResponse, + verifyRegistrationResponse, +} from './webauthn'; +import type { + PasskeyAuthenticationOptions, + PasskeyAuthenticationResponse, + PasskeyRegistrationOptions, + PasskeyRegistrationResponse, +} from './webauthn'; + +const controllerName = 'PasskeyController'; + +const MESSENGER_EXPOSED_METHODS = ['isPasskeyEnrolled'] as const; + +export type PasskeyControllerState = { + passkeyRecord: PasskeyRecord | null; +}; + +export type PasskeyControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + PasskeyControllerState +>; + +export type PasskeyControllerIsPasskeyEnrolledAction = { + type: `${typeof controllerName}:isPasskeyEnrolled`; + handler: PasskeyController['isPasskeyEnrolled']; +}; + +export type PasskeyControllerActions = + | PasskeyControllerGetStateAction + | PasskeyControllerIsPasskeyEnrolledAction; + +export type PasskeyControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + PasskeyControllerState +>; + +export type PasskeyControllerEvents = PasskeyControllerStateChangeEvent; + +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; + +/** + * Manages passkey-based vault key protection using WebAuthn. + * + * 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. + * + * Supports two key derivation strategies: + * - **PRF** -- uses the WebAuthn PRF extension output as HKDF input. + * - **userHandle** -- falls back to the random userHandle when PRF is + * unavailable. + */ +export class PasskeyController extends BaseController< + typeof controllerName, + PasskeyControllerState, + PasskeyControllerMessenger +> { + #registrationSession: PasskeyRegistrationSession | null = null; + + #authenticationSession: PasskeyAuthenticationSession | null = null; + + readonly #rpID: string; + + readonly #rpName: string; + + readonly #expectedOrigin: string | string[]; + + constructor({ + messenger, + state, + rpID, + rpName, + expectedOrigin, + }: { + messenger: PasskeyControllerMessenger; + state?: Partial; + rpID: string; + rpName: string; + expectedOrigin: string | string[]; + }) { + super({ + messenger, + metadata: passkeyControllerMetadata, + name: controllerName, + state: { ...getDefaultPasskeyControllerState(), ...state }, + }); + + this.#rpID = rpID; + this.#rpName = rpName; + this.#expectedOrigin = expectedOrigin; + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + #setPasskeyRecord(record: PasskeyRecord): void { + this.update((state) => { + state.passkeyRecord = record; + }); + } + + #getPasskeyRecord(): PasskeyRecord | null { + return this.state.passkeyRecord; + } + + /** + * Checks if the passkey is enrolled. + * + * @returns Whether the passkey is enrolled. + */ + isPasskeyEnrolled(): boolean { + return this.state.passkeyRecord !== null; + } + + /** + * Produces WebAuthn credential creation options for passkey enrollment. + * + * Must be called before {@link protectVaultKeyWithPasskey}. + * + * @param creationOptionsConfig - Optional configuration. + * @param creationOptionsConfig.prfAvailable - Whether the client + * supports the WebAuthn PRF extension. When `false`, the PRF + * extension is omitted. Defaults to `true`. + * @returns Options JSON 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: 'MetaMask Wallet', + displayName: 'MetaMask Wallet', + }, + challenge, + pubKeyCredParams: [ + { alg: COSEALG.EdDSA, type: 'public-key' }, + { alg: COSEALG.ES256, type: 'public-key' }, + { alg: COSEALG.RS256, type: 'public-key' }, + ], + timeout: 60000, + authenticatorSelection: { + residentKey: 'preferred', + userVerification: 'preferred', + authenticatorAttachment: 'platform', + }, + attestation: 'direct', + ...(Object.keys(extensions).length > 0 ? { extensions } : {}), + }; + + this.#registrationSession = { + userHandle, + prfSalt: prfSalt ?? '', + challenge, + }; + + return options; + } + + /** + * Produces WebAuthn credential request options for passkey + * authentication. + * + * Must be called before {@link retrieveVaultKeyWithPasskey} or + * {@link renewVaultKeyProtection}. + * + * @returns Options JSON for `navigator.credentials.get()`. + * @throws If no passkey is currently enrolled. + */ + generateAuthenticationOptions(): PasskeyAuthenticationOptions { + const record = this.#getPasskeyRecord(); + if (!record) { + throw new Error('Passkey is not enrolled'); + } + + const challenge = bytesToBase64URL(randomBytes(32).slice()); + + const extensions: Record = {}; + if (record.derivationMethod === 'prf' && record.prfSalt) { + extensions.prf = { eval: { first: record.prfSalt } }; + } + + const options: PasskeyAuthenticationOptions = { + challenge, + rpId: this.#rpID, + allowCredentials: [ + { + id: record.credentialId, + type: 'public-key', + transports: record.transports, + }, + ], + userVerification: 'preferred', + timeout: 60000, + extensions, + }; + + this.#authenticationSession = { challenge }; + + return options; + } + + /** + * Completes passkey enrollment by verifying the registration response + * and protecting the vault key with the new credential. + * + * @param params - Protection parameters. + * @param params.registrationResponse - The credential result from + * `navigator.credentials.create()`. + * @param params.vaultKey - The vault encryption key to protect. + * @throws If no registration session is active (call + * {@link generateRegistrationOptions} first). + * @throws If registration verification fails. + */ + async protectVaultKeyWithPasskey(params: { + registrationResponse: PasskeyRegistrationResponse; + vaultKey: string; + }): Promise { + const session = this.#registrationSession; + if (!session) { + throw new Error('No active passkey registration session'); + } + + const { registrationResponse, vaultKey } = params; + + const verification = await verifyRegistrationResponse({ + response: registrationResponse, + expectedChallenge: session.challenge, + expectedOrigin: this.#expectedOrigin, + expectedRPID: this.#rpID, + requireUserVerification: false, + }); + + if (!verification.verified || !verification.registrationInfo) { + throw new Error('Passkey registration verification failed'); + } + + const { registrationInfo } = verification; + + const { encKey, derivationMethod } = deriveKeyFromRegistrationResponse( + registrationResponse, + session, + ); + + const { ciphertext, iv } = encryptWithKey(vaultKey, encKey); + + this.#setPasskeyRecord({ + credentialId: registrationInfo.credentialId, + derivationMethod, + encryptedVaultKey: ciphertext, + iv, + prfSalt: derivationMethod === 'prf' ? session.prfSalt : undefined, + publicKey: bytesToBase64URL(registrationInfo.publicKey), + counter: registrationInfo.counter, + transports: registrationInfo.transports, + }); + + this.#registrationSession = null; + } + + /** + * Retrieves the vault key protected by the enrolled passkey. + * + * @param authenticationResponse - The credential result from + * `navigator.credentials.get()`. + * @returns The recovered vault encryption key. + * @throws If no passkey is enrolled. + * @throws If no authentication session is active (call + * {@link generateAuthenticationOptions} first). + * @throws If authentication verification or key recovery fails. + */ + async retrieveVaultKeyWithPasskey( + authenticationResponse: PasskeyAuthenticationResponse, + ): Promise { + const record = this.#getPasskeyRecord(); + if (!record) { + throw new Error('Passkey is not enrolled'); + } + + await this.#verifyAuthentication(authenticationResponse, record); + + const encKey = deriveKeyFromAuthenticationResponse( + authenticationResponse, + record, + ); + + const vaultKey = decryptWithKey( + record.encryptedVaultKey, + record.iv, + encKey, + ); + + this.#authenticationSession = null; + + return vaultKey; + } + + /** + * Replaces the protected vault key without re-enrolling the passkey. + * + * Intended for password-change flows where the vault key rotates but + * the same passkey credential should continue to work. + * + * @param params - Renewal parameters. + * @param params.authenticationResponse - The credential result from + * `navigator.credentials.get()`. + * @param params.oldVaultKey - The vault key before the password change + * (verified for consistency). + * @param params.newVaultKey - The new vault key to protect. + * @throws If no passkey is enrolled. + * @throws If no authentication session is active. + * @throws If `oldVaultKey` does not match the currently protected key. + */ + async renewVaultKeyProtection(params: { + authenticationResponse: PasskeyAuthenticationResponse; + oldVaultKey: string; + newVaultKey: string; + }): Promise { + const { authenticationResponse, oldVaultKey, newVaultKey } = params; + + const record = this.#getPasskeyRecord(); + if (!record) { + throw new Error('Passkey is not enrolled'); + } + + await this.#verifyAuthentication(authenticationResponse, record); + + const encKey = deriveKeyFromAuthenticationResponse( + authenticationResponse, + record, + ); + + const decryptedVaultKey = decryptWithKey( + record.encryptedVaultKey, + record.iv, + encKey, + ); + + if (decryptedVaultKey !== oldVaultKey) { + this.#authenticationSession = null; + throw new Error( + 'Passkey authentication does not match the current vault key', + ); + } + + const { ciphertext, iv: newIv } = encryptWithKey(newVaultKey, encKey); + + this.#setPasskeyRecord({ + ...record, + encryptedVaultKey: ciphertext, + iv: newIv, + }); + + this.#authenticationSession = null; + } + + /** + * Resets persisted state to defaults and clears any in-flight WebAuthn + * sessions (registration or authentication). Use from app lifecycle hooks + * such as wallet reset, alongside other controllers' `clearState` pattern. + */ + clearState(): void { + this.update((state) => { + Object.assign(state, getDefaultPasskeyControllerState()); + }); + this.#registrationSession = null; + this.#authenticationSession = null; + } + + /** + * Unenrolls the passkey, removing the protected vault key material. + */ + removePasskey(): void { + this.clearState(); + } + + /** + * Verifies a WebAuthn authentication response against the enrolled + * credential. + * + * @param authenticationResponse - Authentication result JSON. + * @param record - The enrolled passkey record to verify against. + */ + async #verifyAuthentication( + authenticationResponse: PasskeyAuthenticationResponse, + record: PasskeyRecord, + ): Promise { + const session = this.#authenticationSession; + if (!session) { + throw new Error('No active passkey authentication session'); + } + + const verification = await verifyAuthenticationResponse({ + response: authenticationResponse, + expectedChallenge: session.challenge, + expectedOrigin: this.#expectedOrigin, + expectedRPID: this.#rpID, + credential: { + id: record.credentialId, + publicKey: base64URLToBytes(record.publicKey), + counter: record.counter, + transports: record.transports, + }, + // Passkeys with touch-only authenticators (no PIN/biometric) are + // accepted intentionally to maximise device compatibility. The + // vault key is already protected by the user's wallet password. + requireUserVerification: false, + }); + + if (!verification.verified) { + throw new Error('Passkey authentication verification failed'); + } + + this.#setPasskeyRecord({ + ...record, + counter: verification.authenticationInfo.newCounter, + }); + } +} diff --git a/packages/passkey-controller/src/index.ts b/packages/passkey-controller/src/index.ts new file mode 100644 index 00000000000..8cbb08a3614 --- /dev/null +++ b/packages/passkey-controller/src/index.ts @@ -0,0 +1,25 @@ +export { + PasskeyController, + getDefaultPasskeyControllerState, +} from './PasskeyController'; +export type { + PasskeyControllerState, + PasskeyControllerMessenger, + PasskeyControllerGetStateAction, + PasskeyControllerIsPasskeyEnrolledAction, + PasskeyControllerActions, + PasskeyControllerStateChangeEvent, + PasskeyControllerEvents, +} from './PasskeyController'; +export type { + PasskeyDerivationMethod, + PasskeyRecord, + PrfEvalExtension, + PrfClientExtensionResults, +} from './types'; +export type { + PasskeyRegistrationOptions, + PasskeyRegistrationResponse, + PasskeyAuthenticationOptions, + PasskeyAuthenticationResponse, +} from './webauthn'; 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..507874b8dce --- /dev/null +++ b/packages/passkey-controller/src/key-derivation.test.ts @@ -0,0 +1,223 @@ +import { + deriveKeyFromRegistrationResponse, + deriveKeyFromAuthenticationResponse, +} from './key-derivation'; +import type { PasskeyRecord, PasskeyRegistrationSession } from './types'; +import type { + PasskeyAuthenticationResponse, + PasskeyRegistrationResponse, +} from './webauthn'; + +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 makeSession(): PasskeyRegistrationSession { + return { + userHandle: USER_HANDLE, + prfSalt: PRF_SALT, + challenge: b64url('challenge'), + }; +} + +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 { + credentialId: CREDENTIAL_ID, + derivationMethod, + iv: 'iv', + encryptedVaultKey: 'ciphertext', + publicKey: 'pubkey', + counter: 0, + prfSalt: derivationMethod === 'prf' ? PRF_SALT : undefined, + }; +} + +describe('deriveKeyFromRegistrationResponse', () => { + it('uses PRF output when prf.results.first is present', () => { + const response = makeRegistrationResponse({ + prf: { results: { first: PRF_FIRST } }, + }); + + const { encKey, derivationMethod } = deriveKeyFromRegistrationResponse( + response, + makeSession(), + ); + + expect(derivationMethod).toBe('prf'); + expect(encKey).toBeInstanceOf(Uint8Array); + expect(encKey).toHaveLength(32); + }); + + it('uses PRF output when prf.enabled is true', () => { + const response = makeRegistrationResponse({ + prf: { enabled: true, results: { first: PRF_FIRST } }, + }); + + const { derivationMethod } = deriveKeyFromRegistrationResponse( + response, + makeSession(), + ); + + expect(derivationMethod).toBe('prf'); + }); + + it('falls back to userHandle when PRF is absent', () => { + const response = makeRegistrationResponse({}); + + const { encKey, derivationMethod } = deriveKeyFromRegistrationResponse( + response, + makeSession(), + ); + + expect(derivationMethod).toBe('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 { derivationMethod } = deriveKeyFromRegistrationResponse( + response, + makeSession(), + ); + + expect(derivationMethod).toBe('userHandle'); + }); + + it('produces different keys for different credential IDs', () => { + const response1 = makeRegistrationResponse({}); + const response2 = makeRegistrationResponse({}); + response2.id = b64url('different-cred-id'); + + const { encKey: key1 } = deriveKeyFromRegistrationResponse( + response1, + makeSession(), + ); + const { encKey: key2 } = deriveKeyFromRegistrationResponse( + response2, + makeSession(), + ); + + expect(key1).not.toStrictEqual(key2); + }); + + it('produces different keys for PRF vs userHandle', () => { + const session = makeSession(); + + const responseWithPrf = makeRegistrationResponse({ + prf: { results: { first: PRF_FIRST } }, + }); + const responseWithoutPrf = makeRegistrationResponse({}); + + const { encKey: prfKey } = deriveKeyFromRegistrationResponse( + responseWithPrf, + session, + ); + const { encKey: uhKey } = deriveKeyFromRegistrationResponse( + responseWithoutPrf, + session, + ); + + expect(prfKey).not.toStrictEqual(uhKey); + }); +}); + +describe('deriveKeyFromAuthenticationResponse', () => { + it('uses PRF output when derivationMethod 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 derivationMethod 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('Passkey assertion missing required key material'); + }); + + it('produces consistent keys across registration and authentication', () => { + const regResponse = makeRegistrationResponse({}); + const session = makeSession(); + + const { encKey: regKey } = deriveKeyFromRegistrationResponse( + regResponse, + session, + ); + + 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..72be72ff62c --- /dev/null +++ b/packages/passkey-controller/src/key-derivation.ts @@ -0,0 +1,86 @@ +import type { + PasskeyRecord, + PasskeyRegistrationSession, + PrfClientExtensionResults, +} from './types'; +import { deriveEncryptionKey } from './utils/crypto'; +import { base64URLToBytes } from './utils/encoding'; +import type { + PasskeyAuthenticationResponse, + PasskeyRegistrationResponse, +} from './webauthn'; + +/** + * Derives an AES-256 wrapping key from a WebAuthn registration ceremony + * response. + * + * Checks whether the authenticator returned a PRF evaluation result. If + * so, uses the PRF output as HKDF input key material; otherwise falls + * back to the random `userHandle` created during option generation. + * + * @param registrationResponse - The registration credential result from + * `navigator.credentials.create()`. + * @param session - The in-memory registration session that was created + * when `generateRegistrationOptions()` was called. + * @returns The derived 32-byte AES wrapping key and which derivation + * method (PRF vs userHandle) was used. + */ +export function deriveKeyFromRegistrationResponse( + registrationResponse: PasskeyRegistrationResponse, + session: PasskeyRegistrationSession, +): { + encKey: Uint8Array; + derivationMethod: 'prf' | 'userHandle'; +} { + const credentialId = registrationResponse.id; + const prf = ( + registrationResponse.clientExtensionResults as PrfClientExtensionResults + )?.prf; + const prfFirst = prf?.results?.first; + const prfEnabled = + prf?.enabled === true || (prfFirst !== undefined && prfFirst.length > 0); + const derivationMethod = prfEnabled ? 'prf' : 'userHandle'; + const ikm: Uint8Array = + derivationMethod === 'prf' + ? base64URLToBytes(prfFirst as string) + : base64URLToBytes(session.userHandle); + const encKey = deriveEncryptionKey(ikm, base64URLToBytes(credentialId)); + return { encKey, derivationMethod }; +} + +/** + * Derives an AES-256 wrapping key from a WebAuthn authentication ceremony + * response. + * + * The derivation method is determined by the stored `PasskeyRecord`: + * - `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 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; + + let ikm: Uint8Array; + if (record.derivationMethod === 'prf') { + ikm = base64URLToBytes(prfFirst as string); + } else if (userHandle) { + ikm = base64URLToBytes(userHandle); + } else { + throw new Error('Passkey assertion missing required key material'); + } + + return deriveEncryptionKey(ikm, base64URLToBytes(record.credentialId)); +} diff --git a/packages/passkey-controller/src/types.ts b/packages/passkey-controller/src/types.ts new file mode 100644 index 00000000000..55acf7e2172 --- /dev/null +++ b/packages/passkey-controller/src/types.ts @@ -0,0 +1,61 @@ +export type PasskeyDerivationMethod = 'prf' | 'userHandle'; + +export type Base64String = string; + +export type Base64URLString = string; + +export type AuthenticatorTransportFuture = + | 'ble' + | 'cable' + | 'hybrid' + | 'internal' + | 'nfc' + | 'smart-card' + | 'usb'; + +export type PasskeyRecord = { + /** WebAuthn credential ID (base64url) */ + credentialId: Base64URLString; + /** PRF or userHandle */ + derivationMethod: PasskeyDerivationMethod; + /** AES-GCM IV for the encryption operation */ + iv: Base64String; + /** PRF salt (present when derivationMethod === 'prf') */ + prfSalt?: Base64URLString; + /** vault key encrypted with passkey-derived key */ + encryptedVaultKey: Base64String; + /** Credential public key for signature verification (base64url-encoded COSE key) */ + publicKey: Base64URLString; + /** Authenticator signature counter for replay detection */ + counter: number; + /** Authenticator transports for allowCredentials hints */ + transports?: AuthenticatorTransportFuture[]; +}; + +/** In-memory registration session: creation material + RP challenge bytes. */ +export type PasskeyRegistrationSession = { + userHandle: Base64URLString; + prfSalt: Base64URLString; + challenge: Base64URLString; +}; + +/** In-memory authentication session: challenge bytes. */ +export type PasskeyAuthenticationSession = { + challenge: Base64URLString; +}; + +/** + * 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/utils/index.ts b/packages/passkey-controller/src/utils/index.ts new file mode 100644 index 00000000000..5a1e80dc611 --- /dev/null +++ b/packages/passkey-controller/src/utils/index.ts @@ -0,0 +1,2 @@ +export { deriveEncryptionKey, encryptWithKey, decryptWithKey } from './crypto'; +export { bytesToBase64URL, base64URLToBytes, bytesToHex } from './encoding'; 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/decodeAttestationObject.ts b/packages/passkey-controller/src/webauthn/decodeAttestationObject.ts new file mode 100644 index 00000000000..21c3a4da218 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/decodeAttestationObject.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/decodeClientDataJSON.ts b/packages/passkey-controller/src/webauthn/decodeClientDataJSON.ts new file mode 100644 index 00000000000..a94504177aa --- /dev/null +++ b/packages/passkey-controller/src/webauthn/decodeClientDataJSON.ts @@ -0,0 +1,14 @@ +import type { ClientDataJSON } from './types'; +import { base64URLToBytes } from '../utils/encoding'; + +/** + * 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/index.ts b/packages/passkey-controller/src/webauthn/index.ts new file mode 100644 index 00000000000..e51c4089d83 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/index.ts @@ -0,0 +1,15 @@ +export { COSEALG } from './constants'; +export { + verifyRegistrationResponse, + type VerifiedRegistrationResponse, +} from './verifyRegistrationResponse'; +export { + verifyAuthenticationResponse, + type VerifiedAuthenticationResponse, +} from './verifyAuthenticationResponse'; +export type { + PasskeyRegistrationOptions, + PasskeyRegistrationResponse, + PasskeyAuthenticationOptions, + PasskeyAuthenticationResponse, +} from './types'; diff --git a/packages/passkey-controller/src/webauthn/matchExpectedRPID.ts b/packages/passkey-controller/src/webauthn/matchExpectedRPID.ts new file mode 100644 index 00000000000..01e489a27c5 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/matchExpectedRPID.ts @@ -0,0 +1,44 @@ +import { sha256 } from '@noble/hashes/sha2'; + +import { bytesToHex } from '../utils/encoding'; + +/** + * Compare two Uint8Arrays for equality in constant time. + * + * @param first - First array. + * @param second - Second array. + * @returns Whether the two arrays are equal. + */ +function areEqual(first: Uint8Array, second: Uint8Array): boolean { + if (first.length !== second.length) { + return false; + } + let diff = 0; + for (let i = 0; i < first.length; i++) { + // eslint-disable-next-line no-bitwise + diff |= (first[i] ?? 0) ^ (second[i] ?? 0); + } + return diff === 0; +} + +/** + * 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 (areEqual(rpIdHash, expectedHash)) { + return rpID; + } + } + throw new Error(`Unexpected RP ID hash: received ${bytesToHex(rpIdHash)}`); +} diff --git a/packages/passkey-controller/src/webauthn/parseAuthenticatorData.ts b/packages/passkey-controller/src/webauthn/parseAuthenticatorData.ts new file mode 100644 index 00000000000..9b52d08732c --- /dev/null +++ b/packages/passkey-controller/src/webauthn/parseAuthenticatorData.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] ?? 0; + 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..f255d7f7a70 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/types.ts @@ -0,0 +1,124 @@ +import type { + AuthenticatorTransportFuture, + Base64URLString as Base64URL, +} from '../types'; + +export type PublicKeyCredentialDescriptorJSON = { + id: Base64URL; + type: 'public-key'; + transports?: AuthenticatorTransportFuture[]; +}; + +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'; + }; + 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'; + 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/verifyAuthenticationResponse.ts b/packages/passkey-controller/src/webauthn/verifyAuthenticationResponse.ts new file mode 100644 index 00000000000..38f64676483 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/verifyAuthenticationResponse.ts @@ -0,0 +1,183 @@ +import { decodePartialCBOR } from '@levischuck/tiny-cbor'; +import { sha256 } from '@noble/hashes/sha2'; + +import { decodeClientDataJSON } from './decodeClientDataJSON'; +import { matchExpectedRPID } from './matchExpectedRPID'; +import { parseAuthenticatorData } from './parseAuthenticatorData'; +import type { ParsedAuthenticatorData } from './types'; +import type { PasskeyAuthenticationResponse } from './types'; +import { verifySignature } from './verifySignature'; +import type { AuthenticatorTransportFuture } from '../types'; +import { base64URLToBytes } from '../utils/encoding'; + +export type VerifiedAuthenticationResponse = { + verified: boolean; + 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; + + if (!id) { + throw new Error('Missing credential ID'); + } + if (id !== rawId) { + throw new Error('Credential ID was not base64url-encoded'); + } + if (credentialType !== 'public-key') { + throw new Error( + `Unexpected credential type ${String(credentialType)}, expected "public-key"`, + ); + } + + const clientDataJSON = decodeClientDataJSON(assertionResponse.clientDataJSON); + + if (clientDataJSON.type !== 'webauthn.get') { + throw new Error( + `Unexpected authentication response type: ${clientDataJSON.type}`, + ); + } + + if (clientDataJSON.challenge !== expectedChallenge) { + throw new Error( + `Unexpected authentication response challenge "${clientDataJSON.challenge}", expected "${expectedChallenge}"`, + ); + } + + const expectedOrigins = Array.isArray(expectedOrigin) + ? expectedOrigin + : [expectedOrigin]; + if (!expectedOrigins.includes(clientDataJSON.origin)) { + throw new Error( + `Unexpected authentication response origin "${clientDataJSON.origin}", expected one of: ${expectedOrigins.join(', ')}`, + ); + } + + const authDataBuffer = base64URLToBytes(assertionResponse.authenticatorData); + const parsedAuthData: ParsedAuthenticatorData = + parseAuthenticatorData(authDataBuffer); + const { rpIdHash, flags, counter } = parsedAuthData; + + const matchedRPID = matchExpectedRPID(rpIdHash, [expectedRPID]); + + if (!flags.up) { + throw new Error('User not present during authentication'); + } + + if (requireUserVerification && !flags.uv) { + throw new Error( + 'User verification required, but user could not be verified', + ); + } + + const clientDataHash = sha256( + base64URLToBytes(assertionResponse.clientDataJSON), + ); + const signatureBase = concatUint8Arrays(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 ( + (counter > 0 || credential.counter > 0) && + counter <= credential.counter + ) { + throw new Error( + `Response counter value ${counter} was lower than expected ${credential.counter}`, + ); + } + + return { + verified, + authenticationInfo: { + credentialId: credential.id, + newCounter: counter, + userVerified: flags.uv, + origin: clientDataJSON.origin, + rpID: matchedRPID, + }, + }; +} + +function concatUint8Arrays(first: Uint8Array, second: Uint8Array): Uint8Array { + const result = new Uint8Array(first.length + second.length); + result.set(first, 0); + result.set(second, first.length); + return result; +} diff --git a/packages/passkey-controller/src/webauthn/verifyRegistrationResponse.ts b/packages/passkey-controller/src/webauthn/verifyRegistrationResponse.ts new file mode 100644 index 00000000000..f6b5d5bae57 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/verifyRegistrationResponse.ts @@ -0,0 +1,289 @@ +import { decodePartialCBOR } from '@levischuck/tiny-cbor'; +import { sha256 } from '@noble/hashes/sha2'; + +import { COSEALG, COSEKEYS } from './constants'; +import { decodeAttestationObject } from './decodeAttestationObject'; +import { decodeClientDataJSON } from './decodeClientDataJSON'; +import { matchExpectedRPID } from './matchExpectedRPID'; +import { parseAuthenticatorData } from './parseAuthenticatorData'; +import type { PasskeyRegistrationResponse } from './types'; +import { verifySignature } from './verifySignature'; +import type { AuthenticatorTransportFuture } from '../types'; +import { + base64URLToBytes, + bytesToBase64URL, + bytesToHex, +} from '../utils/encoding'; + +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`). + * 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; + + if (!id) { + throw new Error('Missing credential ID'); + } + if (id !== rawId) { + throw new Error('Credential ID was not base64url-encoded'); + } + if (credentialType !== 'public-key') { + throw new Error( + `Unexpected credential type ${String(credentialType)}, expected "public-key"`, + ); + } + + const clientDataJSON = decodeClientDataJSON( + attestationResponse.clientDataJSON, + ); + + if (clientDataJSON.type !== 'webauthn.create') { + throw new Error( + `Unexpected registration response type: ${clientDataJSON.type}`, + ); + } + + if (clientDataJSON.challenge !== expectedChallenge) { + throw new Error( + `Unexpected registration response challenge "${clientDataJSON.challenge}", expected "${expectedChallenge}"`, + ); + } + + const expectedOrigins = Array.isArray(expectedOrigin) + ? expectedOrigin + : [expectedOrigin]; + if (!expectedOrigins.includes(clientDataJSON.origin)) { + throw new Error( + `Unexpected registration response origin "${clientDataJSON.origin}", expected one of: ${expectedOrigins.join(', ')}`, + ); + } + + 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]); + + if (!flags.up) { + throw new Error('User presence was required, but user was not present'); + } + + 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'); + } + 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'); + } + 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: bytesToBase64URL(credentialID), + 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 = concatUint8Arrays(authData, clientDataHash); + + return verifySignature({ + cosePublicKey, + signature, + data: signatureBase, + }); +} + +function concatUint8Arrays(first: Uint8Array, second: Uint8Array): Uint8Array { + const result = new Uint8Array(first.length + second.length); + result.set(first, 0); + result.set(second, first.length); + return result; +} diff --git a/packages/passkey-controller/src/webauthn/verifySignature.ts b/packages/passkey-controller/src/webauthn/verifySignature.ts new file mode 100644 index 00000000000..3166086ab0c --- /dev/null +++ b/packages/passkey-controller/src/webauthn/verifySignature.ts @@ -0,0 +1,186 @@ +import { ed25519 } from '@noble/curves/ed25519'; +import { p256, p384 } from '@noble/curves/nist'; +import { sha256, sha384 } from '@noble/hashes/sha2'; + +import { COSEALG, COSECRV, COSEKEYS, COSEKTY } from './constants'; +import { bytesToBase64URL } from '../utils/encoding'; + +type COSEPublicKey = Map; + +/** + * Concatenate multiple Uint8Arrays into a single Uint8Array. + * + * @param arrays - Arrays to concatenate. + * @returns Combined Uint8Array. + */ +function concatBytes(...arrays: Uint8Array[]): Uint8Array { + const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + for (const arr of arrays) { + result.set(arr, offset); + offset += arr.length; + } + return result; +} + +/** + * 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) 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 crv = cosePublicKey.get(COSEKEYS.Crv) as number; + const xCoord = cosePublicKey.get(COSEKEYS.X) as Uint8Array; + const yCoord = cosePublicKey.get(COSEKEYS.Y) as Uint8Array; + + 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); + 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 xCoord = cosePublicKey.get(COSEKEYS.X) as Uint8Array; + + if (!xCoord) { + throw new Error('OKP public key missing x coordinate'); + } + + return ed25519.verify(signature, data, xCoord); +} + +/** + * Verify an RSA (RS256/RS384/RS512) 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) as number; + const modulus = cosePublicKey.get(COSEKEYS.N) as Uint8Array; + const exponent = cosePublicKey.get(COSEKEYS.E) as Uint8Array; + + if (!modulus || !exponent) { + throw new Error('RSA public key missing n or e'); + } + + let hashAlg: string; + switch (alg) { + case COSEALG.RS256: + hashAlg = 'SHA-256'; + break; + case COSEALG.RS384: + hashAlg = 'SHA-384'; + break; + case COSEALG.RS512: + hashAlg = 'SHA-512'; + 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: 'RSASSA-PKCS1-v1_5', hash: { name: hashAlg } }, + false, + ['verify'], + ); + + return globalThis.crypto.subtle.verify( + 'RSASSA-PKCS1-v1_5', + key, + signature.buffer as ArrayBuffer, + data.buffer as ArrayBuffer, + ); +} + +/** + * 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/src/webauthn/webauthn.test.ts b/packages/passkey-controller/src/webauthn/webauthn.test.ts new file mode 100644 index 00000000000..add5d4ff2be --- /dev/null +++ b/packages/passkey-controller/src/webauthn/webauthn.test.ts @@ -0,0 +1,1887 @@ +import { encodeCBOR } from '@levischuck/tiny-cbor'; +import { ed25519 } from '@noble/curves/ed25519'; +import { p256 } from '@noble/curves/p256'; +import { p384 } from '@noble/curves/p384'; +import { sha256, sha384 } from '@noble/hashes/sha2'; + +import { COSEALG, COSEKEYS, COSEKTY, COSECRV } from './constants'; +import type { + PasskeyRegistrationResponse, + PasskeyAuthenticationResponse, +} from './types'; +import { verifyAuthenticationResponse } from './verifyAuthenticationResponse'; +import { verifyRegistrationResponse } from './verifyRegistrationResponse'; +import { bytesToBase64URL } from '../utils/encoding'; + +// --------------------------------------------------------------------------- +// Test Helpers +// --------------------------------------------------------------------------- + +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)); +} + +/** + * Build a COSE public key Map for ES256 (P-256) from a raw public key point. + * + * @param pubKeyBytes - Uncompressed EC public key bytes. + * @returns A COSE public key map. + */ +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); + // Skip 0x04 prefix for uncompressed point + map.set(COSEKEYS.X, pubKeyBytes.slice(1, 33)); + map.set(COSEKEYS.Y, pubKeyBytes.slice(33, 65)); + return map; +} + +/** + * Generate a P-256 key pair. + * + * @returns An object with privateKey, publicKeyRaw, and cosePublicKeyCBOR. + */ +function generateES256KeyPair(): { + privateKey: Uint8Array; + publicKeyRaw: Uint8Array; + cosePublicKeyCBOR: Uint8Array; +} { + const privateKey = p256.utils.randomPrivateKey(); + const publicKeyRaw = p256.getPublicKey(privateKey, false); + const coseMap = buildCosePublicKeyMap(publicKeyRaw); + const cosePublicKeyCBOR = encodeCBOR(coseMap); + return { privateKey, publicKeyRaw, cosePublicKeyCBOR }; +} + +/** + * Build a minimal authenticator data buffer. + * + * @param opts - Authenticator data fields. + * @param opts.rpIdHash - SHA-256 hash of the RP ID. + * @param opts.flags - Flags byte value. + * @param opts.counter - Signature counter. + * @param opts.aaguid - Authenticator AAGUID. + * @param opts.credentialID - Credential identifier bytes. + * @param opts.credentialPublicKey - CBOR-encoded COSE public key. + * @returns Raw authenticator data bytes. + */ +function buildAuthenticatorData(opts: { + rpIdHash: Uint8Array; + flags: number; + counter: number; + aaguid?: Uint8Array; + credentialID?: Uint8Array; + credentialPublicKey?: Uint8Array; +}): Uint8Array { + const parts: Uint8Array[] = []; + + parts.push(opts.rpIdHash); // 32 bytes + + parts.push(new Uint8Array([opts.flags])); // 1 byte + + const counterBuf = new Uint8Array(4); + new DataView(counterBuf.buffer).setUint32(0, opts.counter, false); + parts.push(counterBuf); // 4 bytes + + if (opts.aaguid && opts.credentialID && opts.credentialPublicKey) { + parts.push(opts.aaguid); // 16 bytes + + 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; +} + +/** + * Build a minimal attestation object (CBOR). + * + * @param authData - Raw authenticator data. + * @param fmt - Attestation format string. + * @param attStmt - Attestation statement map. + * @returns CBOR-encoded attestation object. + */ +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: {}, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('verifyRegistrationResponse', () => { + it('verifies a valid registration with none attestation', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x01); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + // flags: UP (0x01) | AT (0x40) = 0x41 + 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: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }); + + expect(result.verified).toBe(true); + expect(result.verified && result.registrationInfo.credentialId).toBe( + credentialIdB64, + ); + expect(result.verified && result.registrationInfo.publicKey).toStrictEqual( + cosePublicKeyCBOR, + ); + }); + + it('rejects mismatched challenge', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x02); + 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: 'wrong-challenge', + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }), + ).rejects.toThrow('Unexpected registration response challenge'); + }); + + it('rejects mismatched origin', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x03); + 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: 'https://evil.com', + expectedRPID: TEST_RP_ID, + }), + ).rejects.toThrow('Unexpected registration response origin'); + }); + + it('rejects mismatched RP ID', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x04); + 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: 'wrong-rp.com', + }), + ).rejects.toThrow('Unexpected RP ID hash'); + }); + + it('rejects wrong clientDataJSON type', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x05); + 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, + 'none', + new Map(), + { type: 'webauthn.get' }, + ); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }), + ).rejects.toThrow('Unexpected registration response type'); + }); + + it('rejects missing credential ID', async () => { + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + // flags: UP only (no AT bit) - no attested credential data + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x01, + counter: 0, + }); + + const response = buildRegistrationResponse(authData, 'some-id'); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }), + ).rejects.toThrow('No credential ID was provided'); + }); + + it('verifies packed self-attestation with real ES256 signature', async () => { + const { privateKey, cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x07); + 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 clientDataJSONStr = makeClientDataJSON(); + const clientDataHash = sha256( + Uint8Array.from( + atob( + clientDataJSONStr.replace(/-/gu, '+').replace(/_/gu, '/') + + '='.repeat((4 - (clientDataJSONStr.length % 4)) % 4), + ), + (ch) => ch.charCodeAt(0), + ), + ); + + const signatureBase = new Uint8Array( + authData.length + clientDataHash.length, + ); + signatureBase.set(authData, 0); + signatureBase.set(clientDataHash, authData.length); + + const sigHash = sha256(signatureBase); + const ecdsaSig = p256.sign(sigHash, privateKey); + + const attStmt = new Map(); + attStmt.set('alg', COSEALG.ES256); + attStmt.set('sig', new Uint8Array(ecdsaSig.toDERRawBytes())); + + const credentialIdB64 = bytesToBase64URL(credentialID); + const attestationObject = buildAttestationObject( + authData, + 'packed', + attStmt, + ); + + const response: PasskeyRegistrationResponse = { + id: credentialIdB64, + rawId: credentialIdB64, + type: 'public-key', + response: { + clientDataJSON: clientDataJSONStr, + attestationObject: bytesToBase64URL(attestationObject), + }, + clientExtensionResults: {}, + }; + + const result = await verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }); + + expect(result.verified).toBe(true); + }); +}); + +describe('verifyAuthenticationResponse', () => { + function makeAuthClientDataJSON( + overrides?: Partial<{ + type: string; + challenge: string; + origin: string; + }>, + ): string { + const json = JSON.stringify({ + type: overrides?.type ?? 'webauthn.get', + challenge: overrides?.challenge ?? TEST_CHALLENGE, + origin: overrides?.origin ?? TEST_ORIGIN, + }); + return bytesToBase64URL(new TextEncoder().encode(json)); + } + + it('verifies a valid authentication with real ES256 signature', async () => { + const { privateKey, cosePublicKeyCBOR } = generateES256KeyPair(); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + // flags: UP (0x01) + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x01, + counter: 1, + }); + + const clientDataJSONStr = makeAuthClientDataJSON(); + const clientDataBytes = Uint8Array.from( + atob( + clientDataJSONStr.replace(/-/gu, '+').replace(/_/gu, '/') + + '='.repeat((4 - (clientDataJSONStr.length % 4)) % 4), + ), + (ch) => ch.charCodeAt(0), + ); + const clientDataHash = sha256(clientDataBytes); + + const signatureBase = new Uint8Array( + authData.length + clientDataHash.length, + ); + signatureBase.set(authData, 0); + signatureBase.set(clientDataHash, authData.length); + + const sigHash = sha256(signatureBase); + const ecdsaSig = p256.sign(sigHash, privateKey); + + const credentialIdB64 = bytesToBase64URL(new Uint8Array(16).fill(0x10)); + + const response: PasskeyAuthenticationResponse = { + id: credentialIdB64, + rawId: credentialIdB64, + type: 'public-key', + response: { + clientDataJSON: clientDataJSONStr, + authenticatorData: bytesToBase64URL(authData), + signature: bytesToBase64URL(new Uint8Array(ecdsaSig.toDERRawBytes())), + }, + clientExtensionResults: {}, + }; + + const result = await verifyAuthenticationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + credential: { + id: credentialIdB64, + publicKey: cosePublicKeyCBOR, + counter: 0, + }, + }); + + expect(result.verified).toBe(true); + expect(result.authenticationInfo.newCounter).toBe(1); + expect(result.authenticationInfo.rpID).toBe(TEST_RP_ID); + }); + + it('rejects mismatched challenge', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x01, + counter: 1, + }); + + const credentialIdB64 = bytesToBase64URL(new Uint8Array(16).fill(0x11)); + + const response: PasskeyAuthenticationResponse = { + id: credentialIdB64, + rawId: credentialIdB64, + type: 'public-key', + response: { + clientDataJSON: makeAuthClientDataJSON(), + authenticatorData: bytesToBase64URL(authData), + signature: bytesToBase64URL(new Uint8Array(64)), + }, + clientExtensionResults: {}, + }; + + await expect( + verifyAuthenticationResponse({ + response, + expectedChallenge: 'wrong-challenge', + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + credential: { + id: credentialIdB64, + publicKey: cosePublicKeyCBOR, + counter: 0, + }, + }), + ).rejects.toThrow('Unexpected authentication response challenge'); + }); + + it('rejects mismatched origin', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x01, + counter: 1, + }); + + const credentialIdB64 = bytesToBase64URL(new Uint8Array(16).fill(0x12)); + + const response: PasskeyAuthenticationResponse = { + id: credentialIdB64, + rawId: credentialIdB64, + type: 'public-key', + response: { + clientDataJSON: makeAuthClientDataJSON(), + authenticatorData: bytesToBase64URL(authData), + signature: bytesToBase64URL(new Uint8Array(64)), + }, + clientExtensionResults: {}, + }; + + await expect( + verifyAuthenticationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: 'https://evil.com', + expectedRPID: TEST_RP_ID, + credential: { + id: credentialIdB64, + publicKey: cosePublicKeyCBOR, + counter: 0, + }, + }), + ).rejects.toThrow('Unexpected authentication response origin'); + }); + + it('rejects counter replay', async () => { + const { privateKey, cosePublicKeyCBOR } = generateES256KeyPair(); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x01, + counter: 5, + }); + + const clientDataJSONStr = makeAuthClientDataJSON(); + const clientDataBytes = Uint8Array.from( + atob( + clientDataJSONStr.replace(/-/gu, '+').replace(/_/gu, '/') + + '='.repeat((4 - (clientDataJSONStr.length % 4)) % 4), + ), + (ch) => ch.charCodeAt(0), + ); + const clientDataHash = sha256(clientDataBytes); + + const signatureBase = new Uint8Array( + authData.length + clientDataHash.length, + ); + signatureBase.set(authData, 0); + signatureBase.set(clientDataHash, authData.length); + + const sigHash = sha256(signatureBase); + const ecdsaSig = p256.sign(sigHash, privateKey); + + const credentialIdB64 = bytesToBase64URL(new Uint8Array(16).fill(0x13)); + + const response: PasskeyAuthenticationResponse = { + id: credentialIdB64, + rawId: credentialIdB64, + type: 'public-key', + response: { + clientDataJSON: clientDataJSONStr, + authenticatorData: bytesToBase64URL(authData), + signature: bytesToBase64URL(new Uint8Array(ecdsaSig.toDERRawBytes())), + }, + clientExtensionResults: {}, + }; + + await expect( + verifyAuthenticationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + credential: { + id: credentialIdB64, + publicKey: cosePublicKeyCBOR, + counter: 10, + }, + }), + ).rejects.toThrow('Response counter value 5 was lower than expected 10'); + }); + + it('rejects wrong clientDataJSON type', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x01, + counter: 1, + }); + + const credentialIdB64 = bytesToBase64URL(new Uint8Array(16).fill(0x14)); + + const response: PasskeyAuthenticationResponse = { + id: credentialIdB64, + rawId: credentialIdB64, + type: 'public-key', + response: { + clientDataJSON: makeAuthClientDataJSON({ type: 'webauthn.create' }), + authenticatorData: bytesToBase64URL(authData), + signature: bytesToBase64URL(new Uint8Array(64)), + }, + clientExtensionResults: {}, + }; + + await expect( + verifyAuthenticationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + credential: { + id: credentialIdB64, + publicKey: cosePublicKeyCBOR, + counter: 0, + }, + }), + ).rejects.toThrow('Unexpected authentication response type'); + }); + + it('rejects mismatched RP ID', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x01, + counter: 1, + }); + + const credentialIdB64 = bytesToBase64URL(new Uint8Array(16).fill(0x15)); + + const response: PasskeyAuthenticationResponse = { + id: credentialIdB64, + rawId: credentialIdB64, + type: 'public-key', + response: { + clientDataJSON: makeAuthClientDataJSON(), + authenticatorData: bytesToBase64URL(authData), + signature: bytesToBase64URL(new Uint8Array(64)), + }, + clientExtensionResults: {}, + }; + + await expect( + verifyAuthenticationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: 'wrong-rp.com', + credential: { + id: credentialIdB64, + publicKey: cosePublicKeyCBOR, + counter: 0, + }, + }), + ).rejects.toThrow('Unexpected RP ID hash'); + }); + + it('rejects missing credential ID', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialIdB64 = bytesToBase64URL(new Uint8Array(16).fill(0x16)); + + const response: PasskeyAuthenticationResponse = { + id: '', + rawId: '', + type: 'public-key', + response: { + clientDataJSON: makeAuthClientDataJSON(), + authenticatorData: bytesToBase64URL(new Uint8Array(37)), + signature: bytesToBase64URL(new Uint8Array(64)), + }, + clientExtensionResults: {}, + }; + + await expect( + verifyAuthenticationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + credential: { + id: credentialIdB64, + publicKey: cosePublicKeyCBOR, + counter: 0, + }, + }), + ).rejects.toThrow('Missing credential ID'); + }); + + it('rejects id !== rawId', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialIdB64 = bytesToBase64URL(new Uint8Array(16).fill(0x17)); + + const response: PasskeyAuthenticationResponse = { + id: credentialIdB64, + rawId: 'different-raw-id', + type: 'public-key', + response: { + clientDataJSON: makeAuthClientDataJSON(), + authenticatorData: bytesToBase64URL(new Uint8Array(37)), + signature: bytesToBase64URL(new Uint8Array(64)), + }, + clientExtensionResults: {}, + }; + + await expect( + verifyAuthenticationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + credential: { + id: credentialIdB64, + publicKey: cosePublicKeyCBOR, + counter: 0, + }, + }), + ).rejects.toThrow('Credential ID was not base64url-encoded'); + }); + + it('rejects wrong credential type', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialIdB64 = bytesToBase64URL(new Uint8Array(16).fill(0x18)); + + const response = { + id: credentialIdB64, + rawId: credentialIdB64, + type: 'not-public-key', + response: { + clientDataJSON: makeAuthClientDataJSON(), + authenticatorData: bytesToBase64URL(new Uint8Array(37)), + signature: bytesToBase64URL(new Uint8Array(64)), + }, + clientExtensionResults: {}, + } as unknown as PasskeyAuthenticationResponse; + + await expect( + verifyAuthenticationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + credential: { + id: credentialIdB64, + publicKey: cosePublicKeyCBOR, + counter: 0, + }, + }), + ).rejects.toThrow('Unexpected credential type'); + }); + + it('rejects when user not present', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + // flags: 0x00 - no UP + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x00, + counter: 1, + }); + + const clientDataJSONStr = makeAuthClientDataJSON(); + const credentialIdB64 = bytesToBase64URL(new Uint8Array(16).fill(0x19)); + + const response: PasskeyAuthenticationResponse = { + id: credentialIdB64, + rawId: credentialIdB64, + type: 'public-key', + response: { + clientDataJSON: clientDataJSONStr, + authenticatorData: bytesToBase64URL(authData), + signature: bytesToBase64URL(new Uint8Array(64)), + }, + clientExtensionResults: {}, + }; + + await expect( + verifyAuthenticationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + credential: { + id: credentialIdB64, + publicKey: cosePublicKeyCBOR, + counter: 0, + }, + }), + ).rejects.toThrow('User not present during authentication'); + }); + + it('rejects user verification not met when required', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + // flags: UP only (0x01), no UV + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x01, + counter: 1, + }); + + const credentialIdB64 = bytesToBase64URL(new Uint8Array(16).fill(0x20)); + + const response: PasskeyAuthenticationResponse = { + id: credentialIdB64, + rawId: credentialIdB64, + type: 'public-key', + response: { + clientDataJSON: makeAuthClientDataJSON(), + authenticatorData: bytesToBase64URL(authData), + signature: bytesToBase64URL(new Uint8Array(64)), + }, + clientExtensionResults: {}, + }; + + await expect( + verifyAuthenticationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + credential: { + id: credentialIdB64, + publicKey: cosePublicKeyCBOR, + counter: 0, + }, + requireUserVerification: true, + }), + ).rejects.toThrow('User verification required'); + }); + + it('accepts expectedOrigin as array', async () => { + const { privateKey, cosePublicKeyCBOR } = generateES256KeyPair(); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x01, + counter: 1, + }); + + const clientDataJSONStr = makeAuthClientDataJSON(); + const clientDataBytes = Uint8Array.from( + atob( + clientDataJSONStr.replace(/-/gu, '+').replace(/_/gu, '/') + + '='.repeat((4 - (clientDataJSONStr.length % 4)) % 4), + ), + (ch) => ch.charCodeAt(0), + ); + const clientDataHash = sha256(clientDataBytes); + + const signatureBase = new Uint8Array( + authData.length + clientDataHash.length, + ); + signatureBase.set(authData, 0); + signatureBase.set(clientDataHash, authData.length); + + const sigHash = sha256(signatureBase); + const ecdsaSig = p256.sign(sigHash, privateKey); + + const credentialIdB64 = bytesToBase64URL(new Uint8Array(16).fill(0x21)); + + const response: PasskeyAuthenticationResponse = { + id: credentialIdB64, + rawId: credentialIdB64, + type: 'public-key', + response: { + clientDataJSON: clientDataJSONStr, + authenticatorData: bytesToBase64URL(authData), + signature: bytesToBase64URL(new Uint8Array(ecdsaSig.toDERRawBytes())), + }, + clientExtensionResults: {}, + }; + + const result = await verifyAuthenticationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: ['https://other.com', TEST_ORIGIN], + expectedRPID: TEST_RP_ID, + credential: { + id: credentialIdB64, + publicKey: cosePublicKeyCBOR, + counter: 0, + }, + }); + + expect(result.verified).toBe(true); + }); + + it('skips counter check when both counters are zero', async () => { + const { privateKey, cosePublicKeyCBOR } = generateES256KeyPair(); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x01, + counter: 0, + }); + + const clientDataJSONStr = makeAuthClientDataJSON(); + const clientDataBytes = Uint8Array.from( + atob( + clientDataJSONStr.replace(/-/gu, '+').replace(/_/gu, '/') + + '='.repeat((4 - (clientDataJSONStr.length % 4)) % 4), + ), + (ch) => ch.charCodeAt(0), + ); + const clientDataHash = sha256(clientDataBytes); + + const signatureBase = new Uint8Array( + authData.length + clientDataHash.length, + ); + signatureBase.set(authData, 0); + signatureBase.set(clientDataHash, authData.length); + + const sigHash = sha256(signatureBase); + const ecdsaSig = p256.sign(sigHash, privateKey); + + const credentialIdB64 = bytesToBase64URL(new Uint8Array(16).fill(0x63)); + + const response: PasskeyAuthenticationResponse = { + id: credentialIdB64, + rawId: credentialIdB64, + type: 'public-key', + response: { + clientDataJSON: clientDataJSONStr, + authenticatorData: bytesToBase64URL(authData), + signature: bytesToBase64URL(new Uint8Array(ecdsaSig.toDERRawBytes())), + }, + clientExtensionResults: {}, + }; + + const result = await verifyAuthenticationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + credential: { + id: credentialIdB64, + publicKey: cosePublicKeyCBOR, + counter: 0, + }, + }); + + expect(result.verified).toBe(true); + expect(result.authenticationInfo.newCounter).toBe(0); + }); +}); + +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)); + + // flags: UP (0x01) | AT (0x40) = 0x41 (no UV) + 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)); + + // flags: AT (0x40) only, no UP + 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 unsupported public key algorithm', async () => { + // Build a COSE key with an unsupported alg + 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 missing public key', async () => { + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x01, + counter: 0, + }); + + const credentialIdB64 = bytesToBase64URL(new Uint8Array(16).fill(0x33)); + const response = buildRegistrationResponse(authData, credentialIdB64); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }), + ).rejects.toThrow('No credential ID was provided'); + }); + + 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)); + // no 'alg' set + + 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); // doesn't match ES256 + 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 x5c certificates', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x34); + 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)); + attStmt.set('x5c', [new Uint8Array(100)]); + + 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 with certificate chain (x5c) is not supported', + ); + }); + + 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 unsupported attestation format', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x36); + 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, + 'fido-u2f', + ); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }), + ).rejects.toThrow('Unsupported attestation format'); + }); + + 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 missing credential ID in registration response', async () => { + const response: PasskeyRegistrationResponse = { + id: '', + rawId: '', + 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('Missing credential ID'); + }); +}); + +describe('verifySignature', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports, n/global-require + const { verifySignature } = require('./verifySignature'); + + it('verifies P-384 EC2 signature', async () => { + const privateKey = p384.utils.randomPrivateKey(); + 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 Ed25519 OKP signature', async () => { + const privateKey = ed25519.utils.randomPrivateKey(); + 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 edSig = ed25519.sign(data, privateKey); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature: edSig, + 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 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 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'); + }); + + /* eslint-disable n/no-unsupported-features/node-builtins */ + it('verifies RSA signature via Web Crypto', async () => { + const keyPair = await globalThis.crypto.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 globalThis.crypto.subtle.sign( + 'RSASSA-PKCS1-v1_5', + keyPair.privateKey, + data, + ), + ); + + const jwk = await globalThis.crypto.subtle.exportKey( + 'jwk', + keyPair.publicKey, + ); + + // Convert JWK n and e to raw bytes + const nBytes = Uint8Array.from( + atob( + (jwk.n as string).replace(/-/gu, '+').replace(/_/gu, '/') + + '='.repeat((4 - ((jwk.n as string).length % 4)) % 4), + ), + (ch) => ch.charCodeAt(0), + ); + const eBytes = Uint8Array.from( + atob( + (jwk.e as string).replace(/-/gu, '+').replace(/_/gu, '/') + + '='.repeat((4 - ((jwk.e as string).length % 4)) % 4), + ), + (ch) => ch.charCodeAt(0), + ); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.Alg, COSEALG.RS256); + // For RSA, COSE uses -1 for n and -2 for e (same numeric values as crv/x in EC2) + coseMap.set(-1, nBytes); + coseMap.set(-2, eBytes); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + /* eslint-enable n/no-unsupported-features/node-builtins */ + + 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'); + }); +}); + +describe('parseAuthenticatorData edge cases', () => { + /* eslint-disable @typescript-eslint/no-require-imports, n/global-require */ + const { parseAuthenticatorData } = require('./parseAuthenticatorData'); + /* eslint-enable @typescript-eslint/no-require-imports, n/global-require */ + + 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)); + // flags: UP (0x01) | ED (0x80) = 0x81 + const flags = 0x81; + const counter = new Uint8Array(4); + + // Extension data: CBOR map {"credProtect": 2} + 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)); + // flags: UP only (0x01) -- no AT, no ED + const authData = new Uint8Array(37 + 5); + authData.set(rpIdHash, 0); + authData[32] = 0x01; + // counter = 0 (bytes 33-36 are already zero) + // 5 extra bytes after the 37-byte minimum + 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)); + // flags: UP (0x01) | UV (0x04) = 0x05 + const authData = new Uint8Array(37); + authData.set(rpIdHash, 0); + authData[32] = 0x05; + // counter = 42 + 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(); + }); +}); + +describe('matchExpectedRPID edge cases', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports, n/global-require + const { matchExpectedRPID } = require('./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', + ); + }); +}); + +describe('verifyRegistrationResponse missing public key fields', () => { + it('rejects public key missing alg field', async () => { + // Build COSE key without alg + 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'); + }); +}); + +/* eslint-disable n/no-unsupported-features/node-builtins */ +describe('verifySignature RSA hash variants', () => { + /* eslint-disable @typescript-eslint/no-require-imports, n/global-require */ + const { + verifySignature: verifySignatureHelper, + } = require('./verifySignature'); + /* eslint-enable @typescript-eslint/no-require-imports, n/global-require */ + + async function generateRSAKeyPairAndSign( + hashName: string, + alg: number, + ): Promise<{ + coseMap: Map; + signature: Uint8Array; + data: Uint8Array; + }> { + const keyPair = await globalThis.crypto.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 globalThis.crypto.subtle.sign( + 'RSASSA-PKCS1-v1_5', + keyPair.privateKey, + data, + ), + ); + + const jwk = await globalThis.crypto.subtle.exportKey( + 'jwk', + keyPair.publicKey, + ); + const nBytes = Uint8Array.from( + atob( + (jwk.n as string).replace(/-/gu, '+').replace(/_/gu, '/') + + '='.repeat((4 - ((jwk.n as string).length % 4)) % 4), + ), + (ch) => ch.charCodeAt(0), + ); + const eBytes = Uint8Array.from( + atob( + (jwk.e as string).replace(/-/gu, '+').replace(/_/gu, '/') + + '='.repeat((4 - ((jwk.e as string).length % 4)) % 4), + ), + (ch) => ch.charCodeAt(0), + ); + + 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 verifySignatureHelper({ + 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 verifySignatureHelper({ + cosePublicKey: coseMap, + signature, + data, + }); + expect(result).toBe(true); + }); +}); +/* eslint-enable n/no-unsupported-features/node-builtins */ diff --git a/packages/passkey-controller/tsconfig.build.json b/packages/passkey-controller/tsconfig.build.json new file mode 100644 index 00000000000..bf5cd863599 --- /dev/null +++ b/packages/passkey-controller/tsconfig.build.json @@ -0,0 +1,20 @@ +{ + "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..265a56cb300 --- /dev/null +++ b/packages/passkey-controller/tsconfig.json @@ -0,0 +1,18 @@ +{ + "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 9e0811957fe..2c7d962f651 100644 --- a/teams.json +++ b/teams.json @@ -47,6 +47,7 @@ "metamask/preferences-controller": "team-core-platform", "metamask/rate-limit-controller": "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 8fa8236e1b1..9f527dc52c5 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -151,6 +151,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/yarn.lock b/yarn.lock index 356f499c473..0feaa855d10 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2385,6 +2385,13 @@ __metadata: languageName: node linkType: hard +"@levischuck/tiny-cbor@npm:^0.2.2": + version: 0.2.11 + resolution: "@levischuck/tiny-cbor@npm:0.2.11" + checksum: 10/b278004882fc9153b6337f04591a8c95471369d4a2eed1ef9c715c12ddb1a6a5bc85d7ef1d45a9757eaac9b9da8f98d99a44dfdf7162c901a82f8bc8fb7add82 + 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" @@ -4422,6 +4429,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.2.2" + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/utils": "npm:^11.9.0" + "@noble/ciphers": "npm:^1.3.0" + "@noble/curves": "npm:^1.8.0" + "@noble/hashes": "npm:^1.8.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^27.5.2" + deepmerge: "npm:^4.2.2" + jest: "npm:^27.5.1" + jest-environment-node: "npm:^27.5.1" + ts-jest: "npm:^27.1.5" + typedoc: "npm:^0.24.8" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/permission-controller@npm:^12.1.0, @metamask/permission-controller@npm:^12.1.1, @metamask/permission-controller@npm:^12.2.0, @metamask/permission-controller@workspace:packages/permission-controller": version: 0.0.0-use.local resolution: "@metamask/permission-controller@workspace:packages/permission-controller" @@ -5325,7 +5356,7 @@ __metadata: languageName: node linkType: hard -"@noble/curves@npm:^1.2.0, @noble/curves@npm:^1.8.1, @noble/curves@npm:^1.9.2": +"@noble/curves@npm:^1.2.0, @noble/curves@npm:^1.8.0, @noble/curves@npm:^1.8.1, @noble/curves@npm:^1.9.2": version: 1.9.7 resolution: "@noble/curves@npm:1.9.7" dependencies: