From 271ca410f775cc437ac39ef818503ec6bd7845df Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Wed, 8 Apr 2026 22:28:14 +0700 Subject: [PATCH 01/44] feat: init passkey controller --- packages/passkey-controller/CHANGELOG.md | 20 ++ packages/passkey-controller/LICENSE | 21 ++ packages/passkey-controller/README.md | 11 + packages/passkey-controller/jest.config.js | 14 ++ .../passkey-controller/jest.environment.js | 17 ++ packages/passkey-controller/package.json | 73 +++++++ .../src/PasskeyController.test.ts | 107 ++++++++++ .../src/PasskeyController.ts | 128 +++++++++++ packages/passkey-controller/src/constants.ts | 3 + packages/passkey-controller/src/crypto.ts | 102 +++++++++ packages/passkey-controller/src/index.ts | 32 +++ .../src/orchestration.test.ts | 202 ++++++++++++++++++ .../passkey-controller/src/orchestration.ts | 120 +++++++++++ packages/passkey-controller/src/types.ts | 38 ++++ .../passkey-controller/tsconfig.build.json | 20 ++ packages/passkey-controller/tsconfig.json | 18 ++ packages/passkey-controller/typedoc.json | 7 + tsconfig.build.json | 3 + yarn.lock | 19 ++ 19 files changed, 955 insertions(+) create mode 100644 packages/passkey-controller/CHANGELOG.md create mode 100644 packages/passkey-controller/LICENSE create mode 100644 packages/passkey-controller/README.md create mode 100644 packages/passkey-controller/jest.config.js create mode 100644 packages/passkey-controller/jest.environment.js create mode 100644 packages/passkey-controller/package.json create mode 100644 packages/passkey-controller/src/PasskeyController.test.ts create mode 100644 packages/passkey-controller/src/PasskeyController.ts create mode 100644 packages/passkey-controller/src/constants.ts create mode 100644 packages/passkey-controller/src/crypto.ts create mode 100644 packages/passkey-controller/src/index.ts create mode 100644 packages/passkey-controller/src/orchestration.test.ts create mode 100644 packages/passkey-controller/src/orchestration.ts create mode 100644 packages/passkey-controller/src/types.ts create mode 100644 packages/passkey-controller/tsconfig.build.json create mode 100644 packages/passkey-controller/tsconfig.json create mode 100644 packages/passkey-controller/typedoc.json diff --git a/packages/passkey-controller/CHANGELOG.md b/packages/passkey-controller/CHANGELOG.md new file mode 100644 index 00000000000..37ed4435c08 --- /dev/null +++ b/packages/passkey-controller/CHANGELOG.md @@ -0,0 +1,20 @@ +# 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 for passkey state management +- Orchestration functions for passkey enrollment and unlock (adaptive PRF + userHandle) +- Crypto utilities (HKDF-SHA256, AES-256-GCM) for password encryption/decryption + +### Changed + +- Store `PasskeyRecord.credentialId` as standard base64 (not base64url). + +[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..171c1962da3 --- /dev/null +++ b/packages/passkey-controller/README.md @@ -0,0 +1,11 @@ +# @metamask/passkey-controller + +Controller and utilities for passkey-based wallet unlock with adaptive PRF + userHandle key derivation. + +## Installation + +`yarn add @metamask/passkey-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/passkey-controller/jest.config.js b/packages/passkey-controller/jest.config.js new file mode 100644 index 00000000000..1dbe6a6c174 --- /dev/null +++ b/packages/passkey-controller/jest.config.js @@ -0,0 +1,14 @@ +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + displayName, + testEnvironment: '/jest.environment.js', + coverageThreshold: { + global: { branches: 100, functions: 100, lines: 100, statements: 100 }, + }, +}); diff --git a/packages/passkey-controller/jest.environment.js b/packages/passkey-controller/jest.environment.js new file mode 100644 index 00000000000..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..80bce2fecba --- /dev/null +++ b/packages/passkey-controller/package.json @@ -0,0 +1,73 @@ +{ + "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": { + "@metamask/base-controller": "^9.0.0", + "@metamask/messenger": "^0.3.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..adc1c1ec022 --- /dev/null +++ b/packages/passkey-controller/src/PasskeyController.test.ts @@ -0,0 +1,107 @@ +import { Messenger } from '@metamask/messenger'; + +import { + PasskeyController, + getDefaultPasskeyControllerState, +} from './PasskeyController'; +import type { PasskeyControllerMessenger } from './PasskeyController'; +import type { PasskeyRecord } from './types'; + +function getMessenger(): PasskeyControllerMessenger { + return new Messenger({ namespace: 'PasskeyController' }); +} + +const mockRecord: PasskeyRecord = { + credentialId: 'dGVzdA==', + derivationMethod: 'userHandle', + wrappedEncryptionKey: 'Y2lwaGVydGV4dA==', + iv: 'aXZpdg==', +}; + +describe('PasskeyController', () => { + describe('getDefaultPasskeyControllerState', () => { + it('returns null passkeyRecord', () => { + const state = getDefaultPasskeyControllerState(); + expect(state.passkeyRecord).toBeNull(); + }); + }); + + describe('constructor', () => { + it('initializes with default state', () => { + const controller = new PasskeyController({ messenger: getMessenger() }); + expect(controller.state.passkeyRecord).toBeNull(); + }); + + it('initializes with provided state', () => { + const controller = new PasskeyController({ + messenger: getMessenger(), + state: { passkeyRecord: mockRecord }, + }); + expect(controller.state.passkeyRecord).toStrictEqual(mockRecord); + }); + }); + + describe('setPasskeyRecord', () => { + it('updates state with record', () => { + const controller = new PasskeyController({ messenger: getMessenger() }); + controller.setPasskeyRecord(mockRecord); + expect(controller.state.passkeyRecord).toStrictEqual(mockRecord); + }); + }); + + describe('getPasskeyRecord', () => { + it('returns null when no record set', () => { + const controller = new PasskeyController({ messenger: getMessenger() }); + expect(controller.getPasskeyRecord()).toBeNull(); + }); + + it('returns record after set', () => { + const controller = new PasskeyController({ messenger: getMessenger() }); + controller.setPasskeyRecord(mockRecord); + expect(controller.getPasskeyRecord()).toStrictEqual(mockRecord); + }); + }); + + describe('isPasskeyEnrolled', () => { + it('returns false when no record', () => { + const controller = new PasskeyController({ messenger: getMessenger() }); + expect(controller.isPasskeyEnrolled()).toBe(false); + }); + + it('returns true after enrollment', () => { + const controller = new PasskeyController({ messenger: getMessenger() }); + controller.setPasskeyRecord(mockRecord); + expect(controller.isPasskeyEnrolled()).toBe(true); + }); + }); + + describe('removePasskey', () => { + it('clears the record', () => { + const controller = new PasskeyController({ + messenger: getMessenger(), + state: { passkeyRecord: mockRecord }, + }); + expect(controller.isPasskeyEnrolled()).toBe(true); + controller.removePasskey(); + expect(controller.isPasskeyEnrolled()).toBe(false); + expect(controller.getPasskeyRecord()).toBeNull(); + }); + }); + + describe('messenger actions', () => { + it('registers method action handlers', () => { + const messenger = getMessenger(); + const controller = new PasskeyController({ messenger }); + expect(controller.getPasskeyRecord()).toBeNull(); + + messenger.call('PasskeyController:setPasskeyRecord', mockRecord); + const result = messenger.call('PasskeyController:getPasskeyRecord'); + expect(result).toStrictEqual(mockRecord); + + expect(messenger.call('PasskeyController:isPasskeyEnrolled')).toBe(true); + + messenger.call('PasskeyController:removePasskey'); + expect(messenger.call('PasskeyController:isPasskeyEnrolled')).toBe(false); + }); + }); +}); diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts new file mode 100644 index 00000000000..466c680833c --- /dev/null +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -0,0 +1,128 @@ +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; + +import { controllerName } from './constants'; +import type { PasskeyRecord } from './types'; + +export type PasskeyControllerState = { + passkeyRecord: PasskeyRecord | null; +}; + +const passkeyControllerMetadata = { + passkeyRecord: { + persist: true, + includeInDebugSnapshot: false, + includeInStateLogs: false, + usedInUi: true, + }, +} satisfies StateMetadata; + +export function getDefaultPasskeyControllerState(): PasskeyControllerState { + return { passkeyRecord: null }; +} + +const MESSENGER_EXPOSED_METHODS = [ + 'setPasskeyRecord', + 'getPasskeyRecord', + 'isPasskeyEnrolled', + 'removePasskey', +] as const; + +export type PasskeyControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + PasskeyControllerState +>; + +export type PasskeyControllerSetPasskeyRecordAction = { + type: `${typeof controllerName}:setPasskeyRecord`; + handler: PasskeyController['setPasskeyRecord']; +}; + +export type PasskeyControllerGetPasskeyRecordAction = { + type: `${typeof controllerName}:getPasskeyRecord`; + handler: PasskeyController['getPasskeyRecord']; +}; + +export type PasskeyControllerIsPasskeyEnrolledAction = { + type: `${typeof controllerName}:isPasskeyEnrolled`; + handler: PasskeyController['isPasskeyEnrolled']; +}; + +export type PasskeyControllerRemovePasskeyAction = { + type: `${typeof controllerName}:removePasskey`; + handler: PasskeyController['removePasskey']; +}; + +export type PasskeyControllerMethodActions = + | PasskeyControllerSetPasskeyRecordAction + | PasskeyControllerGetPasskeyRecordAction + | PasskeyControllerIsPasskeyEnrolledAction + | PasskeyControllerRemovePasskeyAction; + +export type PasskeyControllerActions = + | PasskeyControllerGetStateAction + | PasskeyControllerMethodActions; + +export type PasskeyControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + PasskeyControllerState +>; + +export type PasskeyControllerEvents = PasskeyControllerStateChangeEvent; + +export type PasskeyControllerMessenger = Messenger< + typeof controllerName, + PasskeyControllerActions, + PasskeyControllerEvents +>; + +export class PasskeyController extends BaseController< + typeof controllerName, + PasskeyControllerState, + PasskeyControllerMessenger +> { + constructor({ + messenger, + state, + }: { + messenger: PasskeyControllerMessenger; + state?: Partial; + }) { + super({ + messenger, + metadata: passkeyControllerMetadata, + name: controllerName, + state: { ...getDefaultPasskeyControllerState(), ...state }, + }); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + setPasskeyRecord(record: PasskeyRecord): void { + this.update((state) => { + state.passkeyRecord = record; + }); + } + + getPasskeyRecord(): PasskeyRecord | null { + return this.state.passkeyRecord; + } + + isPasskeyEnrolled(): boolean { + return this.state.passkeyRecord !== null; + } + + removePasskey(): void { + this.update((state) => { + state.passkeyRecord = null; + }); + } +} diff --git a/packages/passkey-controller/src/constants.ts b/packages/passkey-controller/src/constants.ts new file mode 100644 index 00000000000..bc572293534 --- /dev/null +++ b/packages/passkey-controller/src/constants.ts @@ -0,0 +1,3 @@ +export const controllerName = 'PasskeyController'; + +export const PASSKEY_HKDF_INFO = 'MetaMask-passkey-password-encryption-v1'; diff --git a/packages/passkey-controller/src/crypto.ts b/packages/passkey-controller/src/crypto.ts new file mode 100644 index 00000000000..6c7e5545500 --- /dev/null +++ b/packages/passkey-controller/src/crypto.ts @@ -0,0 +1,102 @@ +import { PASSKEY_HKDF_INFO } from './constants'; +import type { + CredentialCreationResult, + PasskeyDerivationMethod, +} from './types'; + +/* + * Base64 via `btoa`/`atob` keeps this package free of Node's `Buffer`, which is + * not guaranteed in browsers, extension workers, or React Native unless polyfilled. + * (Profile-sync uses `Buffer.from` in Node-oriented code.) + */ +export function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +export function base64ToArrayBuffer(base64: string): ArrayBuffer { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} + +export function selectDerivationMethod( + result: CredentialCreationResult, +): PasskeyDerivationMethod { + if ( + result.prfFirst !== undefined && + new Uint8Array(result.prfFirst).byteLength > 0 + ) { + return 'prf'; + } + return 'userHandle'; +} + +export async function deriveWrappingKey( + ikm: ArrayBuffer, + credentialId: ArrayBuffer, +): Promise { + const rawKey = await globalThis.crypto.subtle.importKey( + 'raw', + ikm, + { name: 'HKDF' }, + false, + ['deriveKey'], + ); + + return globalThis.crypto.subtle.deriveKey( + { + name: 'HKDF', + hash: 'SHA-256', + salt: credentialId, + info: new TextEncoder().encode(PASSKEY_HKDF_INFO), + }, + rawKey, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); +} + +export async function wrapKey( + encryptionKey: string, + wrappingKey: CryptoKey, +): Promise<{ ciphertext: string; iv: string }> { + const iv = globalThis.crypto.getRandomValues(new Uint8Array(12)); + const encoded = new TextEncoder().encode(encryptionKey); + + const ciphertextBuffer = await globalThis.crypto.subtle.encrypt( + { name: 'AES-GCM', iv, tagLength: 128 }, + wrappingKey, + encoded, + ); + + return { + ciphertext: arrayBufferToBase64(ciphertextBuffer), + iv: arrayBufferToBase64(iv.buffer), + }; +} + +export async function unwrapKey( + ciphertext: string, + iv: string, + wrappingKey: CryptoKey, +): Promise { + const ciphertextBuffer = base64ToArrayBuffer(ciphertext); + const ivBuffer = new Uint8Array(base64ToArrayBuffer(iv)); + + const plaintext = await globalThis.crypto.subtle.decrypt( + { name: 'AES-GCM', iv: ivBuffer, tagLength: 128 }, + wrappingKey, + ciphertextBuffer, + ); + + return new TextDecoder().decode(plaintext); +} diff --git a/packages/passkey-controller/src/index.ts b/packages/passkey-controller/src/index.ts new file mode 100644 index 00000000000..0d175b3d56b --- /dev/null +++ b/packages/passkey-controller/src/index.ts @@ -0,0 +1,32 @@ +export { + PasskeyController, + getDefaultPasskeyControllerState, +} from './PasskeyController'; +export type { + PasskeyControllerState, + PasskeyControllerMessenger, + PasskeyControllerActions, + PasskeyControllerEvents, + PasskeyControllerGetStateAction, + PasskeyControllerStateChangeEvent, + PasskeyControllerMethodActions, + PasskeyControllerSetPasskeyRecordAction, + PasskeyControllerGetPasskeyRecordAction, + PasskeyControllerIsPasskeyEnrolledAction, + PasskeyControllerRemovePasskeyAction, +} from './PasskeyController'; +export { + prepareCreationParams, + buildPasskeyRecord, + prepareAssertionParams, + unwrapEncryptionKeyFromAssertion, +} from './orchestration'; +export type { + PasskeyRecord, + PasskeyDerivationMethod, + CredentialCreationResult, + AssertionResult, + CreationParams, + AssertionParams, +} from './types'; +export { PASSKEY_HKDF_INFO, controllerName } from './constants'; diff --git a/packages/passkey-controller/src/orchestration.test.ts b/packages/passkey-controller/src/orchestration.test.ts new file mode 100644 index 00000000000..49c3a31cbb8 --- /dev/null +++ b/packages/passkey-controller/src/orchestration.test.ts @@ -0,0 +1,202 @@ +import { webcrypto } from 'node:crypto'; + +import { + prepareCreationParams, + buildPasskeyRecord, + prepareAssertionParams, + unwrapEncryptionKeyFromAssertion, +} from './orchestration'; +import type { CredentialCreationResult, AssertionResult } from './types'; + +describe('orchestration', () => { + describe('prepareCreationParams', () => { + it('returns 64-byte userHandle and 32-byte prfSalt', () => { + const params = prepareCreationParams(); + expect(params.userHandle).toBeInstanceOf(Uint8Array); + expect(params.userHandle.byteLength).toBe(64); + expect(params.prfSalt).toBeInstanceOf(Uint8Array); + expect(params.prfSalt.byteLength).toBe(32); + }); + + it('returns different values on each call', () => { + const a = prepareCreationParams(); + const b = prepareCreationParams(); + expect(Buffer.from(a.userHandle)).not.toStrictEqual( + Buffer.from(b.userHandle), + ); + }); + }); + + describe('buildPasskeyRecord + unwrapEncryptionKeyFromAssertion (round-trip)', () => { + const vaultEncryptionKey = + 'eyJhbGciOiJBMjU2R0NNIiwidHlwIjoiSldFIn0.mock-vault-key-serialized'; + const vaultSalt = 'someSaltValue123'; + + it('round-trips via userHandle path', async () => { + const params = prepareCreationParams(); + const ceremonyResult: CredentialCreationResult = { + credentialId: new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]), + userHandle: params.userHandle, + prfEnabled: false, + }; + + const record = await buildPasskeyRecord( + vaultEncryptionKey, + vaultSalt, + ceremonyResult, + params.prfSalt, + ); + + expect(record.derivationMethod).toBe('userHandle'); + expect(record.wrappedEncryptionKey.length).toBeGreaterThan(0); + expect(record.iv.length).toBeGreaterThan(0); + expect(record.encryptionSalt).toBe(vaultSalt); + expect(record.prfSalt).toBeUndefined(); + + const assertionResult: AssertionResult = { + userHandle: params.userHandle.buffer, + }; + + const unwrapped = await unwrapEncryptionKeyFromAssertion( + record, + assertionResult, + ); + expect(unwrapped).toBe(vaultEncryptionKey); + }); + + it('round-trips via PRF path when prfEnabled is false but prfFirst is present', async () => { + const params = prepareCreationParams(); + const prfOutput = webcrypto.getRandomValues(new Uint8Array(32)); + + const ceremonyResult: CredentialCreationResult = { + credentialId: new Uint8Array([10, 20, 30, 40]), + userHandle: params.userHandle, + prfEnabled: false, + prfFirst: prfOutput.buffer, + }; + + const record = await buildPasskeyRecord( + vaultEncryptionKey, + vaultSalt, + ceremonyResult, + params.prfSalt, + ); + + expect(record.derivationMethod).toBe('prf'); + expect(record.prfSalt).toBeDefined(); + + const assertionResult: AssertionResult = { + prfFirst: prfOutput.buffer, + }; + + const unwrapped = await unwrapEncryptionKeyFromAssertion( + record, + assertionResult, + ); + expect(unwrapped).toBe(vaultEncryptionKey); + }); + + it('round-trips via PRF path', async () => { + const params = prepareCreationParams(); + const prfOutput = webcrypto.getRandomValues(new Uint8Array(32)); + + const ceremonyResult: CredentialCreationResult = { + credentialId: new Uint8Array([10, 20, 30, 40]), + userHandle: params.userHandle, + prfEnabled: true, + prfFirst: prfOutput.buffer, + }; + + const record = await buildPasskeyRecord( + vaultEncryptionKey, + vaultSalt, + ceremonyResult, + params.prfSalt, + ); + + expect(record.derivationMethod).toBe('prf'); + expect(record.prfSalt).toBeDefined(); + expect(record.prfSalt?.length).toBeGreaterThan(0); + expect(record.encryptionSalt).toBe(vaultSalt); + + const assertionResult: AssertionResult = { + prfFirst: prfOutput.buffer, + }; + + const unwrapped = await unwrapEncryptionKeyFromAssertion( + record, + assertionResult, + ); + expect(unwrapped).toBe(vaultEncryptionKey); + }); + + it('throws when assertion is missing key material', async () => { + const params = prepareCreationParams(); + const ceremonyResult: CredentialCreationResult = { + credentialId: new Uint8Array([1, 2, 3]), + userHandle: params.userHandle, + prfEnabled: false, + }; + + const record = await buildPasskeyRecord( + vaultEncryptionKey, + vaultSalt, + ceremonyResult, + params.prfSalt, + ); + + const emptyAssertion: AssertionResult = {}; + + await expect( + unwrapEncryptionKeyFromAssertion(record, emptyAssertion), + ).rejects.toThrow('Passkey assertion missing required key material'); + }); + }); + + describe('prepareAssertionParams', () => { + it('extracts params for userHandle record', async () => { + const params = prepareCreationParams(); + const ceremonyResult: CredentialCreationResult = { + credentialId: new Uint8Array([5, 6, 7]), + userHandle: params.userHandle, + prfEnabled: false, + }; + + const record = await buildPasskeyRecord( + 'test-key', + 'test-salt', + ceremonyResult, + params.prfSalt, + ); + + const assertionParams = prepareAssertionParams(record); + expect(assertionParams.credentialId).toBeInstanceOf(Uint8Array); + expect(assertionParams.usePrf).toBe(false); + expect(assertionParams.prfSalt).toBeUndefined(); + }); + + it('extracts params for PRF record', async () => { + const params = prepareCreationParams(); + const prfOutput = webcrypto.getRandomValues(new Uint8Array(32)); + + const ceremonyResult: CredentialCreationResult = { + credentialId: new Uint8Array([8, 9, 10]), + userHandle: params.userHandle, + prfEnabled: true, + prfFirst: prfOutput.buffer, + }; + + const record = await buildPasskeyRecord( + 'test-key', + 'test-salt', + ceremonyResult, + params.prfSalt, + ); + + const assertionParams = prepareAssertionParams(record); + expect(assertionParams.usePrf).toBe(true); + expect(assertionParams.prfSalt).toBeInstanceOf(Uint8Array); + expect(assertionParams.prfSalt?.byteLength).toBe(32); + }); + }); +}); diff --git a/packages/passkey-controller/src/orchestration.ts b/packages/passkey-controller/src/orchestration.ts new file mode 100644 index 00000000000..1ea90a101d7 --- /dev/null +++ b/packages/passkey-controller/src/orchestration.ts @@ -0,0 +1,120 @@ +import { + selectDerivationMethod, + deriveWrappingKey, + wrapKey, + unwrapKey, + arrayBufferToBase64, + base64ToArrayBuffer, +} from './crypto'; +import type { + PasskeyRecord, + CredentialCreationResult, + AssertionResult, + CreationParams, + AssertionParams, +} from './types'; + +/** + * Generate random userHandle (64 bytes) and prfSalt (32 bytes) for passkey creation. + * + * @returns Creation params for the platform ceremony adapter. + */ +export function prepareCreationParams(): CreationParams { + return { + userHandle: globalThis.crypto.getRandomValues(new Uint8Array(64)), + prfSalt: globalThis.crypto.getRandomValues(new Uint8Array(32)), + }; +} + +/** + * After passkey ceremony: derive wrapping key, wrap the vault encryption key, build record. + * + * @param encryptionKey - Serialized vault encryption key from KeyringController.exportEncryptionKey() + * @param encryptionSalt - Vault salt (from KeyringController state or vault JSON) + * @param ceremonyResult - Normalized output from the platform ceremony adapter + * @param prfSalt - PRF salt generated by prepareCreationParams() + * @returns PasskeyRecord to persist via PasskeyController.setPasskeyRecord() + */ +export async function buildPasskeyRecord( + encryptionKey: string, + encryptionSalt: string, + ceremonyResult: CredentialCreationResult, + prfSalt: Uint8Array, +): Promise { + const derivationMethod = selectDerivationMethod(ceremonyResult); + + const ikm: ArrayBuffer = + derivationMethod === 'prf' && ceremonyResult.prfFirst + ? ceremonyResult.prfFirst + : (ceremonyResult.userHandle.buffer as ArrayBuffer); + + const wrappingKey = await deriveWrappingKey( + ikm, + ceremonyResult.credentialId.buffer as ArrayBuffer, + ); + const { ciphertext, iv } = await wrapKey(encryptionKey, wrappingKey); + + const record: PasskeyRecord = { + credentialId: arrayBufferToBase64( + ceremonyResult.credentialId.buffer as ArrayBuffer, + ), + derivationMethod, + wrappedEncryptionKey: ciphertext, + iv, + encryptionSalt, + }; + + if (derivationMethod === 'prf') { + record.prfSalt = arrayBufferToBase64(prfSalt.buffer as ArrayBuffer); + } + + return record; +} + +/** + * Before unlock ceremony: extract params from stored record. + * + * @param record - Stored PasskeyRecord + * @returns Params for the platform ceremony adapter's getAssertion() + */ +export function prepareAssertionParams(record: PasskeyRecord): AssertionParams { + const credentialId = new Uint8Array(base64ToArrayBuffer(record.credentialId)); + return { + credentialId, + prfSalt: record.prfSalt + ? new Uint8Array(base64ToArrayBuffer(record.prfSalt)) + : undefined, + usePrf: record.derivationMethod === 'prf', + }; +} + +/** + * After passkey assertion: derive wrapping key, unwrap the vault encryption key. + * + * @param record - Stored PasskeyRecord + * @param assertionResult - Normalized output from the platform ceremony adapter + * @returns The serialized vault encryption key (pass to submitEncryptionKey) + */ +export async function unwrapEncryptionKeyFromAssertion( + record: PasskeyRecord, + assertionResult: AssertionResult, +): Promise { + let ikm: ArrayBuffer; + + if (record.derivationMethod === 'prf' && assertionResult.prfFirst) { + ikm = assertionResult.prfFirst; + } else if (assertionResult.userHandle) { + ikm = assertionResult.userHandle; + } else { + throw new Error('Passkey assertion missing required key material'); + } + + const credentialIdBytes = new Uint8Array( + base64ToArrayBuffer(record.credentialId), + ); + const wrappingKey = await deriveWrappingKey( + ikm, + credentialIdBytes.buffer as ArrayBuffer, + ); + return unwrapKey(record.wrappedEncryptionKey, record.iv, wrappingKey); +} diff --git a/packages/passkey-controller/src/types.ts b/packages/passkey-controller/src/types.ts new file mode 100644 index 00000000000..ed316f5b326 --- /dev/null +++ b/packages/passkey-controller/src/types.ts @@ -0,0 +1,38 @@ +export type PasskeyDerivationMethod = 'prf' | 'userHandle'; + +export type PasskeyRecord = { + /** base64-encoded credential ID */ + credentialId: string; + derivationMethod: PasskeyDerivationMethod; + /** base64 — vault encryption key wrapped with passkey-derived key */ + wrappedEncryptionKey: string; + /** base64 — AES-GCM IV for the wrapping operation */ + iv: string; + /** base64 — PRF eval salt (present when derivationMethod === 'prf') */ + prfSalt?: string; + /** Vault encryption salt at time of enrollment (needed for submitEncryptionKey) */ + encryptionSalt?: string; +}; + +export type CredentialCreationResult = { + credentialId: Uint8Array; + userHandle: Uint8Array; + prfEnabled: boolean; + prfFirst?: ArrayBuffer; +}; + +export type AssertionResult = { + userHandle?: ArrayBuffer | null; + prfFirst?: ArrayBuffer; +}; + +export type CreationParams = { + userHandle: Uint8Array; + prfSalt: Uint8Array; +}; + +export type AssertionParams = { + credentialId: Uint8Array; + prfSalt?: Uint8Array; + usePrf: boolean; +}; 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/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..2a94cfcd5bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4422,6 +4422,25 @@ __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: + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/messenger": "npm:^0.3.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" From f56e452f3449af7ab94bcdec7a6cb51fb2b48958 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Fri, 10 Apr 2026 11:08:37 +0700 Subject: [PATCH 02/44] refactor: passkey controller to return registration and authentication options --- packages/passkey-controller/CHANGELOG.md | 21 +- .../src/PasskeyController.test.ts | 421 +++++++++++++++--- .../src/PasskeyController.ts | 289 +++++++++--- packages/passkey-controller/src/constants.ts | 4 +- .../passkey-controller/src/crypto.test.ts | 36 ++ packages/passkey-controller/src/crypto.ts | 40 +- .../passkey-controller/src/encoding.test.ts | 42 ++ packages/passkey-controller/src/encoding.ts | 51 +++ packages/passkey-controller/src/index.ts | 27 +- .../src/orchestration.test.ts | 202 --------- .../passkey-controller/src/orchestration.ts | 120 ----- packages/passkey-controller/src/types.ts | 124 +++++- .../passkey-controller/src/webauthn.test.ts | 82 ++++ packages/passkey-controller/src/webauthn.ts | 42 ++ 14 files changed, 975 insertions(+), 526 deletions(-) create mode 100644 packages/passkey-controller/src/crypto.test.ts create mode 100644 packages/passkey-controller/src/encoding.test.ts create mode 100644 packages/passkey-controller/src/encoding.ts delete mode 100644 packages/passkey-controller/src/orchestration.test.ts delete mode 100644 packages/passkey-controller/src/orchestration.ts create mode 100644 packages/passkey-controller/src/webauthn.test.ts create mode 100644 packages/passkey-controller/src/webauthn.ts diff --git a/packages/passkey-controller/CHANGELOG.md b/packages/passkey-controller/CHANGELOG.md index 37ed4435c08..4ff516ce590 100644 --- a/packages/passkey-controller/CHANGELOG.md +++ b/packages/passkey-controller/CHANGELOG.md @@ -9,12 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- PasskeyController for passkey state management -- Orchestration functions for passkey enrollment and unlock (adaptive PRF + userHandle) -- Crypto utilities (HKDF-SHA256, AES-256-GCM) for password encryption/decryption - -### Changed - -- Store `PasskeyRecord.credentialId` as standard base64 (not base64url). +- Initial release of `@metamask/passkey-controller`, a controller and helper library for passkey-based wallet unlock (WebAuthn registration/authentication, optional PRF extension, and encrypted vault key wrapping). +- **`PasskeyController`** (`PasskeyController.ts`): extends `BaseController` with persisted `passkeyRecord` and in-memory registration/authentication sessions (challenge and PRF salt material are not part of controller `state`). + - `generatePasskeyRegistrationOptions` — starts registration, stores session data, returns WebAuthn creation options JSON (platform authenticator, `userVerification: required`, PRF `eval` with a random salt). + - `completePasskeyRegistration` — verifies the registration challenge in `clientDataJSON`, derives a wrapping key via HKDF (PRF output or `userHandle`), wraps the supplied vault encryption key with AES-GCM, and persists `PasskeyRecord`. + - `generatePasskeyAuthenticationOptions` — requires an enrolled passkey; stores an auth challenge and returns WebAuthn request options (including PRF `eval` when the stored record used PRF). + - `unwrapVaultEncryptionKey` — verifies the authentication challenge, derives the same wrapping key material, and unwraps the stored encryption key. + - `isPasskeyEnrolled` / `removePasskey` — enrollment status and clearing record plus in-memory sessions. + - Messenger registers `PasskeyController:isPasskeyEnrolled`; public typings are `PasskeyControllerState` and `PasskeyControllerMessenger`. +- **`getDefaultPasskeyControllerState`** for initializing controller state. +- **Crypto** (`crypto.ts`): `deriveWrappingKey` (HKDF-SHA256 → AES-256-GCM key), `wrapKey` / `unwrapKey` for the vault encryption string using `PASSKEY_HKDF_INFO` from `constants.ts`. +- **Encoding** (`encoding.ts`): `arrayBufferToBase64`, `base64ToArrayBuffer`, `bytesToBase64URL`, `decodeBase64UrlString`, `base64UrlStringToArrayBuffer` (WebAuthn base64url wire decoding for HKDF inputs). +- **WebAuthn** (`webauthn.ts`): `webauthnWireBinaryToBytes`, `verifyChallengeInClientData`. +- **Types** (`types.ts`): `PasskeyRecord`, registration/authentication options and response JSON shapes, PRF extension types, and related WebAuthn wire aliases. +- **Unit tests** for `PasskeyController`, `crypto`, `encoding`, and `webauthn`. [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/passkey-controller/src/PasskeyController.test.ts b/packages/passkey-controller/src/PasskeyController.test.ts index adc1c1ec022..e6365c94c8b 100644 --- a/packages/passkey-controller/src/PasskeyController.test.ts +++ b/packages/passkey-controller/src/PasskeyController.test.ts @@ -1,107 +1,410 @@ import { Messenger } from '@metamask/messenger'; +import { bytesToBase64URL } from './encoding'; import { - PasskeyController, getDefaultPasskeyControllerState, + PasskeyController, } from './PasskeyController'; import type { PasskeyControllerMessenger } from './PasskeyController'; -import type { PasskeyRecord } from './types'; +import type { + PasskeyAuthenticationResponse, + PasskeyRecord, + PasskeyRegistrationResponse, +} from './types'; + +function getPasskeyMessenger(): PasskeyControllerMessenger { + return new Messenger({ + namespace: 'PasskeyController', + }) as PasskeyControllerMessenger; +} + +function buildClientDataJSON( + type: 'webauthn.create' | 'webauthn.get', + challenge: string, +): string { + return bytesToBase64URL( + new TextEncoder().encode( + JSON.stringify({ type, challenge, origin: 'https://example.test' }), + ), + ); +} -function getMessenger(): PasskeyControllerMessenger { - return new Messenger({ namespace: 'PasskeyController' }); +function minimalRegistrationResponse( + challenge: string, + credentialId: string, + overrides?: Partial, +): PasskeyRegistrationResponse { + return { + id: credentialId, + rawId: credentialId, + response: { + clientDataJSON: buildClientDataJSON('webauthn.create', challenge), + attestationObject: bytesToBase64URL(new Uint8Array([0, 1, 2])), + }, + type: 'public-key', + ...overrides, + }; } -const mockRecord: PasskeyRecord = { - credentialId: 'dGVzdA==', - derivationMethod: 'userHandle', - wrappedEncryptionKey: 'Y2lwaGVydGV4dA==', - iv: 'aXZpdg==', -}; +function minimalAuthenticationResponse( + challenge: string, + credentialId: string, + userHandle?: string, + overrides?: Partial, +): PasskeyAuthenticationResponse { + return { + id: credentialId, + rawId: credentialId, + response: { + clientDataJSON: buildClientDataJSON('webauthn.get', challenge), + authenticatorData: bytesToBase64URL(new Uint8Array([0])), + signature: bytesToBase64URL(new Uint8Array([0])), + userHandle, + }, + type: 'public-key', + ...overrides, + }; +} describe('PasskeyController', () => { describe('getDefaultPasskeyControllerState', () => { it('returns null passkeyRecord', () => { - const state = getDefaultPasskeyControllerState(); - expect(state.passkeyRecord).toBeNull(); + expect(getDefaultPasskeyControllerState()).toStrictEqual({ + passkeyRecord: null, + }); }); }); describe('constructor', () => { - it('initializes with default state', () => { - const controller = new PasskeyController({ messenger: getMessenger() }); - expect(controller.state.passkeyRecord).toBeNull(); + it('merges partial initial state with defaults', () => { + const record: PasskeyRecord = { + credentialId: 'QUJDREVGR2hJSktM', + derivationMethod: 'userHandle', + wrappedEncryptionKey: 'YQ==', + iv: 'YWFhYWFhYWFhYQ==', + encryptionSalt: 'salt', + }; + const messenger = getPasskeyMessenger(); + const controller = new PasskeyController({ + messenger, + state: { passkeyRecord: record }, + }); + expect(controller.state.passkeyRecord).toStrictEqual(record); }); + }); - it('initializes with provided state', () => { + describe('isPasskeyEnrolled', () => { + it('returns false when no record is stored', () => { const controller = new PasskeyController({ - messenger: getMessenger(), - state: { passkeyRecord: mockRecord }, + messenger: getPasskeyMessenger(), }); - expect(controller.state.passkeyRecord).toStrictEqual(mockRecord); + expect(controller.isPasskeyEnrolled()).toBe(false); + }); + + it('is callable via messenger method action', () => { + const messenger = getPasskeyMessenger(); + const controller = new PasskeyController({ messenger }); + expect(messenger.call('PasskeyController:isPasskeyEnrolled')).toBe(false); + expect(controller.isPasskeyEnrolled()).toBe(false); }); }); - describe('setPasskeyRecord', () => { - it('updates state with record', () => { - const controller = new PasskeyController({ messenger: getMessenger() }); - controller.setPasskeyRecord(mockRecord); - expect(controller.state.passkeyRecord).toStrictEqual(mockRecord); + describe('generatePasskeyAuthenticationOptions', () => { + it('throws when passkey is not enrolled', () => { + const controller = new PasskeyController({ + messenger: getPasskeyMessenger(), + }); + expect(() => controller.generatePasskeyAuthenticationOptions()).toThrow( + 'Passkey is not enrolled', + ); }); }); - describe('getPasskeyRecord', () => { - it('returns null when no record set', () => { - const controller = new PasskeyController({ messenger: getMessenger() }); - expect(controller.getPasskeyRecord()).toBeNull(); + describe('generatePasskeyRegistrationOptions', () => { + it('returns options whose challenge matches a subsequent completion flow', async () => { + const controller = new PasskeyController({ + messenger: getPasskeyMessenger(), + }); + const options = controller.generatePasskeyRegistrationOptions({ + rp: { name: 'Test RP', id: 'example.com' }, + }); + expect(options.rp.name).toBe('Test RP'); + expect(options.rp.id).toBe('example.com'); + expect(options.challenge).toBeDefined(); + expect(options.user.id).toBeDefined(); + expect(options.extensions?.prf?.eval.first).toBeDefined(); + + const credentialId = 'QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo='; + + await controller.completePasskeyRegistration({ + registrationResponse: minimalRegistrationResponse( + options.challenge, + credentialId, + ), + encryptionKey: 'user-encryption-key-test', + encryptionSalt: 'enc-salt', + }); + + expect(controller.isPasskeyEnrolled()).toBe(true); + expect(controller.state.passkeyRecord?.credentialId).toBe(credentialId); + expect(controller.state.passkeyRecord?.derivationMethod).toBe( + 'userHandle', + ); + }); + }); + + describe('completePasskeyRegistration', () => { + it('throws when there is no active registration session', async () => { + const controller = new PasskeyController({ + messenger: getPasskeyMessenger(), + }); + await expect( + controller.completePasskeyRegistration({ + registrationResponse: minimalRegistrationResponse('x', 'y'), + encryptionKey: 'k', + encryptionSalt: 's', + }), + ).rejects.toThrow('No active passkey registration session'); }); - it('returns record after set', () => { - const controller = new PasskeyController({ messenger: getMessenger() }); - controller.setPasskeyRecord(mockRecord); - expect(controller.getPasskeyRecord()).toStrictEqual(mockRecord); + it('throws when challenge verification fails', async () => { + const controller = new PasskeyController({ + messenger: getPasskeyMessenger(), + }); + const options = controller.generatePasskeyRegistrationOptions(); + await expect( + controller.completePasskeyRegistration({ + registrationResponse: minimalRegistrationResponse( + 'wrong-challenge', + 'QUJD', + ), + encryptionKey: 'k', + encryptionSalt: 's', + }), + ).rejects.toThrow('Passkey registration challenge verification failed'); + expect(options.challenge).not.toBe('wrong-challenge'); + }); + + it('uses prf derivation when extension results include PRF output', async () => { + const controller = new PasskeyController({ + messenger: getPasskeyMessenger(), + }); + const options = controller.generatePasskeyRegistrationOptions(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(9)); + + await controller.completePasskeyRegistration({ + registrationResponse: minimalRegistrationResponse( + options.challenge, + 'UFJGRW5jcnlwdGlvbktleUlkMTI=', + { + clientExtensionResults: { + prf: { enabled: true, results: { first: prfFirst } }, + }, + }, + ), + encryptionKey: 'vault-key-prf-path', + encryptionSalt: 'salt-prf', + }); + + expect(controller.state.passkeyRecord?.derivationMethod).toBe('prf'); + expect(controller.state.passkeyRecord?.prfSalt).toBe( + options.extensions?.prf?.eval.first, + ); }); }); - describe('isPasskeyEnrolled', () => { - it('returns false when no record', () => { - const controller = new PasskeyController({ messenger: getMessenger() }); - expect(controller.isPasskeyEnrolled()).toBe(false); + describe('unwrapVaultEncryptionKey', () => { + it('throws when there is no authentication session', async () => { + const controller = new PasskeyController({ + messenger: getPasskeyMessenger(), + }); + await expect( + controller.unwrapVaultEncryptionKey( + minimalAuthenticationResponse('c', 'id', 'uh'), + ), + ).rejects.toThrow('No active passkey authentication session'); }); - it('returns true after enrollment', () => { - const controller = new PasskeyController({ messenger: getMessenger() }); - controller.setPasskeyRecord(mockRecord); - expect(controller.isPasskeyEnrolled()).toBe(true); + it('throws when challenge verification fails', async () => { + const controller = new PasskeyController({ + messenger: getPasskeyMessenger(), + }); + const regOpts = controller.generatePasskeyRegistrationOptions(); + await controller.completePasskeyRegistration({ + registrationResponse: minimalRegistrationResponse( + regOpts.challenge, + 'dmVyaWZ5Q3JlZA==', + ), + encryptionKey: 'k', + encryptionSalt: 's', + }); + const authOpts = controller.generatePasskeyAuthenticationOptions(); + + await expect( + controller.unwrapVaultEncryptionKey( + minimalAuthenticationResponse( + 'wrong-challenge', + 'dmVyaWZ5Q3JlZA==', + regOpts.user.id, + ), + ), + ).rejects.toThrow('Passkey authentication challenge verification failed'); + expect(authOpts.challenge).not.toBe('wrong-challenge'); + }); + + it('throws when userHandle derivation record lacks userHandle on assertion', async () => { + const controller = new PasskeyController({ + messenger: getPasskeyMessenger(), + }); + const regOpts = controller.generatePasskeyRegistrationOptions(); + await controller.completePasskeyRegistration({ + registrationResponse: minimalRegistrationResponse( + regOpts.challenge, + 'dXNlckhhbmRsZUlk', + ), + encryptionKey: 'k', + encryptionSalt: 's', + }); + const authOpts = controller.generatePasskeyAuthenticationOptions(); + + await expect( + controller.unwrapVaultEncryptionKey( + minimalAuthenticationResponse( + authOpts.challenge, + 'dXNlckhhbmRsZUlk', + undefined, + ), + ), + ).rejects.toThrow('Passkey assertion missing required key material'); + }); + + it('clears the authentication session after a successful unwrap', async () => { + const controller = new PasskeyController({ + messenger: getPasskeyMessenger(), + }); + const regOpts = controller.generatePasskeyRegistrationOptions(); + await controller.completePasskeyRegistration({ + registrationResponse: minimalRegistrationResponse( + regOpts.challenge, + 'c2Vzc2lvbkNsZWFy', + ), + encryptionKey: 'secret', + encryptionSalt: 's', + }); + const authOpts = controller.generatePasskeyAuthenticationOptions(); + await controller.unwrapVaultEncryptionKey( + minimalAuthenticationResponse( + authOpts.challenge, + 'c2Vzc2lvbkNsZWFy', + regOpts.user.id, + ), + ); + + await expect( + controller.unwrapVaultEncryptionKey( + minimalAuthenticationResponse( + 'x', + 'c2Vzc2lvbkNsZWFy', + regOpts.user.id, + ), + ), + ).rejects.toThrow('No active passkey authentication session'); }); }); - describe('removePasskey', () => { - it('clears the record', () => { + describe('registration and authentication round-trip (userHandle)', () => { + it('unwraps the same encryption key that was supplied at registration', async () => { const controller = new PasskeyController({ - messenger: getMessenger(), - state: { passkeyRecord: mockRecord }, + messenger: getPasskeyMessenger(), }); - expect(controller.isPasskeyEnrolled()).toBe(true); - controller.removePasskey(); - expect(controller.isPasskeyEnrolled()).toBe(false); - expect(controller.getPasskeyRecord()).toBeNull(); + const regOptions = controller.generatePasskeyRegistrationOptions(); + const credentialId = 'Um91bmR0cmlwQ3JlZA=='; + const encryptionKey = 'roundtrip-encryption-key-value'; + + await controller.completePasskeyRegistration({ + registrationResponse: minimalRegistrationResponse( + regOptions.challenge, + credentialId, + ), + encryptionKey, + encryptionSalt: 'roundtrip-salt', + }); + + const authOptions = controller.generatePasskeyAuthenticationOptions(); + const out = await controller.unwrapVaultEncryptionKey( + minimalAuthenticationResponse( + authOptions.challenge, + credentialId, + regOptions.user.id, + ), + ); + + expect(out).toBe(encryptionKey); + expect(controller.state.passkeyRecord).not.toBeNull(); }); }); - describe('messenger actions', () => { - it('registers method action handlers', () => { - const messenger = getMessenger(); - const controller = new PasskeyController({ messenger }); - expect(controller.getPasskeyRecord()).toBeNull(); + describe('registration and authentication round-trip (prf)', () => { + it('unwraps when auth response repeats the same PRF output', async () => { + const controller = new PasskeyController({ + messenger: getPasskeyMessenger(), + }); + const regOptions = controller.generatePasskeyRegistrationOptions(); + const credentialId = 'UFJGUm91bmR0cmlwSWQ='; + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + const encryptionKey = 'prf-roundtrip-key'; - messenger.call('PasskeyController:setPasskeyRecord', mockRecord); - const result = messenger.call('PasskeyController:getPasskeyRecord'); - expect(result).toStrictEqual(mockRecord); + await controller.completePasskeyRegistration({ + registrationResponse: minimalRegistrationResponse( + regOptions.challenge, + credentialId, + { + clientExtensionResults: { + prf: { results: { first: prfFirst } }, + }, + }, + ), + encryptionKey, + encryptionSalt: 'ps', + }); - expect(messenger.call('PasskeyController:isPasskeyEnrolled')).toBe(true); + const authOptions = controller.generatePasskeyAuthenticationOptions(); + const out = await controller.unwrapVaultEncryptionKey( + minimalAuthenticationResponse( + authOptions.challenge, + credentialId, + undefined, + { + clientExtensionResults: { + prf: { results: { first: prfFirst } }, + }, + }, + ), + ); - messenger.call('PasskeyController:removePasskey'); - expect(messenger.call('PasskeyController:isPasskeyEnrolled')).toBe(false); + expect(out).toBe(encryptionKey); + }); + }); + + describe('removePasskey', () => { + it('clears stored record and resets enrollment', async () => { + const controller = new PasskeyController({ + messenger: getPasskeyMessenger(), + }); + const opts = controller.generatePasskeyRegistrationOptions(); + await controller.completePasskeyRegistration({ + registrationResponse: minimalRegistrationResponse( + opts.challenge, + 'Y2xlYXI=', + ), + encryptionKey: 'k', + encryptionSalt: 's', + }); + expect(controller.isPasskeyEnrolled()).toBe(true); + + controller.removePasskey(); + expect(controller.isPasskeyEnrolled()).toBe(false); + expect(controller.state.passkeyRecord).toBeNull(); }); }); }); diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index 466c680833c..82b27fcf1ed 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -6,74 +6,50 @@ import type { import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; -import { controllerName } from './constants'; -import type { PasskeyRecord } from './types'; +import { deriveWrappingKey, unwrapKey, wrapKey } from './crypto'; +import { base64UrlStringToArrayBuffer, bytesToBase64URL } from './encoding'; +import type { + PasskeyAuthenticationResponse, + Base64URLString as PasskeyBase64URLString, + PasskeyRegistrationOptions, + PasskeyRecord, + PasskeyRegistrationSession, + PasskeyAuthenticationOptions, + PasskeyRegistrationResponse, + PasskeyAuthenticationSession, +} from './types'; +import { verifyChallengeInClientData } from './webauthn'; + +const controllerName = 'PasskeyController'; + +const MESSENGER_EXPOSED_METHODS = ['isPasskeyEnrolled'] as const; export type PasskeyControllerState = { passkeyRecord: PasskeyRecord | null; }; -const passkeyControllerMetadata = { - passkeyRecord: { - persist: true, - includeInDebugSnapshot: false, - includeInStateLogs: false, - usedInUi: true, - }, -} satisfies StateMetadata; - -export function getDefaultPasskeyControllerState(): PasskeyControllerState { - return { passkeyRecord: null }; -} - -const MESSENGER_EXPOSED_METHODS = [ - 'setPasskeyRecord', - 'getPasskeyRecord', - 'isPasskeyEnrolled', - 'removePasskey', -] as const; - -export type PasskeyControllerGetStateAction = ControllerGetStateAction< +type PasskeyControllerGetStateAction = ControllerGetStateAction< typeof controllerName, PasskeyControllerState >; -export type PasskeyControllerSetPasskeyRecordAction = { - type: `${typeof controllerName}:setPasskeyRecord`; - handler: PasskeyController['setPasskeyRecord']; -}; - -export type PasskeyControllerGetPasskeyRecordAction = { - type: `${typeof controllerName}:getPasskeyRecord`; - handler: PasskeyController['getPasskeyRecord']; -}; +type PasskeyControllerMessengerMethodActions = { + [Method in (typeof MESSENGER_EXPOSED_METHODS)[number]]: { + type: `${typeof controllerName}:${Method}`; + handler: PasskeyController[Method]; + }; +}[(typeof MESSENGER_EXPOSED_METHODS)[number]]; -export type PasskeyControllerIsPasskeyEnrolledAction = { - type: `${typeof controllerName}:isPasskeyEnrolled`; - handler: PasskeyController['isPasskeyEnrolled']; -}; - -export type PasskeyControllerRemovePasskeyAction = { - type: `${typeof controllerName}:removePasskey`; - handler: PasskeyController['removePasskey']; -}; - -export type PasskeyControllerMethodActions = - | PasskeyControllerSetPasskeyRecordAction - | PasskeyControllerGetPasskeyRecordAction - | PasskeyControllerIsPasskeyEnrolledAction - | PasskeyControllerRemovePasskeyAction; - -export type PasskeyControllerActions = +type PasskeyControllerActions = | PasskeyControllerGetStateAction - | PasskeyControllerMethodActions; + | PasskeyControllerMessengerMethodActions; -export type PasskeyControllerStateChangeEvent = ControllerStateChangeEvent< +type PasskeyControllerStateChangeEvent = ControllerStateChangeEvent< typeof controllerName, PasskeyControllerState >; -export type PasskeyControllerEvents = PasskeyControllerStateChangeEvent; +type PasskeyControllerEvents = PasskeyControllerStateChangeEvent; export type PasskeyControllerMessenger = Messenger< typeof controllerName, @@ -81,11 +57,30 @@ export type PasskeyControllerMessenger = Messenger< PasskeyControllerEvents >; +export function getDefaultPasskeyControllerState(): PasskeyControllerState { + return { passkeyRecord: null }; +} + +const passkeyControllerMetadata = { + passkeyRecord: { + persist: true, + includeInDebugSnapshot: false, + includeInStateLogs: false, + usedInUi: true, + }, +} satisfies StateMetadata; + export class PasskeyController extends BaseController< typeof controllerName, PasskeyControllerState, PasskeyControllerMessenger > { + /** In-memory registration ceremony (not persisted, not part of `state`). */ + #registrationSession: PasskeyRegistrationSession | null = null; + + /** In-memory authentication ceremony challenge (not persisted). */ + #authenticationSession: PasskeyAuthenticationSession | null = null; + constructor({ messenger, state, @@ -106,13 +101,13 @@ export class PasskeyController extends BaseController< ); } - setPasskeyRecord(record: PasskeyRecord): void { + #setPasskeyRecord(record: PasskeyRecord): void { this.update((state) => { state.passkeyRecord = record; }); } - getPasskeyRecord(): PasskeyRecord | null { + #getPasskeyRecord(): PasskeyRecord | null { return this.state.passkeyRecord; } @@ -121,8 +116,194 @@ export class PasskeyController extends BaseController< } removePasskey(): void { + this.#registrationSession = null; + this.#authenticationSession = null; this.update((state) => { state.passkeyRecord = null; }); } + + generatePasskeyRegistrationOptions(creationOptionsConfig?: { + rp?: { name?: string; id?: string }; + }): PasskeyRegistrationOptions { + const userHandle = bytesToBase64URL( + globalThis.crypto.getRandomValues(new Uint8Array(64)), + ); + const prfSalt = bytesToBase64URL( + globalThis.crypto.getRandomValues(new Uint8Array(32)), + ); + const challenge = bytesToBase64URL( + globalThis.crypto.getRandomValues(new Uint8Array(32)), + ); + + // store session in memory + this.#registrationSession = { userHandle, prfSalt, challenge }; + + const options: PasskeyRegistrationOptions = { + rp: { + name: creationOptionsConfig?.rp?.name ?? 'MetaMask', + id: creationOptionsConfig?.rp?.id, + }, + user: { + id: userHandle, + name: 'MetaMask User', + displayName: 'MetaMask', + }, + challenge, + pubKeyCredParams: [ + { alg: -7, type: 'public-key' }, + { alg: -257, type: 'public-key' }, + ], + authenticatorSelection: { + residentKey: 'preferred', + userVerification: 'required', + authenticatorAttachment: 'platform', + }, + extensions: { + prf: { eval: { first: prfSalt } }, + }, + }; + + return options; + } + + /** + * Starts passkey authentication: stores a random challenge in memory and returns WebAuthn request options. + * + * @returns Public key credential request options for `navigator.credentials.get`. + */ + generatePasskeyAuthenticationOptions(): PasskeyAuthenticationOptions { + const record = this.#getPasskeyRecord(); + if (!record) { + throw new Error('Passkey is not enrolled'); + } + + // generate challenge + const challenge = bytesToBase64URL( + globalThis.crypto.getRandomValues(new Uint8Array(32)), + ); + this.#authenticationSession = { challenge }; + + const options: PasskeyAuthenticationOptions = { + challenge, + allowCredentials: [{ type: 'public-key', id: record.credentialId }], + userVerification: 'required', + }; + + if (record.derivationMethod === 'prf' && record.prfSalt) { + options.extensions = { + prf: { eval: { first: record.prfSalt } }, + }; + } + + return options; + } + + async completePasskeyRegistration(input: { + registrationResponse: PasskeyRegistrationResponse; + encryptionKey: string; + encryptionSalt: string; + }): Promise { + const session = this.#registrationSession; + if (!session) { + throw new Error('No active passkey registration session'); + } + + // verify challenge + const { registrationResponse, encryptionKey, encryptionSalt } = input; + const ok = verifyChallengeInClientData( + registrationResponse.response.clientDataJSON, + session.challenge, + 'webauthn.create', + ); + if (!ok) { + throw new Error('Passkey registration challenge verification failed'); + } + const credentialId = registrationResponse.id; + const prf = registrationResponse.clientExtensionResults?.prf; + const prfFirst = prf?.results?.first; + const prfEnabled = + prf?.enabled === true || (prfFirst !== undefined && prfFirst.length > 0); + + // create wrapping key + const derivationMethod = prfEnabled ? 'prf' : 'userHandle'; + const ikm: ArrayBuffer = + derivationMethod === 'prf' + ? base64UrlStringToArrayBuffer(prfFirst as PasskeyBase64URLString) + : base64UrlStringToArrayBuffer(session.userHandle); + const wrappingKey = await deriveWrappingKey( + ikm, + base64UrlStringToArrayBuffer(credentialId), + ); + + // wrap encryption key + const { ciphertext, iv } = await wrapKey(encryptionKey, wrappingKey); + + // build passkey record + const record: PasskeyRecord = { + credentialId, + derivationMethod, + wrappedEncryptionKey: ciphertext, + iv, + encryptionSalt, + prfSalt: derivationMethod === 'prf' ? session.prfSalt : undefined, + }; + this.#setPasskeyRecord(record); + this.#registrationSession = null; + } + + /** + * Verifies the assertion against the in-memory auth challenge and stored record, then derives the vault encryption key. + * + * @param authenticationResponse - Authentication result JSON from the browser ceremony. + * @returns Serialized vault encryption key for `submitEncryptionKey` (or equivalent). + */ + async unwrapVaultEncryptionKey( + authenticationResponse: PasskeyAuthenticationResponse, + ): Promise { + const session = this.#authenticationSession; + if (!session) { + throw new Error('No active passkey authentication session'); + } + const record = this.#getPasskeyRecord(); + if (!record) { + throw new Error('Passkey is not enrolled'); + } + + // verify challenge + const ok = verifyChallengeInClientData( + authenticationResponse.response.clientDataJSON, + session.challenge, + 'webauthn.get', + ); + if (!ok) { + throw new Error('Passkey authentication challenge verification failed'); + } + const { userHandle } = authenticationResponse.response; + const prfFirst = + authenticationResponse.clientExtensionResults?.prf?.results?.first; + + // derive wrapping key + let ikm: ArrayBuffer; + if (record.derivationMethod === 'prf') { + ikm = base64UrlStringToArrayBuffer(prfFirst as PasskeyBase64URLString); + } else if (userHandle) { + ikm = base64UrlStringToArrayBuffer(userHandle); + } else { + throw new Error('Passkey assertion missing required key material'); + } + const wrappingKey = await deriveWrappingKey( + ikm, + base64UrlStringToArrayBuffer(record.credentialId), + ); + const encryptionKey = await unwrapKey( + record.wrappedEncryptionKey, + record.iv, + wrappingKey, + ); + + // clear session + this.#authenticationSession = null; + return encryptionKey; + } } diff --git a/packages/passkey-controller/src/constants.ts b/packages/passkey-controller/src/constants.ts index bc572293534..fb58260119c 100644 --- a/packages/passkey-controller/src/constants.ts +++ b/packages/passkey-controller/src/constants.ts @@ -1,3 +1 @@ -export const controllerName = 'PasskeyController'; - -export const PASSKEY_HKDF_INFO = 'MetaMask-passkey-password-encryption-v1'; +export const PASSKEY_HKDF_INFO = 'MetaMask-passkey-encryption-v1'; diff --git a/packages/passkey-controller/src/crypto.test.ts b/packages/passkey-controller/src/crypto.test.ts new file mode 100644 index 00000000000..89047cbbcb0 --- /dev/null +++ b/packages/passkey-controller/src/crypto.test.ts @@ -0,0 +1,36 @@ +import { deriveWrappingKey, unwrapKey, wrapKey } from './crypto'; + +describe('crypto', () => { + describe('wrapKey / unwrapKey', () => { + it('round-trips the encryption key with a derived wrapping key', async () => { + const ikm = new Uint8Array(32); + ikm.fill(11); + const credentialId = new Uint8Array(16); + credentialId.fill(22); + + const wrappingKey = await deriveWrappingKey( + ikm.buffer, + credentialId.buffer, + ); + const plaintext = 'vault-encryption-key-material'; + const { ciphertext, iv } = await wrapKey(plaintext, wrappingKey); + const recovered = await unwrapKey(ciphertext, iv, wrappingKey); + expect(recovered).toBe(plaintext); + }); + + it('fails unwrap when a different wrapping key is used', async () => { + const wrappingKeyA = await deriveWrappingKey( + new Uint8Array(32).fill(1).buffer, + new Uint8Array(8).fill(2).buffer, + ); + const wrappingKeyB = await deriveWrappingKey( + new Uint8Array(32).fill(3).buffer, + new Uint8Array(8).fill(4).buffer, + ); + const { ciphertext, iv } = await wrapKey('secret', wrappingKeyA); + await expect(unwrapKey(ciphertext, iv, wrappingKeyB)).rejects.toThrow( + 'The operation failed for an operation-specific reason', + ); + }); + }); +}); diff --git a/packages/passkey-controller/src/crypto.ts b/packages/passkey-controller/src/crypto.ts index 6c7e5545500..9da87c7b0d9 100644 --- a/packages/passkey-controller/src/crypto.ts +++ b/packages/passkey-controller/src/crypto.ts @@ -1,43 +1,5 @@ import { PASSKEY_HKDF_INFO } from './constants'; -import type { - CredentialCreationResult, - PasskeyDerivationMethod, -} from './types'; - -/* - * Base64 via `btoa`/`atob` keeps this package free of Node's `Buffer`, which is - * not guaranteed in browsers, extension workers, or React Native unless polyfilled. - * (Profile-sync uses `Buffer.from` in Node-oriented code.) - */ -export function arrayBufferToBase64(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - let binary = ''; - for (let i = 0; i < bytes.byteLength; i++) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary); -} - -export function base64ToArrayBuffer(base64: string): ArrayBuffer { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes.buffer; -} - -export function selectDerivationMethod( - result: CredentialCreationResult, -): PasskeyDerivationMethod { - if ( - result.prfFirst !== undefined && - new Uint8Array(result.prfFirst).byteLength > 0 - ) { - return 'prf'; - } - return 'userHandle'; -} +import { arrayBufferToBase64, base64ToArrayBuffer } from './encoding'; export async function deriveWrappingKey( ikm: ArrayBuffer, diff --git a/packages/passkey-controller/src/encoding.test.ts b/packages/passkey-controller/src/encoding.test.ts new file mode 100644 index 00000000000..f1e936746a8 --- /dev/null +++ b/packages/passkey-controller/src/encoding.test.ts @@ -0,0 +1,42 @@ +import { + arrayBufferToBase64, + base64ToArrayBuffer, + base64UrlStringToArrayBuffer, + bytesToBase64URL, + decodeBase64UrlString, +} from './encoding'; + +describe('encoding', () => { + describe('bytesToBase64URL / decodeBase64UrlString', () => { + it('round-trips random bytes', () => { + const input = new Uint8Array([0, 255, 128, 1, 2, 3]); + const encoded = bytesToBase64URL(input); + expect(encoded).not.toMatch(/[+/=]/u); + expect(decodeBase64UrlString(encoded)).toStrictEqual(input); + }); + + it('round-trips empty input', () => { + const encoded = bytesToBase64URL(new Uint8Array(0)); + expect(decodeBase64UrlString(encoded)).toStrictEqual(new Uint8Array(0)); + }); + }); + + describe('arrayBufferToBase64 / base64ToArrayBuffer', () => { + it('round-trips binary data', () => { + const bytes = new Uint8Array([10, 20, 30, 40, 50]); + const b64 = arrayBufferToBase64(bytes.buffer); + const out = new Uint8Array(base64ToArrayBuffer(b64)); + expect(out).toStrictEqual(bytes); + }); + }); + + describe('base64UrlStringToArrayBuffer', () => { + it('decodes base64url produced by bytesToBase64URL', () => { + const input = new Uint8Array([0, 255, 1, 2]); + const wire = bytesToBase64URL(input); + expect(new Uint8Array(base64UrlStringToArrayBuffer(wire))).toStrictEqual( + input, + ); + }); + }); +}); diff --git a/packages/passkey-controller/src/encoding.ts b/packages/passkey-controller/src/encoding.ts new file mode 100644 index 00000000000..d91d29a8bb2 --- /dev/null +++ b/packages/passkey-controller/src/encoding.ts @@ -0,0 +1,51 @@ +export function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +export function base64ToArrayBuffer(base64: string): ArrayBuffer { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} + +export function bytesToBase64URL(bytes: Uint8Array): string { + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + const base64 = btoa(binary); + return base64.replace(/\+/gu, '-').replace(/\//gu, '_').replace(/[=]+$/u, ''); +} + +export function decodeBase64UrlString(input: string): Uint8Array { + let base64 = input.replace(/-/gu, '+').replace(/_/gu, '/'); + const pad = base64.length % 4; + if (pad !== 0) { + base64 += '='.repeat(4 - pad); + } + const binary = atob(base64); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + out[i] = binary.charCodeAt(i); + } + return out; +} + +/** + * Decodes a WebAuthn base64url wire string (or padded base64) into an `ArrayBuffer`. + * + * @param input - Base64url-encoded bytes (may use `-` / `_` and omit padding). + * @returns The decoded bytes as an `ArrayBuffer`. + */ +export function base64UrlStringToArrayBuffer(input: string): ArrayBuffer { + const bytes = decodeBase64UrlString(input); + return new Uint8Array(bytes).buffer; +} diff --git a/packages/passkey-controller/src/index.ts b/packages/passkey-controller/src/index.ts index 0d175b3d56b..6f2e6ab1b00 100644 --- a/packages/passkey-controller/src/index.ts +++ b/packages/passkey-controller/src/index.ts @@ -5,28 +5,7 @@ export { export type { PasskeyControllerState, PasskeyControllerMessenger, - PasskeyControllerActions, - PasskeyControllerEvents, - PasskeyControllerGetStateAction, - PasskeyControllerStateChangeEvent, - PasskeyControllerMethodActions, - PasskeyControllerSetPasskeyRecordAction, - PasskeyControllerGetPasskeyRecordAction, - PasskeyControllerIsPasskeyEnrolledAction, - PasskeyControllerRemovePasskeyAction, } from './PasskeyController'; -export { - prepareCreationParams, - buildPasskeyRecord, - prepareAssertionParams, - unwrapEncryptionKeyFromAssertion, -} from './orchestration'; -export type { - PasskeyRecord, - PasskeyDerivationMethod, - CredentialCreationResult, - AssertionResult, - CreationParams, - AssertionParams, -} from './types'; -export { PASSKEY_HKDF_INFO, controllerName } from './constants'; +export * from './encoding'; +export type * from './types'; +export * from './webauthn'; diff --git a/packages/passkey-controller/src/orchestration.test.ts b/packages/passkey-controller/src/orchestration.test.ts deleted file mode 100644 index 49c3a31cbb8..00000000000 --- a/packages/passkey-controller/src/orchestration.test.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { webcrypto } from 'node:crypto'; - -import { - prepareCreationParams, - buildPasskeyRecord, - prepareAssertionParams, - unwrapEncryptionKeyFromAssertion, -} from './orchestration'; -import type { CredentialCreationResult, AssertionResult } from './types'; - -describe('orchestration', () => { - describe('prepareCreationParams', () => { - it('returns 64-byte userHandle and 32-byte prfSalt', () => { - const params = prepareCreationParams(); - expect(params.userHandle).toBeInstanceOf(Uint8Array); - expect(params.userHandle.byteLength).toBe(64); - expect(params.prfSalt).toBeInstanceOf(Uint8Array); - expect(params.prfSalt.byteLength).toBe(32); - }); - - it('returns different values on each call', () => { - const a = prepareCreationParams(); - const b = prepareCreationParams(); - expect(Buffer.from(a.userHandle)).not.toStrictEqual( - Buffer.from(b.userHandle), - ); - }); - }); - - describe('buildPasskeyRecord + unwrapEncryptionKeyFromAssertion (round-trip)', () => { - const vaultEncryptionKey = - 'eyJhbGciOiJBMjU2R0NNIiwidHlwIjoiSldFIn0.mock-vault-key-serialized'; - const vaultSalt = 'someSaltValue123'; - - it('round-trips via userHandle path', async () => { - const params = prepareCreationParams(); - const ceremonyResult: CredentialCreationResult = { - credentialId: new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]), - userHandle: params.userHandle, - prfEnabled: false, - }; - - const record = await buildPasskeyRecord( - vaultEncryptionKey, - vaultSalt, - ceremonyResult, - params.prfSalt, - ); - - expect(record.derivationMethod).toBe('userHandle'); - expect(record.wrappedEncryptionKey.length).toBeGreaterThan(0); - expect(record.iv.length).toBeGreaterThan(0); - expect(record.encryptionSalt).toBe(vaultSalt); - expect(record.prfSalt).toBeUndefined(); - - const assertionResult: AssertionResult = { - userHandle: params.userHandle.buffer, - }; - - const unwrapped = await unwrapEncryptionKeyFromAssertion( - record, - assertionResult, - ); - expect(unwrapped).toBe(vaultEncryptionKey); - }); - - it('round-trips via PRF path when prfEnabled is false but prfFirst is present', async () => { - const params = prepareCreationParams(); - const prfOutput = webcrypto.getRandomValues(new Uint8Array(32)); - - const ceremonyResult: CredentialCreationResult = { - credentialId: new Uint8Array([10, 20, 30, 40]), - userHandle: params.userHandle, - prfEnabled: false, - prfFirst: prfOutput.buffer, - }; - - const record = await buildPasskeyRecord( - vaultEncryptionKey, - vaultSalt, - ceremonyResult, - params.prfSalt, - ); - - expect(record.derivationMethod).toBe('prf'); - expect(record.prfSalt).toBeDefined(); - - const assertionResult: AssertionResult = { - prfFirst: prfOutput.buffer, - }; - - const unwrapped = await unwrapEncryptionKeyFromAssertion( - record, - assertionResult, - ); - expect(unwrapped).toBe(vaultEncryptionKey); - }); - - it('round-trips via PRF path', async () => { - const params = prepareCreationParams(); - const prfOutput = webcrypto.getRandomValues(new Uint8Array(32)); - - const ceremonyResult: CredentialCreationResult = { - credentialId: new Uint8Array([10, 20, 30, 40]), - userHandle: params.userHandle, - prfEnabled: true, - prfFirst: prfOutput.buffer, - }; - - const record = await buildPasskeyRecord( - vaultEncryptionKey, - vaultSalt, - ceremonyResult, - params.prfSalt, - ); - - expect(record.derivationMethod).toBe('prf'); - expect(record.prfSalt).toBeDefined(); - expect(record.prfSalt?.length).toBeGreaterThan(0); - expect(record.encryptionSalt).toBe(vaultSalt); - - const assertionResult: AssertionResult = { - prfFirst: prfOutput.buffer, - }; - - const unwrapped = await unwrapEncryptionKeyFromAssertion( - record, - assertionResult, - ); - expect(unwrapped).toBe(vaultEncryptionKey); - }); - - it('throws when assertion is missing key material', async () => { - const params = prepareCreationParams(); - const ceremonyResult: CredentialCreationResult = { - credentialId: new Uint8Array([1, 2, 3]), - userHandle: params.userHandle, - prfEnabled: false, - }; - - const record = await buildPasskeyRecord( - vaultEncryptionKey, - vaultSalt, - ceremonyResult, - params.prfSalt, - ); - - const emptyAssertion: AssertionResult = {}; - - await expect( - unwrapEncryptionKeyFromAssertion(record, emptyAssertion), - ).rejects.toThrow('Passkey assertion missing required key material'); - }); - }); - - describe('prepareAssertionParams', () => { - it('extracts params for userHandle record', async () => { - const params = prepareCreationParams(); - const ceremonyResult: CredentialCreationResult = { - credentialId: new Uint8Array([5, 6, 7]), - userHandle: params.userHandle, - prfEnabled: false, - }; - - const record = await buildPasskeyRecord( - 'test-key', - 'test-salt', - ceremonyResult, - params.prfSalt, - ); - - const assertionParams = prepareAssertionParams(record); - expect(assertionParams.credentialId).toBeInstanceOf(Uint8Array); - expect(assertionParams.usePrf).toBe(false); - expect(assertionParams.prfSalt).toBeUndefined(); - }); - - it('extracts params for PRF record', async () => { - const params = prepareCreationParams(); - const prfOutput = webcrypto.getRandomValues(new Uint8Array(32)); - - const ceremonyResult: CredentialCreationResult = { - credentialId: new Uint8Array([8, 9, 10]), - userHandle: params.userHandle, - prfEnabled: true, - prfFirst: prfOutput.buffer, - }; - - const record = await buildPasskeyRecord( - 'test-key', - 'test-salt', - ceremonyResult, - params.prfSalt, - ); - - const assertionParams = prepareAssertionParams(record); - expect(assertionParams.usePrf).toBe(true); - expect(assertionParams.prfSalt).toBeInstanceOf(Uint8Array); - expect(assertionParams.prfSalt?.byteLength).toBe(32); - }); - }); -}); diff --git a/packages/passkey-controller/src/orchestration.ts b/packages/passkey-controller/src/orchestration.ts deleted file mode 100644 index 1ea90a101d7..00000000000 --- a/packages/passkey-controller/src/orchestration.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { - selectDerivationMethod, - deriveWrappingKey, - wrapKey, - unwrapKey, - arrayBufferToBase64, - base64ToArrayBuffer, -} from './crypto'; -import type { - PasskeyRecord, - CredentialCreationResult, - AssertionResult, - CreationParams, - AssertionParams, -} from './types'; - -/** - * Generate random userHandle (64 bytes) and prfSalt (32 bytes) for passkey creation. - * - * @returns Creation params for the platform ceremony adapter. - */ -export function prepareCreationParams(): CreationParams { - return { - userHandle: globalThis.crypto.getRandomValues(new Uint8Array(64)), - prfSalt: globalThis.crypto.getRandomValues(new Uint8Array(32)), - }; -} - -/** - * After passkey ceremony: derive wrapping key, wrap the vault encryption key, build record. - * - * @param encryptionKey - Serialized vault encryption key from KeyringController.exportEncryptionKey() - * @param encryptionSalt - Vault salt (from KeyringController state or vault JSON) - * @param ceremonyResult - Normalized output from the platform ceremony adapter - * @param prfSalt - PRF salt generated by prepareCreationParams() - * @returns PasskeyRecord to persist via PasskeyController.setPasskeyRecord() - */ -export async function buildPasskeyRecord( - encryptionKey: string, - encryptionSalt: string, - ceremonyResult: CredentialCreationResult, - prfSalt: Uint8Array, -): Promise { - const derivationMethod = selectDerivationMethod(ceremonyResult); - - const ikm: ArrayBuffer = - derivationMethod === 'prf' && ceremonyResult.prfFirst - ? ceremonyResult.prfFirst - : (ceremonyResult.userHandle.buffer as ArrayBuffer); - - const wrappingKey = await deriveWrappingKey( - ikm, - ceremonyResult.credentialId.buffer as ArrayBuffer, - ); - const { ciphertext, iv } = await wrapKey(encryptionKey, wrappingKey); - - const record: PasskeyRecord = { - credentialId: arrayBufferToBase64( - ceremonyResult.credentialId.buffer as ArrayBuffer, - ), - derivationMethod, - wrappedEncryptionKey: ciphertext, - iv, - encryptionSalt, - }; - - if (derivationMethod === 'prf') { - record.prfSalt = arrayBufferToBase64(prfSalt.buffer as ArrayBuffer); - } - - return record; -} - -/** - * Before unlock ceremony: extract params from stored record. - * - * @param record - Stored PasskeyRecord - * @returns Params for the platform ceremony adapter's getAssertion() - */ -export function prepareAssertionParams(record: PasskeyRecord): AssertionParams { - const credentialId = new Uint8Array(base64ToArrayBuffer(record.credentialId)); - return { - credentialId, - prfSalt: record.prfSalt - ? new Uint8Array(base64ToArrayBuffer(record.prfSalt)) - : undefined, - usePrf: record.derivationMethod === 'prf', - }; -} - -/** - * After passkey assertion: derive wrapping key, unwrap the vault encryption key. - * - * @param record - Stored PasskeyRecord - * @param assertionResult - Normalized output from the platform ceremony adapter - * @returns The serialized vault encryption key (pass to submitEncryptionKey) - */ -export async function unwrapEncryptionKeyFromAssertion( - record: PasskeyRecord, - assertionResult: AssertionResult, -): Promise { - let ikm: ArrayBuffer; - - if (record.derivationMethod === 'prf' && assertionResult.prfFirst) { - ikm = assertionResult.prfFirst; - } else if (assertionResult.userHandle) { - ikm = assertionResult.userHandle; - } else { - throw new Error('Passkey assertion missing required key material'); - } - - const credentialIdBytes = new Uint8Array( - base64ToArrayBuffer(record.credentialId), - ); - const wrappingKey = await deriveWrappingKey( - ikm, - credentialIdBytes.buffer as ArrayBuffer, - ); - return unwrapKey(record.wrappedEncryptionKey, record.iv, wrappingKey); -} diff --git a/packages/passkey-controller/src/types.ts b/packages/passkey-controller/src/types.ts index ed316f5b326..e271be23f49 100644 --- a/packages/passkey-controller/src/types.ts +++ b/packages/passkey-controller/src/types.ts @@ -1,38 +1,126 @@ export type PasskeyDerivationMethod = 'prf' | 'userHandle'; export type PasskeyRecord = { - /** base64-encoded credential ID */ - credentialId: string; + credentialId: Base64URLString; derivationMethod: PasskeyDerivationMethod; /** base64 — vault encryption key wrapped with passkey-derived key */ wrappedEncryptionKey: string; /** base64 — AES-GCM IV for the wrapping operation */ iv: string; /** base64 — PRF eval salt (present when derivationMethod === 'prf') */ - prfSalt?: string; + prfSalt?: Base64URLString; /** Vault encryption salt at time of enrollment (needed for submitEncryptionKey) */ encryptionSalt?: string; }; -export type CredentialCreationResult = { - credentialId: Uint8Array; - userHandle: Uint8Array; - prfEnabled: boolean; - prfFirst?: ArrayBuffer; +/** In-memory registration session: creation material + RP challenge bytes. */ +export type PasskeyRegistrationSession = { + userHandle: Base64URLString; + prfSalt: Base64URLString; + challenge: Base64URLString; }; -export type AssertionResult = { - userHandle?: ArrayBuffer | null; - prfFirst?: ArrayBuffer; +export type PasskeyAuthenticationSession = { + challenge: Base64URLString; }; -export type CreationParams = { - userHandle: Uint8Array; - prfSalt: Uint8Array; +/** WebAuthn JSON wire types (base64url for binary fields). */ + +export type Base64URLString = string; + +export type PublicKeyCredentialRpEntityJSON = { + id?: string; + name: string; +}; + +export type PublicKeyCredentialUserEntityJSON = { + id: Base64URLString; + name: string; + displayName: string; +}; + +export type PublicKeyCredentialParametersJSON = { + alg: number; + type: 'public-key'; +}; + +export type AuthenticatorSelectionCriteriaJSON = { + authenticatorAttachment?: string; + requireResidentKey?: boolean; + residentKey?: string; + userVerification?: string; +}; + +export type PublicKeyCredentialDescriptorJSON = { + id: Base64URLString; + type: 'public-key'; +}; + +export type PrfEvalExtensionJSON = { + eval: { + first: Base64URLString; + }; +}; + +export type AuthenticationExtensionsClientInputsJSON = { + prf?: PrfEvalExtensionJSON; +}; + +export type PasskeyRegistrationOptions = { + rp: PublicKeyCredentialRpEntityJSON; + user: PublicKeyCredentialUserEntityJSON; + challenge: Base64URLString; + pubKeyCredParams: PublicKeyCredentialParametersJSON[]; + timeout?: number; + excludeCredentials?: PublicKeyCredentialDescriptorJSON[]; + authenticatorSelection?: AuthenticatorSelectionCriteriaJSON; + attestation?: string; + extensions?: AuthenticationExtensionsClientInputsJSON; +}; + +export type AuthenticatorTransportFuture = string; + +export type PasskeyRegistrationResponse = { + id: Base64URLString; + rawId: Base64URLString; + response: { + clientDataJSON: Base64URLString; + attestationObject: Base64URLString; + transports?: AuthenticatorTransportFuture[]; + }; + type: string; + clientExtensionResults?: { + prf?: { + enabled?: boolean; + results?: { first?: Base64URLString }; + }; + }; +}; + +export type PasskeyAuthenticationOptions = { + challenge: Base64URLString; + timeout?: number; + rpId?: string; + allowCredentials?: PublicKeyCredentialDescriptorJSON[]; + userVerification?: string; + hints?: string[]; + extensions?: AuthenticationExtensionsClientInputsJSON; }; -export type AssertionParams = { - credentialId: Uint8Array; - prfSalt?: Uint8Array; - usePrf: boolean; +export type PasskeyAuthenticationResponse = { + id: Base64URLString; + rawId: Base64URLString; + response: { + clientDataJSON: Base64URLString; + authenticatorData: Base64URLString; + signature: Base64URLString; + userHandle?: Base64URLString; + }; + type: string; + clientExtensionResults?: { + prf?: { + enabled?: boolean; + results?: { first?: Base64URLString }; + }; + }; }; diff --git a/packages/passkey-controller/src/webauthn.test.ts b/packages/passkey-controller/src/webauthn.test.ts new file mode 100644 index 00000000000..61852eeed10 --- /dev/null +++ b/packages/passkey-controller/src/webauthn.test.ts @@ -0,0 +1,82 @@ +import { bytesToBase64URL } from './encoding'; +import { + verifyChallengeInClientData, + webauthnWireBinaryToBytes, +} from './webauthn'; + +function clientDataToWire( + type: 'webauthn.create' | 'webauthn.get', + challenge: string, +): string { + const json = JSON.stringify({ + type, + challenge, + origin: 'https://example.test', + }); + return bytesToBase64URL(new TextEncoder().encode(json)); +} + +describe('webauthn', () => { + describe('webauthnWireBinaryToBytes', () => { + it('accepts base64url string', () => { + const wire = bytesToBase64URL(new TextEncoder().encode('hello')); + expect(new TextDecoder().decode(webauthnWireBinaryToBytes(wire))).toBe( + 'hello', + ); + }); + + it('accepts ArrayBuffer', () => { + const arrayBuffer = new Uint8Array([1, 2, 3]).buffer; + expect(webauthnWireBinaryToBytes(arrayBuffer)).toStrictEqual( + new Uint8Array([1, 2, 3]), + ); + }); + + it('accepts Uint8Array view', () => { + const u8 = new Uint8Array([9, 8, 7]); + expect(webauthnWireBinaryToBytes(u8)).toStrictEqual(u8); + }); + + it('accepts numeric arrays as byte sequences', () => { + expect(webauthnWireBinaryToBytes([10, 20, 30])).toStrictEqual( + new Uint8Array([10, 20, 30]), + ); + }); + + it('throws on unsupported wire shape', () => { + expect(() => webauthnWireBinaryToBytes(123)).toThrow(TypeError); + }); + }); + + describe('verifyChallengeInClientData', () => { + it('returns true when type and challenge match', () => { + const challenge = 'test-challenge-b64url'; + const wire = clientDataToWire('webauthn.create', challenge); + expect( + verifyChallengeInClientData(wire, challenge, 'webauthn.create'), + ).toBe(true); + }); + + it('returns false when challenge differs', () => { + const wire = clientDataToWire('webauthn.get', 'expected'); + expect(verifyChallengeInClientData(wire, 'other', 'webauthn.get')).toBe( + false, + ); + }); + + it('returns false when type differs', () => { + const challenge = 'c'; + const wire = clientDataToWire('webauthn.create', challenge); + expect(verifyChallengeInClientData(wire, challenge, 'webauthn.get')).toBe( + false, + ); + }); + + it('returns false on invalid JSON', () => { + const wire = bytesToBase64URL(new TextEncoder().encode('not-json')); + expect(verifyChallengeInClientData(wire, 'x', 'webauthn.create')).toBe( + false, + ); + }); + }); +}); diff --git a/packages/passkey-controller/src/webauthn.ts b/packages/passkey-controller/src/webauthn.ts new file mode 100644 index 00000000000..c286acf5753 --- /dev/null +++ b/packages/passkey-controller/src/webauthn.ts @@ -0,0 +1,42 @@ +import { decodeBase64UrlString } from './encoding'; +import type { Base64URLString as PasskeyBase64URLString } from './types'; + +export function webauthnWireBinaryToBytes(wire: unknown): Uint8Array { + if (typeof wire === 'string') { + return decodeBase64UrlString(wire); + } + if (wire instanceof ArrayBuffer) { + return new Uint8Array(wire); + } + if (ArrayBuffer.isView(wire)) { + const view = wire; + return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); + } + if (Array.isArray(wire)) { + return Uint8Array.from(wire as number[]); + } + throw new TypeError( + 'webauthnWireBinaryToBytes: expected base64url string or binary buffer', + ); +} + +export function verifyChallengeInClientData( + clientDataJSONWire: unknown, + expectedChallenge: PasskeyBase64URLString, + expectedType: 'webauthn.create' | 'webauthn.get', +): boolean { + let parsed: { type?: unknown; challenge?: unknown }; + try { + const jsonBytes = webauthnWireBinaryToBytes(clientDataJSONWire); + const jsonText = new TextDecoder().decode(jsonBytes); + parsed = JSON.parse(jsonText) as { type?: unknown; challenge?: unknown }; + } catch { + return false; + } + + if (parsed.type !== expectedType || typeof parsed.challenge !== 'string') { + return false; + } + + return parsed.challenge === expectedChallenge; +} From 47b6bf9dc9c35e0ad512a956c809868ccf679470 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Fri, 10 Apr 2026 13:46:26 +0700 Subject: [PATCH 03/44] feat: update configuration of passkeys --- packages/passkey-controller/CHANGELOG.md | 7 +- .../src/PasskeyController.test.ts | 4 + .../src/PasskeyController.ts | 33 +++++---- packages/passkey-controller/src/constants.ts | 20 +++++ packages/passkey-controller/src/index.ts | 1 + packages/passkey-controller/src/types.ts | 73 ++++++++++++------- 6 files changed, 95 insertions(+), 43 deletions(-) diff --git a/packages/passkey-controller/CHANGELOG.md b/packages/passkey-controller/CHANGELOG.md index 4ff516ce590..13543e500c3 100644 --- a/packages/passkey-controller/CHANGELOG.md +++ b/packages/passkey-controller/CHANGELOG.md @@ -11,14 +11,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release of `@metamask/passkey-controller`, a controller and helper library for passkey-based wallet unlock (WebAuthn registration/authentication, optional PRF extension, and encrypted vault key wrapping). - **`PasskeyController`** (`PasskeyController.ts`): extends `BaseController` with persisted `passkeyRecord` and in-memory registration/authentication sessions (challenge and PRF salt material are not part of controller `state`). - - `generatePasskeyRegistrationOptions` — starts registration, stores session data, returns WebAuthn creation options JSON (platform authenticator, `userVerification: required`, PRF `eval` with a random salt). + - `generatePasskeyRegistrationOptions` — starts registration, stores session data, returns WebAuthn creation options JSON (platform authenticator, `userVerification: preferred`, `residentKey: preferred`, attestation `direct`, Ed25519 (-8) + ES256 (-7) + RS256 (-257), L3 credential `hints` default `client-device` then `hybrid`, PRF `eval` with a random salt). - `completePasskeyRegistration` — verifies the registration challenge in `clientDataJSON`, derives a wrapping key via HKDF (PRF output or `userHandle`), wraps the supplied vault encryption key with AES-GCM, and persists `PasskeyRecord`. - - `generatePasskeyAuthenticationOptions` — requires an enrolled passkey; stores an auth challenge and returns WebAuthn request options (including PRF `eval` when the stored record used PRF). + - `generatePasskeyAuthenticationOptions` — requires an enrolled passkey; stores an auth challenge and returns WebAuthn request options (`userVerification: preferred`, same default `hints`, plus PRF `eval` when the stored record used PRF). - `unwrapVaultEncryptionKey` — verifies the authentication challenge, derives the same wrapping key material, and unwraps the stored encryption key. - `isPasskeyEnrolled` / `removePasskey` — enrollment status and clearing record plus in-memory sessions. - Messenger registers `PasskeyController:isPasskeyEnrolled`; public typings are `PasskeyControllerState` and `PasskeyControllerMessenger`. - **`getDefaultPasskeyControllerState`** for initializing controller state. -- **Crypto** (`crypto.ts`): `deriveWrappingKey` (HKDF-SHA256 → AES-256-GCM key), `wrapKey` / `unwrapKey` for the vault encryption string using `PASSKEY_HKDF_INFO` from `constants.ts`. +- **Constants** (`constants.ts`): `PASSKEY_HKDF_INFO`, `PASSKEY_DEFAULT_CREDENTIAL_HINTS`. +- **Crypto** (`crypto.ts`): `deriveWrappingKey` (HKDF-SHA256 → AES-256-GCM key), `wrapKey` / `unwrapKey` for the vault encryption string using `PASSKEY_HKDF_INFO`. - **Encoding** (`encoding.ts`): `arrayBufferToBase64`, `base64ToArrayBuffer`, `bytesToBase64URL`, `decodeBase64UrlString`, `base64UrlStringToArrayBuffer` (WebAuthn base64url wire decoding for HKDF inputs). - **WebAuthn** (`webauthn.ts`): `webauthnWireBinaryToBytes`, `verifyChallengeInClientData`. - **Types** (`types.ts`): `PasskeyRecord`, registration/authentication options and response JSON shapes, PRF extension types, and related WebAuthn wire aliases. diff --git a/packages/passkey-controller/src/PasskeyController.test.ts b/packages/passkey-controller/src/PasskeyController.test.ts index e6365c94c8b..9fc0623f0c1 100644 --- a/packages/passkey-controller/src/PasskeyController.test.ts +++ b/packages/passkey-controller/src/PasskeyController.test.ts @@ -133,6 +133,10 @@ describe('PasskeyController', () => { expect(options.challenge).toBeDefined(); expect(options.user.id).toBeDefined(); expect(options.extensions?.prf?.eval.first).toBeDefined(); + expect(options.hints).toStrictEqual(['client-device', 'hybrid']); + expect(options.pubKeyCredParams.map((param) => param.alg)).toStrictEqual([ + -8, -7, -257, + ]); const credentialId = 'QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo='; diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index 82b27fcf1ed..9a456a72fbb 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -6,6 +6,7 @@ import type { import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; +import { COSEALG } from './constants'; import { deriveWrappingKey, unwrapKey, wrapKey } from './crypto'; import { base64UrlStringToArrayBuffer, bytesToBase64URL } from './encoding'; import type { @@ -115,17 +116,10 @@ export class PasskeyController extends BaseController< return this.state.passkeyRecord !== null; } - removePasskey(): void { - this.#registrationSession = null; - this.#authenticationSession = null; - this.update((state) => { - state.passkeyRecord = null; - }); - } - generatePasskeyRegistrationOptions(creationOptionsConfig?: { rp?: { name?: string; id?: string }; }): PasskeyRegistrationOptions { + // create registration session const userHandle = bytesToBase64URL( globalThis.crypto.getRandomValues(new Uint8Array(64)), ); @@ -135,10 +129,9 @@ export class PasskeyController extends BaseController< const challenge = bytesToBase64URL( globalThis.crypto.getRandomValues(new Uint8Array(32)), ); - - // store session in memory this.#registrationSession = { userHandle, prfSalt, challenge }; + // build registration options const options: PasskeyRegistrationOptions = { rp: { name: creationOptionsConfig?.rp?.name ?? 'MetaMask', @@ -151,14 +144,17 @@ export class PasskeyController extends BaseController< }, challenge, pubKeyCredParams: [ - { alg: -7, type: 'public-key' }, - { alg: -257, type: 'public-key' }, + { alg: COSEALG.EdDSA, type: 'public-key' }, + { alg: COSEALG.ES256, type: 'public-key' }, + { alg: COSEALG.RS256, type: 'public-key' }, ], authenticatorSelection: { residentKey: 'preferred', - userVerification: 'required', + userVerification: 'preferred', authenticatorAttachment: 'platform', }, + attestation: 'direct', + hints: ['client-device', 'hybrid'], extensions: { prf: { eval: { first: prfSalt } }, }, @@ -187,7 +183,8 @@ export class PasskeyController extends BaseController< const options: PasskeyAuthenticationOptions = { challenge, allowCredentials: [{ type: 'public-key', id: record.credentialId }], - userVerification: 'required', + userVerification: 'preferred', + hints: ['client-device', 'hybrid'], }; if (record.derivationMethod === 'prf' && record.prfSalt) { @@ -306,4 +303,12 @@ export class PasskeyController extends BaseController< this.#authenticationSession = null; return encryptionKey; } + + removePasskey(): void { + this.#registrationSession = null; + this.#authenticationSession = null; + this.update((state) => { + state.passkeyRecord = null; + }); + } } diff --git a/packages/passkey-controller/src/constants.ts b/packages/passkey-controller/src/constants.ts index fb58260119c..5967555b602 100644 --- a/packages/passkey-controller/src/constants.ts +++ b/packages/passkey-controller/src/constants.ts @@ -1 +1,21 @@ export const PASSKEY_HKDF_INFO = 'MetaMask-passkey-encryption-v1'; + +/** + * COSE Algorithms + * + * 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, +} diff --git a/packages/passkey-controller/src/index.ts b/packages/passkey-controller/src/index.ts index 6f2e6ab1b00..a1b9c37e380 100644 --- a/packages/passkey-controller/src/index.ts +++ b/packages/passkey-controller/src/index.ts @@ -6,6 +6,7 @@ export type { PasskeyControllerState, PasskeyControllerMessenger, } from './PasskeyController'; +export * from './constants'; export * from './encoding'; export type * from './types'; export * from './webauthn'; diff --git a/packages/passkey-controller/src/types.ts b/packages/passkey-controller/src/types.ts index e271be23f49..f949457ce3e 100644 --- a/packages/passkey-controller/src/types.ts +++ b/packages/passkey-controller/src/types.ts @@ -28,54 +28,75 @@ export type PasskeyAuthenticationSession = { export type Base64URLString = string; -export type PublicKeyCredentialRpEntityJSON = { +export type ResidentKeyRequirement = 'discouraged' | 'preferred' | 'required'; +export type UserVerificationRequirement = + | 'discouraged' + | 'preferred' + | 'required'; +export type AuthenticatorAttachment = 'cross-platform' | 'platform'; +export type AttestationConveyancePreference = + | 'direct' + | 'enterprise' + | 'indirect' + | 'none'; +export type PublicKeyCredentialHint = + | 'hybrid' + | 'security-key' + | 'client-device'; + +export type PublicKeyCredentialRpEntity = { id?: string; name: string; }; -export type PublicKeyCredentialUserEntityJSON = { +export type COSEAlgorithmIdentifier = number; +export type PublicKeyCredentialType = 'public-key'; + +export type PublicKeyCredentialParameters = { + alg: COSEAlgorithmIdentifier; + type: PublicKeyCredentialType; +}; + +export type PublicKeyCredentialUserEntity = { id: Base64URLString; name: string; displayName: string; }; -export type PublicKeyCredentialParametersJSON = { - alg: number; - type: 'public-key'; -}; - -export type AuthenticatorSelectionCriteriaJSON = { - authenticatorAttachment?: string; +export type AuthenticatorSelectionCriteria = { + authenticatorAttachment?: AuthenticatorAttachment; requireResidentKey?: boolean; - residentKey?: string; - userVerification?: string; + residentKey?: ResidentKeyRequirement; + userVerification?: UserVerificationRequirement; }; -export type PublicKeyCredentialDescriptorJSON = { +export type PublicKeyCredentialDescriptor = { id: Base64URLString; type: 'public-key'; }; -export type PrfEvalExtensionJSON = { +export type PrfEvalExtension = { eval: { first: Base64URLString; }; }; -export type AuthenticationExtensionsClientInputsJSON = { - prf?: PrfEvalExtensionJSON; +export type AuthenticationExtensionsClientInputs = { + prf?: PrfEvalExtension; }; export type PasskeyRegistrationOptions = { - rp: PublicKeyCredentialRpEntityJSON; - user: PublicKeyCredentialUserEntityJSON; + rp: PublicKeyCredentialRpEntity; + user: PublicKeyCredentialUserEntity; challenge: Base64URLString; - pubKeyCredParams: PublicKeyCredentialParametersJSON[]; + pubKeyCredParams: PublicKeyCredentialParameters[]; timeout?: number; - excludeCredentials?: PublicKeyCredentialDescriptorJSON[]; - authenticatorSelection?: AuthenticatorSelectionCriteriaJSON; - attestation?: string; - extensions?: AuthenticationExtensionsClientInputsJSON; + excludeCredentials?: PublicKeyCredentialDescriptor[]; + authenticatorSelection?: AuthenticatorSelectionCriteria; + attestation?: AttestationConveyancePreference; + /** WebAuthn L3 credential hints (`client-device`, `hybrid`, etc.). */ + hints?: string[]; + extensions?: AuthenticationExtensionsClientInputs; }; export type AuthenticatorTransportFuture = string; @@ -101,10 +122,10 @@ export type PasskeyAuthenticationOptions = { challenge: Base64URLString; timeout?: number; rpId?: string; - allowCredentials?: PublicKeyCredentialDescriptorJSON[]; - userVerification?: string; - hints?: string[]; - extensions?: AuthenticationExtensionsClientInputsJSON; + allowCredentials?: PublicKeyCredentialDescriptor[]; + userVerification?: UserVerificationRequirement; + hints?: PublicKeyCredentialHint[]; + extensions?: AuthenticationExtensionsClientInputs; }; export type PasskeyAuthenticationResponse = { From e3c0db5c67734c03bc1fbc0f7b7a93c1de1f5814 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Tue, 14 Apr 2026 17:47:11 +0700 Subject: [PATCH 04/44] feat: allow update encryption key --- packages/passkey-controller/CHANGELOG.md | 5 ++ .../src/PasskeyController.test.ts | 81 +++++++++++++++++ .../src/PasskeyController.ts | 86 +++++++++++++++++-- 3 files changed, 167 insertions(+), 5 deletions(-) diff --git a/packages/passkey-controller/CHANGELOG.md b/packages/passkey-controller/CHANGELOG.md index 13543e500c3..a3319f5ba31 100644 --- a/packages/passkey-controller/CHANGELOG.md +++ b/packages/passkey-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`PasskeyController.rewrapVaultEncryptionKeyAfterPasswordChange`** — verifies a WebAuthn authentication response, confirms the stored passkey wrap decrypts to the pre-rotation vault encryption key, then re-wraps and persists the record for a new serialized encryption key and encryption salt (e.g. after `KeyringController.changePassword`). - Initial release of `@metamask/passkey-controller`, a controller and helper library for passkey-based wallet unlock (WebAuthn registration/authentication, optional PRF extension, and encrypted vault key wrapping). - **`PasskeyController`** (`PasskeyController.ts`): extends `BaseController` with persisted `passkeyRecord` and in-memory registration/authentication sessions (challenge and PRF salt material are not part of controller `state`). - `generatePasskeyRegistrationOptions` — starts registration, stores session data, returns WebAuthn creation options JSON (platform authenticator, `userVerification: preferred`, `residentKey: preferred`, attestation `direct`, Ed25519 (-8) + ES256 (-7) + RS256 (-257), L3 credential `hints` default `client-device` then `hybrid`, PRF `eval` with a random salt). @@ -25,4 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Types** (`types.ts`): `PasskeyRecord`, registration/authentication options and response JSON shapes, PRF extension types, and related WebAuthn wire aliases. - **Unit tests** for `PasskeyController`, `crypto`, `encoding`, and `webauthn`. +### Changed + +- Refactored authentication handling so `unwrapVaultEncryptionKey` shares challenge verification and wrapping-key derivation with the new re-wrap path. + [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/passkey-controller/src/PasskeyController.test.ts b/packages/passkey-controller/src/PasskeyController.test.ts index 9fc0623f0c1..055765a9914 100644 --- a/packages/passkey-controller/src/PasskeyController.test.ts +++ b/packages/passkey-controller/src/PasskeyController.test.ts @@ -390,6 +390,87 @@ describe('PasskeyController', () => { }); }); + describe('rewrapVaultEncryptionKeyAfterPasswordChange', () => { + it('updates the passkey wrap when before/after encryption keys match the ceremony', async () => { + const controller = new PasskeyController({ + messenger: getPasskeyMessenger(), + }); + const regOptions = controller.generatePasskeyRegistrationOptions(); + const credentialId = 'UmV3cmFwQ3JlZGVudGlhbA=='; + const beforeKey = 'encryption-key-before-password'; + + await controller.completePasskeyRegistration({ + registrationResponse: minimalRegistrationResponse( + regOptions.challenge, + credentialId, + ), + encryptionKey: beforeKey, + encryptionSalt: 'salt-before', + }); + + const authOptions = controller.generatePasskeyAuthenticationOptions(); + const authResponse = minimalAuthenticationResponse( + authOptions.challenge, + credentialId, + regOptions.user.id, + ); + const afterKey = 'encryption-key-after-password'; + + await controller.changeVaultEncryptionKey({ + authenticationResponse: authResponse, + oldEncryptionKey: beforeKey, + newEncryptionKey: afterKey, + newEncryptionSalt: 'salt-after', + }); + + expect(controller.state.passkeyRecord?.encryptionSalt).toBe('salt-after'); + + const authOptions2 = controller.generatePasskeyAuthenticationOptions(); + const unwrapped = await controller.unwrapVaultEncryptionKey( + minimalAuthenticationResponse( + authOptions2.challenge, + credentialId, + regOptions.user.id, + ), + ); + expect(unwrapped).toBe(afterKey); + }); + + it('throws when the live vault key does not match the passkey unwrap', async () => { + const controller = new PasskeyController({ + messenger: getPasskeyMessenger(), + }); + const regOptions = controller.generatePasskeyRegistrationOptions(); + const credentialId = 'bWlzbWF0Y2hLZXk='; + + await controller.completePasskeyRegistration({ + registrationResponse: minimalRegistrationResponse( + regOptions.challenge, + credentialId, + ), + encryptionKey: 'actual-wrapped-key', + encryptionSalt: 's', + }); + + const authOptions = controller.generatePasskeyAuthenticationOptions(); + + await expect( + controller.changeVaultEncryptionKey({ + authenticationResponse: minimalAuthenticationResponse( + authOptions.challenge, + credentialId, + regOptions.user.id, + ), + oldEncryptionKey: 'wrong-expected-key', + newEncryptionKey: 'new-key', + newEncryptionSalt: 'new-salt', + }), + ).rejects.toThrow( + 'Passkey authentication does not match the current vault encryption key', + ); + }); + }); + describe('removePasskey', () => { it('clears stored record and resets enrollment', async () => { const controller = new PasskeyController({ diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index 9a456a72fbb..4f684cb5fc1 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -255,9 +255,16 @@ export class PasskeyController extends BaseController< * @param authenticationResponse - Authentication result JSON from the browser ceremony. * @returns Serialized vault encryption key for `submitEncryptionKey` (or equivalent). */ - async unwrapVaultEncryptionKey( + /** + * Verifies the WebAuthn authentication challenge and derives the wrapping key + * for the enrolled credential (same material as {@link unwrapVaultEncryptionKey}). + * + * @param authenticationResponse - Authentication result JSON from the browser ceremony. + * @returns Wrapping key and passkey record for unwrap/re-wrap. + */ + async #getWrappingKey( authenticationResponse: PasskeyAuthenticationResponse, - ): Promise { + ): Promise { const session = this.#authenticationSession; if (!session) { throw new Error('No active passkey authentication session'); @@ -267,7 +274,6 @@ export class PasskeyController extends BaseController< throw new Error('Passkey is not enrolled'); } - // verify challenge const ok = verifyChallengeInClientData( authenticationResponse.response.clientDataJSON, session.challenge, @@ -280,7 +286,6 @@ export class PasskeyController extends BaseController< const prfFirst = authenticationResponse.clientExtensionResults?.prf?.results?.first; - // derive wrapping key let ikm: ArrayBuffer; if (record.derivationMethod === 'prf') { ikm = base64UrlStringToArrayBuffer(prfFirst as PasskeyBase64URLString); @@ -293,17 +298,88 @@ export class PasskeyController extends BaseController< ikm, base64UrlStringToArrayBuffer(record.credentialId), ); + + return wrappingKey; + } + + async unwrapVaultEncryptionKey( + authenticationResponse: PasskeyAuthenticationResponse, + ): Promise { + const record = this.#getPasskeyRecord(); + if (!record) { + throw new Error('Passkey is not enrolled'); + } + const wrappingKey = await this.#getWrappingKey(authenticationResponse); const encryptionKey = await unwrapKey( record.wrappedEncryptionKey, record.iv, wrappingKey, ); - // clear session this.#authenticationSession = null; return encryptionKey; } + /** + * After the keyring vault encryption key has changed (e.g. wallet password change), + * re-wraps the new serialized encryption key with the same passkey-derived wrapping key + * from a verified WebAuthn authentication response. + * + * Verifies the auth challenge, checks that the stored passkey wrap still decrypts to + * `encryptionKeyBeforePasswordChange`, clears the auth session, then persists a new + * wrap for `encryptionKeyAfterPasswordChange` and `encryptionSaltAfterPasswordChange`. + * + * @param input - Re-wrap parameters. + * @param input.authenticationResponse - Authentication result JSON from the browser ceremony. + * @param input.oldEncryptionKey - Serialized vault encryption key before password change. + * @param input.newEncryptionKey - Serialized vault encryption key after password change. + * @param input.newEncryptionSalt - Keyring encryption salt after password change. + */ + async changeVaultEncryptionKey(input: { + authenticationResponse: PasskeyAuthenticationResponse; + oldEncryptionKey: string; + newEncryptionKey: string; + newEncryptionSalt: string; + }): Promise { + const { + authenticationResponse, + oldEncryptionKey, + newEncryptionKey, + newEncryptionSalt, + } = input; + + const record = this.#getPasskeyRecord(); + if (!record) { + throw new Error('Passkey is not enrolled'); + } + const wrappingKey = await this.#getWrappingKey(authenticationResponse); + + // TODO: why we need to do this? + const decryptedKey = await unwrapKey( + record.wrappedEncryptionKey, + record.iv, + wrappingKey, + ); + if (decryptedKey !== oldEncryptionKey) { + this.#authenticationSession = null; + throw new Error( + 'Passkey authentication does not match the current vault encryption key', + ); + } + + this.#authenticationSession = null; + + const { ciphertext, iv } = await wrapKey(newEncryptionKey, wrappingKey); + + const nextRecord: PasskeyRecord = { + ...record, + wrappedEncryptionKey: ciphertext, + iv, + encryptionSalt: newEncryptionSalt, + }; + this.#setPasskeyRecord(nextRecord); + } + removePasskey(): void { this.#registrationSession = null; this.#authenticationSession = null; From d5816f0d9f60c45b0bfc7c02bc02f741b183429c Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Wed, 15 Apr 2026 13:58:16 +0700 Subject: [PATCH 05/44] refactor: change method names and use crypto packages --- packages/passkey-controller/CHANGELOG.md | 15 +- packages/passkey-controller/package.json | 5 +- .../src/PasskeyController.test.ts | 179 +++++----- .../src/PasskeyController.ts | 309 ++++++++++-------- .../passkey-controller/src/crypto.test.ts | 35 +- packages/passkey-controller/src/crypto.ts | 99 +++--- .../passkey-controller/src/encoding.test.ts | 33 +- packages/passkey-controller/src/encoding.ts | 44 +-- packages/passkey-controller/src/types.ts | 30 +- packages/passkey-controller/src/webauthn.ts | 25 +- yarn.lock | 3 + 11 files changed, 364 insertions(+), 413 deletions(-) diff --git a/packages/passkey-controller/CHANGELOG.md b/packages/passkey-controller/CHANGELOG.md index a3319f5ba31..13691c5482c 100644 --- a/packages/passkey-controller/CHANGELOG.md +++ b/packages/passkey-controller/CHANGELOG.md @@ -9,13 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **`PasskeyController.rewrapVaultEncryptionKeyAfterPasswordChange`** — verifies a WebAuthn authentication response, confirms the stored passkey wrap decrypts to the pre-rotation vault encryption key, then re-wraps and persists the record for a new serialized encryption key and encryption salt (e.g. after `KeyringController.changePassword`). -- Initial release of `@metamask/passkey-controller`, a controller and helper library for passkey-based wallet unlock (WebAuthn registration/authentication, optional PRF extension, and encrypted vault key wrapping). +- **`PasskeyController.renewVaultKeyProtection`** — verifies a WebAuthn authentication response, confirms the currently protected vault key matches the pre-rotation value, then re-protects and persists the record for a new vault encryption key and encryption salt (e.g. after `KeyringController.changePassword`). +- Initial release of `@metamask/passkey-controller`, a controller and helper library for passkey-based wallet unlock (WebAuthn registration/authentication, optional PRF extension, and vault key protection). - **`PasskeyController`** (`PasskeyController.ts`): extends `BaseController` with persisted `passkeyRecord` and in-memory registration/authentication sessions (challenge and PRF salt material are not part of controller `state`). - - `generatePasskeyRegistrationOptions` — starts registration, stores session data, returns WebAuthn creation options JSON (platform authenticator, `userVerification: preferred`, `residentKey: preferred`, attestation `direct`, Ed25519 (-8) + ES256 (-7) + RS256 (-257), L3 credential `hints` default `client-device` then `hybrid`, PRF `eval` with a random salt). - - `completePasskeyRegistration` — verifies the registration challenge in `clientDataJSON`, derives a wrapping key via HKDF (PRF output or `userHandle`), wraps the supplied vault encryption key with AES-GCM, and persists `PasskeyRecord`. - - `generatePasskeyAuthenticationOptions` — requires an enrolled passkey; stores an auth challenge and returns WebAuthn request options (`userVerification: preferred`, same default `hints`, plus PRF `eval` when the stored record used PRF). - - `unwrapVaultEncryptionKey` — verifies the authentication challenge, derives the same wrapping key material, and unwraps the stored encryption key. + - `generateRegistrationOptions` — starts registration, stores session data, returns WebAuthn creation options JSON (platform authenticator, `userVerification: preferred`, `residentKey: preferred`, attestation `direct`, Ed25519 (-8) + ES256 (-7) + RS256 (-257), L3 credential `hints` default `client-device` then `hybrid`, PRF `eval` with a random salt). + - `protectVaultKeyWithPasskey` — verifies the registration challenge in `clientDataJSON`, derives a wrapping key via HKDF (PRF output or `userHandle`), protects the supplied vault encryption key with AES-GCM, and persists `PasskeyRecord`. + - `generateAuthenticationOptions` — requires an enrolled passkey; stores an auth challenge and returns WebAuthn request options (`userVerification: preferred`, same default `hints`, plus PRF `eval` when the stored record used PRF). + - `retrieveVaultKeyWithPasskey` — verifies the authentication challenge, derives the same wrapping key material, and retrieves the protected vault encryption key. - `isPasskeyEnrolled` / `removePasskey` — enrollment status and clearing record plus in-memory sessions. - Messenger registers `PasskeyController:isPasskeyEnrolled`; public typings are `PasskeyControllerState` and `PasskeyControllerMessenger`. - **`getDefaultPasskeyControllerState`** for initializing controller state. @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Refactored authentication handling so `unwrapVaultEncryptionKey` shares challenge verification and wrapping-key derivation with the new re-wrap path. +- Refactored authentication handling so `retrieveVaultKeyWithPasskey` shares challenge verification and wrapping-key derivation with the vault key protection renewal path. +- `encoding.ts` now uses `bytesToBase64` and `base64ToBytes` from `@metamask/utils` for base64 and base64url decode paths (URL-safe encoding still normalizes `-` / `_` and padding before decoding). [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/passkey-controller/package.json b/packages/passkey-controller/package.json index 80bce2fecba..c0391caed81 100644 --- a/packages/passkey-controller/package.json +++ b/packages/passkey-controller/package.json @@ -49,7 +49,10 @@ }, "dependencies": { "@metamask/base-controller": "^9.0.0", - "@metamask/messenger": "^0.3.0" + "@metamask/messenger": "^0.3.0", + "@metamask/utils": "^11.9.0", + "@noble/ciphers": "^1.3.0", + "@noble/hashes": "^1.8.0" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", diff --git a/packages/passkey-controller/src/PasskeyController.test.ts b/packages/passkey-controller/src/PasskeyController.test.ts index 055765a9914..465d1ab32bd 100644 --- a/packages/passkey-controller/src/PasskeyController.test.ts +++ b/packages/passkey-controller/src/PasskeyController.test.ts @@ -80,9 +80,8 @@ describe('PasskeyController', () => { const record: PasskeyRecord = { credentialId: 'QUJDREVGR2hJSktM', derivationMethod: 'userHandle', - wrappedEncryptionKey: 'YQ==', + encryptedVaultKey: 'YQ==', iv: 'YWFhYWFhYWFhYQ==', - encryptionSalt: 'salt', }; const messenger = getPasskeyMessenger(); const controller = new PasskeyController({ @@ -109,23 +108,23 @@ describe('PasskeyController', () => { }); }); - describe('generatePasskeyAuthenticationOptions', () => { + describe('generateAuthenticationOptions', () => { it('throws when passkey is not enrolled', () => { const controller = new PasskeyController({ messenger: getPasskeyMessenger(), }); - expect(() => controller.generatePasskeyAuthenticationOptions()).toThrow( + expect(() => controller.generateAuthenticationOptions()).toThrow( 'Passkey is not enrolled', ); }); }); - describe('generatePasskeyRegistrationOptions', () => { + describe('generateRegistrationOptions', () => { it('returns options whose challenge matches a subsequent completion flow', async () => { const controller = new PasskeyController({ messenger: getPasskeyMessenger(), }); - const options = controller.generatePasskeyRegistrationOptions({ + const options = controller.generateRegistrationOptions({ rp: { name: 'Test RP', id: 'example.com' }, }); expect(options.rp.name).toBe('Test RP'); @@ -140,13 +139,12 @@ describe('PasskeyController', () => { const credentialId = 'QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo='; - await controller.completePasskeyRegistration({ + await controller.protectVaultKeyWithPasskey({ registrationResponse: minimalRegistrationResponse( options.challenge, credentialId, ), - encryptionKey: 'user-encryption-key-test', - encryptionSalt: 'enc-salt', + vaultKey: 'user-encryption-key-test', }); expect(controller.isPasskeyEnrolled()).toBe(true); @@ -157,16 +155,15 @@ describe('PasskeyController', () => { }); }); - describe('completePasskeyRegistration', () => { + describe('protectVaultKeyWithPasskey', () => { it('throws when there is no active registration session', async () => { const controller = new PasskeyController({ messenger: getPasskeyMessenger(), }); await expect( - controller.completePasskeyRegistration({ + controller.protectVaultKeyWithPasskey({ registrationResponse: minimalRegistrationResponse('x', 'y'), - encryptionKey: 'k', - encryptionSalt: 's', + vaultKey: 'k', }), ).rejects.toThrow('No active passkey registration session'); }); @@ -175,15 +172,14 @@ describe('PasskeyController', () => { const controller = new PasskeyController({ messenger: getPasskeyMessenger(), }); - const options = controller.generatePasskeyRegistrationOptions(); + const options = controller.generateRegistrationOptions(); await expect( - controller.completePasskeyRegistration({ + controller.protectVaultKeyWithPasskey({ registrationResponse: minimalRegistrationResponse( 'wrong-challenge', 'QUJD', ), - encryptionKey: 'k', - encryptionSalt: 's', + vaultKey: 'k', }), ).rejects.toThrow('Passkey registration challenge verification failed'); expect(options.challenge).not.toBe('wrong-challenge'); @@ -193,10 +189,10 @@ describe('PasskeyController', () => { const controller = new PasskeyController({ messenger: getPasskeyMessenger(), }); - const options = controller.generatePasskeyRegistrationOptions(); + const options = controller.generateRegistrationOptions(); const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(9)); - await controller.completePasskeyRegistration({ + await controller.protectVaultKeyWithPasskey({ registrationResponse: minimalRegistrationResponse( options.challenge, 'UFJGRW5jcnlwdGlvbktleUlkMTI=', @@ -206,8 +202,7 @@ describe('PasskeyController', () => { }, }, ), - encryptionKey: 'vault-key-prf-path', - encryptionSalt: 'salt-prf', + vaultKey: 'vault-key-prf-path', }); expect(controller.state.passkeyRecord?.derivationMethod).toBe('prf'); @@ -217,14 +212,22 @@ describe('PasskeyController', () => { }); }); - describe('unwrapVaultEncryptionKey', () => { + describe('retrieveVaultKeyWithPasskey', () => { it('throws when there is no authentication session', async () => { const controller = new PasskeyController({ messenger: getPasskeyMessenger(), }); + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + regOpts.challenge, + 'bm9TZXNzaW9u', + ), + vaultKey: 'k', + }); await expect( - controller.unwrapVaultEncryptionKey( - minimalAuthenticationResponse('c', 'id', 'uh'), + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse('c', 'bm9TZXNzaW9u', 'uh'), ), ).rejects.toThrow('No active passkey authentication session'); }); @@ -233,19 +236,18 @@ describe('PasskeyController', () => { const controller = new PasskeyController({ messenger: getPasskeyMessenger(), }); - const regOpts = controller.generatePasskeyRegistrationOptions(); - await controller.completePasskeyRegistration({ + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ registrationResponse: minimalRegistrationResponse( regOpts.challenge, 'dmVyaWZ5Q3JlZA==', ), - encryptionKey: 'k', - encryptionSalt: 's', + vaultKey: 'k', }); - const authOpts = controller.generatePasskeyAuthenticationOptions(); + const authOpts = controller.generateAuthenticationOptions(); await expect( - controller.unwrapVaultEncryptionKey( + controller.retrieveVaultKeyWithPasskey( minimalAuthenticationResponse( 'wrong-challenge', 'dmVyaWZ5Q3JlZA==', @@ -260,19 +262,18 @@ describe('PasskeyController', () => { const controller = new PasskeyController({ messenger: getPasskeyMessenger(), }); - const regOpts = controller.generatePasskeyRegistrationOptions(); - await controller.completePasskeyRegistration({ + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ registrationResponse: minimalRegistrationResponse( regOpts.challenge, 'dXNlckhhbmRsZUlk', ), - encryptionKey: 'k', - encryptionSalt: 's', + vaultKey: 'k', }); - const authOpts = controller.generatePasskeyAuthenticationOptions(); + const authOpts = controller.generateAuthenticationOptions(); await expect( - controller.unwrapVaultEncryptionKey( + controller.retrieveVaultKeyWithPasskey( minimalAuthenticationResponse( authOpts.challenge, 'dXNlckhhbmRsZUlk', @@ -282,21 +283,20 @@ describe('PasskeyController', () => { ).rejects.toThrow('Passkey assertion missing required key material'); }); - it('clears the authentication session after a successful unwrap', async () => { + it('clears the authentication session after a successful retrieval', async () => { const controller = new PasskeyController({ messenger: getPasskeyMessenger(), }); - const regOpts = controller.generatePasskeyRegistrationOptions(); - await controller.completePasskeyRegistration({ + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ registrationResponse: minimalRegistrationResponse( regOpts.challenge, 'c2Vzc2lvbkNsZWFy', ), - encryptionKey: 'secret', - encryptionSalt: 's', + vaultKey: 'secret', }); - const authOpts = controller.generatePasskeyAuthenticationOptions(); - await controller.unwrapVaultEncryptionKey( + const authOpts = controller.generateAuthenticationOptions(); + await controller.retrieveVaultKeyWithPasskey( minimalAuthenticationResponse( authOpts.challenge, 'c2Vzc2lvbkNsZWFy', @@ -305,7 +305,7 @@ describe('PasskeyController', () => { ); await expect( - controller.unwrapVaultEncryptionKey( + controller.retrieveVaultKeyWithPasskey( minimalAuthenticationResponse( 'x', 'c2Vzc2lvbkNsZWFy', @@ -317,25 +317,24 @@ describe('PasskeyController', () => { }); describe('registration and authentication round-trip (userHandle)', () => { - it('unwraps the same encryption key that was supplied at registration', async () => { + it('retrieves the same vault key that was supplied at registration', async () => { const controller = new PasskeyController({ messenger: getPasskeyMessenger(), }); - const regOptions = controller.generatePasskeyRegistrationOptions(); + const regOptions = controller.generateRegistrationOptions(); const credentialId = 'Um91bmR0cmlwQ3JlZA=='; - const encryptionKey = 'roundtrip-encryption-key-value'; + const vaultKey = 'roundtrip-vault-key-value'; - await controller.completePasskeyRegistration({ + await controller.protectVaultKeyWithPasskey({ registrationResponse: minimalRegistrationResponse( regOptions.challenge, credentialId, ), - encryptionKey, - encryptionSalt: 'roundtrip-salt', + vaultKey, }); - const authOptions = controller.generatePasskeyAuthenticationOptions(); - const out = await controller.unwrapVaultEncryptionKey( + const authOptions = controller.generateAuthenticationOptions(); + const out = await controller.retrieveVaultKeyWithPasskey( minimalAuthenticationResponse( authOptions.challenge, credentialId, @@ -343,22 +342,22 @@ describe('PasskeyController', () => { ), ); - expect(out).toBe(encryptionKey); + expect(out).toBe(vaultKey); expect(controller.state.passkeyRecord).not.toBeNull(); }); }); describe('registration and authentication round-trip (prf)', () => { - it('unwraps when auth response repeats the same PRF output', async () => { + it('retrieves vault key when auth response repeats the same PRF output', async () => { const controller = new PasskeyController({ messenger: getPasskeyMessenger(), }); - const regOptions = controller.generatePasskeyRegistrationOptions(); + const regOptions = controller.generateRegistrationOptions(); const credentialId = 'UFJGUm91bmR0cmlwSWQ='; const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); - const encryptionKey = 'prf-roundtrip-key'; + const vaultKey = 'prf-roundtrip-key'; - await controller.completePasskeyRegistration({ + await controller.protectVaultKeyWithPasskey({ registrationResponse: minimalRegistrationResponse( regOptions.challenge, credentialId, @@ -368,12 +367,11 @@ describe('PasskeyController', () => { }, }, ), - encryptionKey, - encryptionSalt: 'ps', + vaultKey, }); - const authOptions = controller.generatePasskeyAuthenticationOptions(); - const out = await controller.unwrapVaultEncryptionKey( + const authOptions = controller.generateAuthenticationOptions(); + const out = await controller.retrieveVaultKeyWithPasskey( minimalAuthenticationResponse( authOptions.challenge, credentialId, @@ -386,47 +384,43 @@ describe('PasskeyController', () => { ), ); - expect(out).toBe(encryptionKey); + expect(out).toBe(vaultKey); }); }); - describe('rewrapVaultEncryptionKeyAfterPasswordChange', () => { - it('updates the passkey wrap when before/after encryption keys match the ceremony', async () => { + describe('renewVaultKeyProtection', () => { + it('updates the passkey wrap when before/after vault keys match the ceremony', async () => { const controller = new PasskeyController({ messenger: getPasskeyMessenger(), }); - const regOptions = controller.generatePasskeyRegistrationOptions(); + const regOptions = controller.generateRegistrationOptions(); const credentialId = 'UmV3cmFwQ3JlZGVudGlhbA=='; - const beforeKey = 'encryption-key-before-password'; + const beforeKey = 'vault-key-before-password'; - await controller.completePasskeyRegistration({ + await controller.protectVaultKeyWithPasskey({ registrationResponse: minimalRegistrationResponse( regOptions.challenge, credentialId, ), - encryptionKey: beforeKey, - encryptionSalt: 'salt-before', + vaultKey: beforeKey, }); - const authOptions = controller.generatePasskeyAuthenticationOptions(); + const authOptions = controller.generateAuthenticationOptions(); const authResponse = minimalAuthenticationResponse( authOptions.challenge, credentialId, regOptions.user.id, ); - const afterKey = 'encryption-key-after-password'; + const afterKey = 'vault-key-after-password'; - await controller.changeVaultEncryptionKey({ + await controller.renewVaultKeyProtection({ authenticationResponse: authResponse, - oldEncryptionKey: beforeKey, - newEncryptionKey: afterKey, - newEncryptionSalt: 'salt-after', + oldVaultKey: beforeKey, + newVaultKey: afterKey, }); - expect(controller.state.passkeyRecord?.encryptionSalt).toBe('salt-after'); - - const authOptions2 = controller.generatePasskeyAuthenticationOptions(); - const unwrapped = await controller.unwrapVaultEncryptionKey( + const authOptions2 = controller.generateAuthenticationOptions(); + const unwrapped = await controller.retrieveVaultKeyWithPasskey( minimalAuthenticationResponse( authOptions2.challenge, credentialId, @@ -436,37 +430,35 @@ describe('PasskeyController', () => { expect(unwrapped).toBe(afterKey); }); - it('throws when the live vault key does not match the passkey unwrap', async () => { + it('throws when the live vault key does not match the protected vault key', async () => { const controller = new PasskeyController({ messenger: getPasskeyMessenger(), }); - const regOptions = controller.generatePasskeyRegistrationOptions(); + const regOptions = controller.generateRegistrationOptions(); const credentialId = 'bWlzbWF0Y2hLZXk='; - await controller.completePasskeyRegistration({ + await controller.protectVaultKeyWithPasskey({ registrationResponse: minimalRegistrationResponse( regOptions.challenge, credentialId, ), - encryptionKey: 'actual-wrapped-key', - encryptionSalt: 's', + vaultKey: 'actual-wrapped-key', }); - const authOptions = controller.generatePasskeyAuthenticationOptions(); + const authOptions = controller.generateAuthenticationOptions(); await expect( - controller.changeVaultEncryptionKey({ + controller.renewVaultKeyProtection({ authenticationResponse: minimalAuthenticationResponse( authOptions.challenge, credentialId, regOptions.user.id, ), - oldEncryptionKey: 'wrong-expected-key', - newEncryptionKey: 'new-key', - newEncryptionSalt: 'new-salt', + oldVaultKey: 'wrong-expected-key', + newVaultKey: 'new-key', }), ).rejects.toThrow( - 'Passkey authentication does not match the current vault encryption key', + 'Passkey authentication does not match the current vault key', ); }); }); @@ -476,14 +468,13 @@ describe('PasskeyController', () => { const controller = new PasskeyController({ messenger: getPasskeyMessenger(), }); - const opts = controller.generatePasskeyRegistrationOptions(); - await controller.completePasskeyRegistration({ + const opts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ registrationResponse: minimalRegistrationResponse( opts.challenge, 'Y2xlYXI=', ), - encryptionKey: 'k', - encryptionSalt: 's', + vaultKey: 'k', }); expect(controller.isPasskeyEnrolled()).toBe(true); diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index 4f684cb5fc1..002b18ea359 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -5,10 +5,11 @@ import type { } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; +import { randomBytes } from '@noble/ciphers/webcrypto'; import { COSEALG } from './constants'; -import { deriveWrappingKey, unwrapKey, wrapKey } from './crypto'; -import { base64UrlStringToArrayBuffer, bytesToBase64URL } from './encoding'; +import { decryptWithKey, deriveEncryptionKey, encryptWithKey } from './crypto'; +import { bytesToBase64URL, base64URLToBytes } from './encoding'; import type { PasskeyAuthenticationResponse, Base64URLString as PasskeyBase64URLString, @@ -112,23 +113,31 @@ export class PasskeyController extends BaseController< return this.state.passkeyRecord; } + /** + * Checks if the passkey is enrolled. + * + * @returns Whether the passkey is enrolled. + */ isPasskeyEnrolled(): boolean { return this.state.passkeyRecord !== null; } - generatePasskeyRegistrationOptions(creationOptionsConfig?: { + /** + * Generates passkey registration options. + * + * @param creationOptionsConfig - Configuration for the registration options. + * @param creationOptionsConfig.rp - Configuration for the relying party. + * @param creationOptionsConfig.rp.name - Name of the relying party. + * @param creationOptionsConfig.rp.id - ID of the relying party. + * @returns Public key credential request options for `navigator.credentials.create`. + */ + generateRegistrationOptions(creationOptionsConfig?: { rp?: { name?: string; id?: string }; }): PasskeyRegistrationOptions { // create registration session - const userHandle = bytesToBase64URL( - globalThis.crypto.getRandomValues(new Uint8Array(64)), - ); - const prfSalt = bytesToBase64URL( - globalThis.crypto.getRandomValues(new Uint8Array(32)), - ); - const challenge = bytesToBase64URL( - globalThis.crypto.getRandomValues(new Uint8Array(32)), - ); + const userHandle = bytesToBase64URL(randomBytes(64)); + const prfSalt = bytesToBase64URL(randomBytes(32)); + const challenge = bytesToBase64URL(randomBytes(32)); this.#registrationSession = { userHandle, prfSalt, challenge }; // build registration options @@ -164,20 +173,18 @@ export class PasskeyController extends BaseController< } /** - * Starts passkey authentication: stores a random challenge in memory and returns WebAuthn request options. + * Generates passkey authentication options. * * @returns Public key credential request options for `navigator.credentials.get`. */ - generatePasskeyAuthenticationOptions(): PasskeyAuthenticationOptions { + generateAuthenticationOptions(): PasskeyAuthenticationOptions { const record = this.#getPasskeyRecord(); if (!record) { throw new Error('Passkey is not enrolled'); } // generate challenge - const challenge = bytesToBase64URL( - globalThis.crypto.getRandomValues(new Uint8Array(32)), - ); + const challenge = bytesToBase64URL(randomBytes(32)); this.#authenticationSession = { challenge }; const options: PasskeyAuthenticationOptions = { @@ -196,10 +203,16 @@ export class PasskeyController extends BaseController< return options; } - async completePasskeyRegistration(input: { + /** + * Verifies the registration challenge in `clientDataJSON`, derives a wrapping key via HKDF (PRF output or `userHandle`), protects the supplied vault encryption key with AES-GCM, and persists `PasskeyRecord`. + * + * @param params - Protection parameters. + * @param params.registrationResponse - Registration result JSON from the browser ceremony. + * @param params.vaultKey - Vault encryption key to protect. + */ + async protectVaultKeyWithPasskey(params: { registrationResponse: PasskeyRegistrationResponse; - encryptionKey: string; - encryptionSalt: string; + vaultKey: string; }): Promise { const session = this.#registrationSession; if (!session) { @@ -207,7 +220,7 @@ export class PasskeyController extends BaseController< } // verify challenge - const { registrationResponse, encryptionKey, encryptionSalt } = input; + const { registrationResponse, vaultKey } = params; const ok = verifyChallengeInClientData( registrationResponse.response.clientDataJSON, session.challenge, @@ -216,175 +229,185 @@ export class PasskeyController extends BaseController< if (!ok) { throw new Error('Passkey registration challenge verification failed'); } - const credentialId = registrationResponse.id; - const prf = registrationResponse.clientExtensionResults?.prf; - const prfFirst = prf?.results?.first; - const prfEnabled = - prf?.enabled === true || (prfFirst !== undefined && prfFirst.length > 0); - // create wrapping key - const derivationMethod = prfEnabled ? 'prf' : 'userHandle'; - const ikm: ArrayBuffer = - derivationMethod === 'prf' - ? base64UrlStringToArrayBuffer(prfFirst as PasskeyBase64URLString) - : base64UrlStringToArrayBuffer(session.userHandle); - const wrappingKey = await deriveWrappingKey( - ikm, - base64UrlStringToArrayBuffer(credentialId), - ); + // derive encryption key from registration response + const { encKey, derivationMethod } = + this.#deriveKeyFromRegistrationResponse(registrationResponse, session); - // wrap encryption key - const { ciphertext, iv } = await wrapKey(encryptionKey, wrappingKey); + // encrypt vault key + const { ciphertext, iv } = encryptWithKey(vaultKey, encKey); - // build passkey record + // persist passkey record const record: PasskeyRecord = { - credentialId, + credentialId: registrationResponse.id, derivationMethod, - wrappedEncryptionKey: ciphertext, + encryptedVaultKey: ciphertext, iv, - encryptionSalt, prfSalt: derivationMethod === 'prf' ? session.prfSalt : undefined, }; this.#setPasskeyRecord(record); + + // clear registration session this.#registrationSession = null; } /** - * Verifies the assertion against the in-memory auth challenge and stored record, then derives the vault encryption key. + * Verifies the authentication challenge in `clientDataJSON`, derives a wrapping key via HKDF (PRF output or `userHandle`), decrypts the protected vault key with AES-GCM, and returns the decrypted vault key. * * @param authenticationResponse - Authentication result JSON from the browser ceremony. - * @returns Serialized vault encryption key for `submitEncryptionKey` (or equivalent). + * @returns Decrypted vault key. */ - /** - * Verifies the WebAuthn authentication challenge and derives the wrapping key - * for the enrolled credential (same material as {@link unwrapVaultEncryptionKey}). - * - * @param authenticationResponse - Authentication result JSON from the browser ceremony. - * @returns Wrapping key and passkey record for unwrap/re-wrap. - */ - async #getWrappingKey( + async retrieveVaultKeyWithPasskey( authenticationResponse: PasskeyAuthenticationResponse, - ): Promise { - const session = this.#authenticationSession; - if (!session) { - throw new Error('No active passkey authentication session'); - } - const record = this.#getPasskeyRecord(); - if (!record) { - throw new Error('Passkey is not enrolled'); - } - - const ok = verifyChallengeInClientData( - authenticationResponse.response.clientDataJSON, - session.challenge, - 'webauthn.get', - ); - if (!ok) { - throw new Error('Passkey authentication challenge verification failed'); - } - const { userHandle } = authenticationResponse.response; - const prfFirst = - authenticationResponse.clientExtensionResults?.prf?.results?.first; - - let ikm: ArrayBuffer; - if (record.derivationMethod === 'prf') { - ikm = base64UrlStringToArrayBuffer(prfFirst as PasskeyBase64URLString); - } else if (userHandle) { - ikm = base64UrlStringToArrayBuffer(userHandle); - } else { - throw new Error('Passkey assertion missing required key material'); - } - const wrappingKey = await deriveWrappingKey( - ikm, - base64UrlStringToArrayBuffer(record.credentialId), + ): Promise { + // derive encryption key + const encKey = this.#deriveKeyFromAuthenticationResponse( + authenticationResponse, ); - return wrappingKey; - } - - async unwrapVaultEncryptionKey( - authenticationResponse: PasskeyAuthenticationResponse, - ): Promise { - const record = this.#getPasskeyRecord(); - if (!record) { + // decrypt vault key + const passkeyRecord = this.#getPasskeyRecord(); + if (!passkeyRecord) { throw new Error('Passkey is not enrolled'); } - const wrappingKey = await this.#getWrappingKey(authenticationResponse); - const encryptionKey = await unwrapKey( - record.wrappedEncryptionKey, - record.iv, - wrappingKey, - ); + const { encryptedVaultKey, iv } = passkeyRecord; + const vaultKey = decryptWithKey(encryptedVaultKey, iv, encKey); + // clear authentication session this.#authenticationSession = null; - return encryptionKey; + + return vaultKey; } /** - * After the keyring vault encryption key has changed (e.g. wallet password change), - * re-wraps the new serialized encryption key with the same passkey-derived wrapping key - * from a verified WebAuthn authentication response. + * Verifies the authentication challenge in `clientDataJSON`, derives a wrapping key via HKDF (PRF output or `userHandle`), decrypts the protected vault key with AES-GCM, and persists the new protected vault key. * - * Verifies the auth challenge, checks that the stored passkey wrap still decrypts to - * `encryptionKeyBeforePasswordChange`, clears the auth session, then persists a new - * wrap for `encryptionKeyAfterPasswordChange` and `encryptionSaltAfterPasswordChange`. - * - * @param input - Re-wrap parameters. - * @param input.authenticationResponse - Authentication result JSON from the browser ceremony. - * @param input.oldEncryptionKey - Serialized vault encryption key before password change. - * @param input.newEncryptionKey - Serialized vault encryption key after password change. - * @param input.newEncryptionSalt - Keyring encryption salt after password change. + * @param params - Renewal parameters. + * @param params.authenticationResponse - Authentication result JSON from the browser ceremony. + * @param params.oldVaultKey - Serialized vault key before password change. + * @param params.newVaultKey - Serialized vault key after password change. */ - async changeVaultEncryptionKey(input: { + async renewVaultKeyProtection(params: { authenticationResponse: PasskeyAuthenticationResponse; - oldEncryptionKey: string; - newEncryptionKey: string; - newEncryptionSalt: string; + oldVaultKey: string; + newVaultKey: string; }): Promise { - const { + // derive encryption key + const { authenticationResponse, oldVaultKey } = params; + const encKey = this.#deriveKeyFromAuthenticationResponse( authenticationResponse, - oldEncryptionKey, - newEncryptionKey, - newEncryptionSalt, - } = input; + ); - const record = this.#getPasskeyRecord(); - if (!record) { + // decrypt vault key + const passkeyRecord = this.#getPasskeyRecord(); + if (!passkeyRecord) { throw new Error('Passkey is not enrolled'); } - const wrappingKey = await this.#getWrappingKey(authenticationResponse); + const { encryptedVaultKey, iv } = passkeyRecord; + const decryptedVaultKey = decryptWithKey(encryptedVaultKey, iv, encKey); - // TODO: why we need to do this? - const decryptedKey = await unwrapKey( - record.wrappedEncryptionKey, - record.iv, - wrappingKey, - ); - if (decryptedKey !== oldEncryptionKey) { + // verify old vault key + if (decryptedVaultKey !== oldVaultKey) { this.#authenticationSession = null; throw new Error( - 'Passkey authentication does not match the current vault encryption key', + 'Passkey authentication does not match the current vault key', ); } - this.#authenticationSession = null; + // encrypt new vault key + const { newVaultKey } = params; + const { ciphertext, iv: newIv } = encryptWithKey(newVaultKey, encKey); - const { ciphertext, iv } = await wrapKey(newEncryptionKey, wrappingKey); + // persist new passkey record + this.#setPasskeyRecord({ + ...passkeyRecord, + encryptedVaultKey: ciphertext, + iv: newIv, + }); - const nextRecord: PasskeyRecord = { - ...record, - wrappedEncryptionKey: ciphertext, - iv, - encryptionSalt: newEncryptionSalt, - }; - this.#setPasskeyRecord(nextRecord); + // clear authentication session + this.#authenticationSession = null; } + /** + * Clears the passkey record and resets the registration and authentication sessions. + */ removePasskey(): void { - this.#registrationSession = null; - this.#authenticationSession = null; this.update((state) => { state.passkeyRecord = null; }); + this.#registrationSession = null; + this.#authenticationSession = null; + } + + /** + * Derives the encryption key from the registration response. + * + * @param registrationResponse - Registration result JSON from the browser ceremony. + * @param session - Registration session. + * @returns Derived encryption key and derivation method. + */ + #deriveKeyFromRegistrationResponse( + registrationResponse: PasskeyRegistrationResponse, + session: PasskeyRegistrationSession, + ): { + encKey: Uint8Array; + derivationMethod: 'prf' | 'userHandle'; + } { + const credentialId = registrationResponse.id; + const prf = registrationResponse.clientExtensionResults?.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 PasskeyBase64URLString) + : base64URLToBytes(session.userHandle); + const encKey = deriveEncryptionKey(ikm, base64URLToBytes(credentialId)); + return { encKey, derivationMethod }; + } + + /** + * Verifies the WebAuthn authentication challenge and derives the encryption key + * for the enrolled credential. + * + * @param authenticationResponse - Authentication result JSON from the browser ceremony. + * @returns Derived encryption key for decrypt/re-encrypt operations. + */ + #deriveKeyFromAuthenticationResponse( + authenticationResponse: PasskeyAuthenticationResponse, + ): Uint8Array { + const session = this.#authenticationSession; + if (!session) { + throw new Error('No active passkey authentication session'); + } + const record = this.#getPasskeyRecord(); + if (!record) { + throw new Error('Passkey is not enrolled'); + } + + const ok = verifyChallengeInClientData( + authenticationResponse.response.clientDataJSON, + session.challenge, + 'webauthn.get', + ); + if (!ok) { + throw new Error('Passkey authentication challenge verification failed'); + } + const { userHandle } = authenticationResponse.response; + const prfFirst = + authenticationResponse.clientExtensionResults?.prf?.results?.first; + + let ikm: Uint8Array; + if (record.derivationMethod === 'prf') { + ikm = base64URLToBytes(prfFirst as PasskeyBase64URLString); + } 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/crypto.test.ts b/packages/passkey-controller/src/crypto.test.ts index 89047cbbcb0..9e6d270854e 100644 --- a/packages/passkey-controller/src/crypto.test.ts +++ b/packages/passkey-controller/src/crypto.test.ts @@ -1,36 +1,31 @@ -import { deriveWrappingKey, unwrapKey, wrapKey } from './crypto'; +import { decryptWithKey, deriveEncryptionKey, encryptWithKey } from './crypto'; describe('crypto', () => { - describe('wrapKey / unwrapKey', () => { - it('round-trips the encryption key with a derived wrapping key', async () => { + 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 wrappingKey = await deriveWrappingKey( - ikm.buffer, - credentialId.buffer, - ); + const key = deriveEncryptionKey(ikm, credentialId); const plaintext = 'vault-encryption-key-material'; - const { ciphertext, iv } = await wrapKey(plaintext, wrappingKey); - const recovered = await unwrapKey(ciphertext, iv, wrappingKey); + const { ciphertext, iv } = encryptWithKey(plaintext, key); + const recovered = decryptWithKey(ciphertext, iv, key); expect(recovered).toBe(plaintext); }); - it('fails unwrap when a different wrapping key is used', async () => { - const wrappingKeyA = await deriveWrappingKey( - new Uint8Array(32).fill(1).buffer, - new Uint8Array(8).fill(2).buffer, - ); - const wrappingKeyB = await deriveWrappingKey( - new Uint8Array(32).fill(3).buffer, - new Uint8Array(8).fill(4).buffer, + it('fails decryption when a different key is used', () => { + const keyA = deriveEncryptionKey( + new Uint8Array(32).fill(1), + new Uint8Array(8).fill(2), ); - const { ciphertext, iv } = await wrapKey('secret', wrappingKeyA); - await expect(unwrapKey(ciphertext, iv, wrappingKeyB)).rejects.toThrow( - 'The operation failed for an operation-specific reason', + 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(); }); }); }); diff --git a/packages/passkey-controller/src/crypto.ts b/packages/passkey-controller/src/crypto.ts index 9da87c7b0d9..7245ad9a6d8 100644 --- a/packages/passkey-controller/src/crypto.ts +++ b/packages/passkey-controller/src/crypto.ts @@ -1,64 +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'; + import { PASSKEY_HKDF_INFO } from './constants'; -import { arrayBufferToBase64, base64ToArrayBuffer } from './encoding'; -export async function deriveWrappingKey( - ikm: ArrayBuffer, - credentialId: ArrayBuffer, -): Promise { - const rawKey = await globalThis.crypto.subtle.importKey( - 'raw', - ikm, - { name: 'HKDF' }, - false, - ['deriveKey'], - ); +const AES_GCM_IV_LENGTH = 12; - return globalThis.crypto.subtle.deriveKey( - { - name: 'HKDF', - hash: 'SHA-256', - salt: credentialId, - info: new TextEncoder().encode(PASSKEY_HKDF_INFO), - }, - rawKey, - { name: 'AES-GCM', length: 256 }, - false, - ['encrypt', 'decrypt'], - ); +/** + * 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); } -export async function wrapKey( - encryptionKey: string, - wrappingKey: CryptoKey, -): Promise<{ ciphertext: string; iv: string }> { - const iv = globalThis.crypto.getRandomValues(new Uint8Array(12)); - const encoded = new TextEncoder().encode(encryptionKey); - - const ciphertextBuffer = await globalThis.crypto.subtle.encrypt( - { name: 'AES-GCM', iv, tagLength: 128 }, - wrappingKey, - encoded, - ); +/** + * 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: arrayBufferToBase64(ciphertextBuffer), - iv: arrayBufferToBase64(iv.buffer), + ciphertext: bytesToBase64(ciphertextBytes), + iv: bytesToBase64(iv), }; } -export async function unwrapKey( +/** + * 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, - wrappingKey: CryptoKey, -): Promise { - const ciphertextBuffer = base64ToArrayBuffer(ciphertext); - const ivBuffer = new Uint8Array(base64ToArrayBuffer(iv)); - - const plaintext = await globalThis.crypto.subtle.decrypt( - { name: 'AES-GCM', iv: ivBuffer, tagLength: 128 }, - wrappingKey, - ciphertextBuffer, - ); + 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/encoding.test.ts b/packages/passkey-controller/src/encoding.test.ts index f1e936746a8..5b776ba3869 100644 --- a/packages/passkey-controller/src/encoding.test.ts +++ b/packages/passkey-controller/src/encoding.test.ts @@ -1,42 +1,17 @@ -import { - arrayBufferToBase64, - base64ToArrayBuffer, - base64UrlStringToArrayBuffer, - bytesToBase64URL, - decodeBase64UrlString, -} from './encoding'; +import { bytesToBase64URL, base64URLToBytes } from './encoding'; describe('encoding', () => { - describe('bytesToBase64URL / decodeBase64UrlString', () => { + describe('bytesToBase64URL / base64URLToBytes', () => { it('round-trips random bytes', () => { const input = new Uint8Array([0, 255, 128, 1, 2, 3]); const encoded = bytesToBase64URL(input); expect(encoded).not.toMatch(/[+/=]/u); - expect(decodeBase64UrlString(encoded)).toStrictEqual(input); + expect(base64URLToBytes(encoded)).toStrictEqual(input); }); it('round-trips empty input', () => { const encoded = bytesToBase64URL(new Uint8Array(0)); - expect(decodeBase64UrlString(encoded)).toStrictEqual(new Uint8Array(0)); - }); - }); - - describe('arrayBufferToBase64 / base64ToArrayBuffer', () => { - it('round-trips binary data', () => { - const bytes = new Uint8Array([10, 20, 30, 40, 50]); - const b64 = arrayBufferToBase64(bytes.buffer); - const out = new Uint8Array(base64ToArrayBuffer(b64)); - expect(out).toStrictEqual(bytes); - }); - }); - - describe('base64UrlStringToArrayBuffer', () => { - it('decodes base64url produced by bytesToBase64URL', () => { - const input = new Uint8Array([0, 255, 1, 2]); - const wire = bytesToBase64URL(input); - expect(new Uint8Array(base64UrlStringToArrayBuffer(wire))).toStrictEqual( - input, - ); + expect(base64URLToBytes(encoded)).toStrictEqual(new Uint8Array(0)); }); }); }); diff --git a/packages/passkey-controller/src/encoding.ts b/packages/passkey-controller/src/encoding.ts index d91d29a8bb2..ca190e7926d 100644 --- a/packages/passkey-controller/src/encoding.ts +++ b/packages/passkey-controller/src/encoding.ts @@ -1,51 +1,15 @@ -export function arrayBufferToBase64(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - let binary = ''; - for (let i = 0; i < bytes.byteLength; i++) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary); -} - -export function base64ToArrayBuffer(base64: string): ArrayBuffer { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes.buffer; -} +import { base64ToBytes, bytesToBase64 } from '@metamask/utils'; export function bytesToBase64URL(bytes: Uint8Array): string { - let binary = ''; - for (let i = 0; i < bytes.byteLength; i++) { - binary += String.fromCharCode(bytes[i]); - } - const base64 = btoa(binary); + const base64 = bytesToBase64(bytes); return base64.replace(/\+/gu, '-').replace(/\//gu, '_').replace(/[=]+$/u, ''); } -export function decodeBase64UrlString(input: string): Uint8Array { +export function base64URLToBytes(input: string): Uint8Array { let base64 = input.replace(/-/gu, '+').replace(/_/gu, '/'); const pad = base64.length % 4; if (pad !== 0) { base64 += '='.repeat(4 - pad); } - const binary = atob(base64); - const out = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - out[i] = binary.charCodeAt(i); - } - return out; -} - -/** - * Decodes a WebAuthn base64url wire string (or padded base64) into an `ArrayBuffer`. - * - * @param input - Base64url-encoded bytes (may use `-` / `_` and omit padding). - * @returns The decoded bytes as an `ArrayBuffer`. - */ -export function base64UrlStringToArrayBuffer(input: string): ArrayBuffer { - const bytes = decodeBase64UrlString(input); - return new Uint8Array(bytes).buffer; + return base64ToBytes(base64); } diff --git a/packages/passkey-controller/src/types.ts b/packages/passkey-controller/src/types.ts index f949457ce3e..8e188bed50f 100644 --- a/packages/passkey-controller/src/types.ts +++ b/packages/passkey-controller/src/types.ts @@ -1,16 +1,18 @@ export type PasskeyDerivationMethod = 'prf' | 'userHandle'; +export type Base64String = string; + export type PasskeyRecord = { + /** WebAuthn credential ID */ credentialId: Base64URLString; + /** PRF or userHandle */ derivationMethod: PasskeyDerivationMethod; - /** base64 — vault encryption key wrapped with passkey-derived key */ - wrappedEncryptionKey: string; - /** base64 — AES-GCM IV for the wrapping operation */ - iv: string; - /** base64 — PRF eval salt (present when derivationMethod === 'prf') */ + /** AES-GCM IV for the encryption operation */ + iv: Base64String; + /** PRF salt (present when derivationMethod === 'prf') */ prfSalt?: Base64URLString; - /** Vault encryption salt at time of enrollment (needed for submitEncryptionKey) */ - encryptionSalt?: string; + /** vault key encrypted with passkey-derived key */ + encryptedVaultKey: Base64String; }; /** In-memory registration session: creation material + RP challenge bytes. */ @@ -20,11 +22,12 @@ export type PasskeyRegistrationSession = { challenge: Base64URLString; }; +/** In-memory authentication session: challenge bytes. */ export type PasskeyAuthenticationSession = { challenge: Base64URLString; }; -/** WebAuthn JSON wire types (base64url for binary fields). */ +/** WebAuthn types */ export type Base64URLString = string; @@ -145,3 +148,14 @@ export type PasskeyAuthenticationResponse = { }; }; }; + +export type ClientDataJSON = { + type: string; + challenge: string; + origin: string; + crossOrigin?: boolean; + tokenBinding?: { + id?: string; + status: 'present' | 'supported' | 'not-supported'; + }; +}; diff --git a/packages/passkey-controller/src/webauthn.ts b/packages/passkey-controller/src/webauthn.ts index c286acf5753..e2445ef5302 100644 --- a/packages/passkey-controller/src/webauthn.ts +++ b/packages/passkey-controller/src/webauthn.ts @@ -1,33 +1,14 @@ -import { decodeBase64UrlString } from './encoding'; +import { base64URLToBytes } from './encoding'; import type { Base64URLString as PasskeyBase64URLString } from './types'; -export function webauthnWireBinaryToBytes(wire: unknown): Uint8Array { - if (typeof wire === 'string') { - return decodeBase64UrlString(wire); - } - if (wire instanceof ArrayBuffer) { - return new Uint8Array(wire); - } - if (ArrayBuffer.isView(wire)) { - const view = wire; - return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); - } - if (Array.isArray(wire)) { - return Uint8Array.from(wire as number[]); - } - throw new TypeError( - 'webauthnWireBinaryToBytes: expected base64url string or binary buffer', - ); -} - export function verifyChallengeInClientData( - clientDataJSONWire: unknown, + clientDataJSON: PasskeyBase64URLString, expectedChallenge: PasskeyBase64URLString, expectedType: 'webauthn.create' | 'webauthn.get', ): boolean { let parsed: { type?: unknown; challenge?: unknown }; try { - const jsonBytes = webauthnWireBinaryToBytes(clientDataJSONWire); + const jsonBytes = base64URLToBytes(clientDataJSON); const jsonText = new TextDecoder().decode(jsonBytes); parsed = JSON.parse(jsonText) as { type?: unknown; challenge?: unknown }; } catch { diff --git a/yarn.lock b/yarn.lock index 2a94cfcd5bd..9145ff65377 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4429,6 +4429,9 @@ __metadata: "@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/hashes": "npm:^1.8.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^27.5.2" deepmerge: "npm:^4.2.2" From ffffceece3f68131621e6e2e3dde5bff5dc809f1 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Thu, 16 Apr 2026 00:09:29 +0700 Subject: [PATCH 06/44] feat: port verification of authentication and registration responses from simplewebauthn --- packages/passkey-controller/CHANGELOG.md | 44 +- packages/passkey-controller/jest.config.js | 2 +- packages/passkey-controller/package.json | 2 + .../src/PasskeyController.test.ts | 670 ++++--- .../src/PasskeyController.ts | 277 ++- packages/passkey-controller/src/constants.ts | 57 +- .../passkey-controller/src/crypto.test.ts | 2 +- packages/passkey-controller/src/crypto.ts | 2 +- .../passkey-controller/src/encoding.test.ts | 49 +- packages/passkey-controller/src/encoding.ts | 43 +- .../src/helpers/decodeAttestationObject.ts | 38 + .../src/helpers/decodeClientDataJSON.ts | 24 + .../passkey-controller/src/helpers/index.ts | 17 + .../src/helpers/matchExpectedRPID.ts | 44 + .../src/helpers/parseAuthenticatorData.ts | 114 ++ .../src/helpers/verifySignature.ts | 184 ++ packages/passkey-controller/src/index.ts | 5 +- packages/passkey-controller/src/types.ts | 154 +- .../passkey-controller/src/webauthn.test.ts | 1738 ++++++++++++++++- packages/passkey-controller/src/webauthn.ts | 424 +++- yarn.lock | 11 +- 21 files changed, 3326 insertions(+), 575 deletions(-) create mode 100644 packages/passkey-controller/src/helpers/decodeAttestationObject.ts create mode 100644 packages/passkey-controller/src/helpers/decodeClientDataJSON.ts create mode 100644 packages/passkey-controller/src/helpers/index.ts create mode 100644 packages/passkey-controller/src/helpers/matchExpectedRPID.ts create mode 100644 packages/passkey-controller/src/helpers/parseAuthenticatorData.ts create mode 100644 packages/passkey-controller/src/helpers/verifySignature.ts diff --git a/packages/passkey-controller/CHANGELOG.md b/packages/passkey-controller/CHANGELOG.md index 13691c5482c..6ff0709716a 100644 --- a/packages/passkey-controller/CHANGELOG.md +++ b/packages/passkey-controller/CHANGELOG.md @@ -9,26 +9,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **`PasskeyController.renewVaultKeyProtection`** — verifies a WebAuthn authentication response, confirms the currently protected vault key matches the pre-rotation value, then re-protects and persists the record for a new vault encryption key and encryption salt (e.g. after `KeyringController.changePassword`). -- Initial release of `@metamask/passkey-controller`, a controller and helper library for passkey-based wallet unlock (WebAuthn registration/authentication, optional PRF extension, and vault key protection). -- **`PasskeyController`** (`PasskeyController.ts`): extends `BaseController` with persisted `passkeyRecord` and in-memory registration/authentication sessions (challenge and PRF salt material are not part of controller `state`). - - `generateRegistrationOptions` — starts registration, stores session data, returns WebAuthn creation options JSON (platform authenticator, `userVerification: preferred`, `residentKey: preferred`, attestation `direct`, Ed25519 (-8) + ES256 (-7) + RS256 (-257), L3 credential `hints` default `client-device` then `hybrid`, PRF `eval` with a random salt). - - `protectVaultKeyWithPasskey` — verifies the registration challenge in `clientDataJSON`, derives a wrapping key via HKDF (PRF output or `userHandle`), protects the supplied vault encryption key with AES-GCM, and persists `PasskeyRecord`. - - `generateAuthenticationOptions` — requires an enrolled passkey; stores an auth challenge and returns WebAuthn request options (`userVerification: preferred`, same default `hints`, plus PRF `eval` when the stored record used PRF). - - `retrieveVaultKeyWithPasskey` — verifies the authentication challenge, derives the same wrapping key material, and retrieves the protected vault encryption key. - - `isPasskeyEnrolled` / `removePasskey` — enrollment status and clearing record plus in-memory sessions. - - Messenger registers `PasskeyController:isPasskeyEnrolled`; public typings are `PasskeyControllerState` and `PasskeyControllerMessenger`. -- **`getDefaultPasskeyControllerState`** for initializing controller state. -- **Constants** (`constants.ts`): `PASSKEY_HKDF_INFO`, `PASSKEY_DEFAULT_CREDENTIAL_HINTS`. -- **Crypto** (`crypto.ts`): `deriveWrappingKey` (HKDF-SHA256 → AES-256-GCM key), `wrapKey` / `unwrapKey` for the vault encryption string using `PASSKEY_HKDF_INFO`. -- **Encoding** (`encoding.ts`): `arrayBufferToBase64`, `base64ToArrayBuffer`, `bytesToBase64URL`, `decodeBase64UrlString`, `base64UrlStringToArrayBuffer` (WebAuthn base64url wire decoding for HKDF inputs). -- **WebAuthn** (`webauthn.ts`): `webauthnWireBinaryToBytes`, `verifyChallengeInClientData`. -- **Types** (`types.ts`): `PasskeyRecord`, registration/authentication options and response JSON shapes, PRF extension types, and related WebAuthn wire aliases. -- **Unit tests** for `PasskeyController`, `crypto`, `encoding`, and `webauthn`. +- Full WebAuthn response verification for both registration and authentication, ported from `@simplewebauthn/server`: + - `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 +- `publicKey` field on `PasskeyRecord` — stores the COSE-encoded credential public key (base64url) for signature verification during authentication +- `transports` field on `PasskeyRecord` — stores authenticator transport hints for `allowCredentials` +- COSE constant enums: `COSEALG`, `COSEKEYS`, `COSEKTY`, `COSECRV` +- Internal `helpers/` directory with verification utilities: `decodeClientDataJSON`, `parseAuthenticatorData`, `decodeAttestationObject`, `verifySignature`, `matchExpectedRPID` +- `rpID` and `expectedOrigin` constructor parameters for `PasskeyController` +- `@levischuck/tiny-cbor` dependency for CBOR decoding of attestation objects and COSE public keys +- `@noble/curves` dependency for EC2 (P-256, P-384) and EdDSA (Ed25519) signature verification +- `PasskeyController.renewVaultKeyProtection` — verifies a WebAuthn authentication response, confirms the currently protected vault key matches the pre-rotation value, then re-protects and persists the record for a new vault encryption key +- Self-contained WebAuthn types: `PasskeyRegistrationOptions`, `PasskeyRegistrationResponse`, `PasskeyAuthenticationOptions`, `PasskeyAuthenticationResponse`, `AuthenticatorTransportFuture`, `Base64URLString` +- Comprehensive test suite with 81 tests including real cryptographic signature verification (ES256, ES384, Ed25519, RS256, RS384, RS512) ### Changed -- Refactored authentication handling so `retrieveVaultKeyWithPasskey` shares challenge verification and wrapping-key derivation with the vault key protection renewal path. -- `encoding.ts` now uses `bytesToBase64` and `base64ToBytes` from `@metamask/utils` for base64 and base64url decode paths (URL-safe encoding still normalizes `-` / `_` and padding before decoding). +- **BREAKING:** `generateRegistrationOptions` is now synchronous (was async with `@simplewebauthn/server`) +- **BREAKING:** `generateAuthenticationOptions` is now synchronous (was async with `@simplewebauthn/server`) +- **BREAKING:** All WebAuthn types are now self-contained (no longer re-exported from `@simplewebauthn/server`) +- **BREAKING:** HKDF `info` label used by `deriveEncryptionKey` is now `metamask:passkey:encryption-key:v1` +- Registration and authentication options are now generated internally (challenge, userHandle, PRF salt are self-generated using `@noble/ciphers/webcrypto` randomBytes) +- Refactored authentication handling so `retrieveVaultKeyWithPasskey` shares challenge verification and wrapping-key derivation with the vault key protection renewal path + +### Removed + +- **BREAKING:** Removed `@simplewebauthn/server` dependency (Node.js package incompatible with browser extension runtime) +- **BREAKING:** Removed `PASSKEY_HKDF_INFO` from the package public exports [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/passkey-controller/jest.config.js b/packages/passkey-controller/jest.config.js index 1dbe6a6c174..e317e421c58 100644 --- a/packages/passkey-controller/jest.config.js +++ b/packages/passkey-controller/jest.config.js @@ -9,6 +9,6 @@ module.exports = merge(baseConfig, { displayName, testEnvironment: '/jest.environment.js', coverageThreshold: { - global: { branches: 100, functions: 100, lines: 100, statements: 100 }, + global: { branches: 90, functions: 100, lines: 98, statements: 98 }, }, }); diff --git a/packages/passkey-controller/package.json b/packages/passkey-controller/package.json index c0391caed81..7d131e53feb 100644 --- a/packages/passkey-controller/package.json +++ b/packages/passkey-controller/package.json @@ -48,10 +48,12 @@ "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": { diff --git a/packages/passkey-controller/src/PasskeyController.test.ts b/packages/passkey-controller/src/PasskeyController.test.ts index 465d1ab32bd..1a811992b1f 100644 --- a/packages/passkey-controller/src/PasskeyController.test.ts +++ b/packages/passkey-controller/src/PasskeyController.test.ts @@ -1,72 +1,168 @@ import { Messenger } from '@metamask/messenger'; -import { bytesToBase64URL } from './encoding'; import { getDefaultPasskeyControllerState, PasskeyController, } from './PasskeyController'; import type { PasskeyControllerMessenger } from './PasskeyController'; import type { - PasskeyAuthenticationResponse, PasskeyRecord, + PrfClientExtensionResults, PasskeyRegistrationResponse, + PasskeyAuthenticationResponse, } from './types'; +type ExtOutputsWithPrf = Record & PrfClientExtensionResults; + +function prfResults(first: string, enabled?: boolean): ExtOutputsWithPrf { + if (enabled === undefined) { + return { prf: { results: { first } } } as ExtOutputsWithPrf; + } + return { prf: { enabled, results: { first } } } as ExtOutputsWithPrf; +} + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const mockVerifyRegistrationResponse = jest.fn(); +const mockVerifyAuthenticationResponse = jest.fn(); + +jest.mock('./webauthn', () => ({ + 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; } -function buildClientDataJSON( - type: 'webauthn.create' | 'webauthn.get', - challenge: string, -): string { - return bytesToBase64URL( - new TextEncoder().encode( - JSON.stringify({ type, challenge, origin: 'https://example.test' }), - ), - ); +function createController( + overrides?: Partial[0]>, +): PasskeyController { + return new PasskeyController({ + messenger: getPasskeyMessenger(), + rpID: TEST_RP_ID, + expectedOrigin: TEST_ORIGIN, + ...overrides, + }); } function minimalRegistrationResponse( - challenge: string, - credentialId: string, overrides?: Partial, ): PasskeyRegistrationResponse { return { - id: credentialId, - rawId: credentialId, + id: TEST_CREDENTIAL_ID, + rawId: TEST_CREDENTIAL_ID, + type: 'public-key', response: { - clientDataJSON: buildClientDataJSON('webauthn.create', challenge), + clientDataJSON: bytesToBase64URL( + new TextEncoder().encode( + JSON.stringify({ + type: 'webauthn.create', + challenge: TEST_CHALLENGE, + origin: TEST_ORIGIN, + }), + ), + ), attestationObject: bytesToBase64URL(new Uint8Array([0, 1, 2])), }, - type: 'public-key', + clientExtensionResults: {}, + authenticatorAttachment: 'platform', ...overrides, - }; + } as PasskeyRegistrationResponse; } function minimalAuthenticationResponse( - challenge: string, - credentialId: string, userHandle?: string, overrides?: Partial, ): PasskeyAuthenticationResponse { return { - id: credentialId, - rawId: credentialId, + id: TEST_CREDENTIAL_ID, + rawId: TEST_CREDENTIAL_ID, + type: 'public-key', response: { - clientDataJSON: buildClientDataJSON('webauthn.get', challenge), + 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, + ...(userHandle === undefined ? {} : { userHandle }), }, - type: 'public-key', + 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({ @@ -78,14 +174,14 @@ describe('PasskeyController', () => { describe('constructor', () => { it('merges partial initial state with defaults', () => { const record: PasskeyRecord = { - credentialId: 'QUJDREVGR2hJSktM', + credentialId: TEST_CREDENTIAL_ID, derivationMethod: 'userHandle', encryptedVaultKey: 'YQ==', iv: 'YWFhYWFhYWFhYQ==', + publicKey: TEST_PUBLIC_KEY, + transports: ['internal'], }; - const messenger = getPasskeyMessenger(); - const controller = new PasskeyController({ - messenger, + const controller = createController({ state: { passkeyRecord: record }, }); expect(controller.state.passkeyRecord).toStrictEqual(record); @@ -94,294 +190,288 @@ describe('PasskeyController', () => { describe('isPasskeyEnrolled', () => { it('returns false when no record is stored', () => { - const controller = new PasskeyController({ - messenger: getPasskeyMessenger(), - }); + 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 }); expect(messenger.call('PasskeyController:isPasskeyEnrolled')).toBe(false); - expect(controller.isPasskeyEnrolled()).toBe(false); + }); + }); + + describe('generateRegistrationOptions', () => { + it('returns options with PRF extension and challenge', () => { + const controller = createController(); + + const options = controller.generateRegistrationOptions({ + rp: { name: 'Test RP', id: 'test.com' }, + }); + + expect(options.rp).toStrictEqual({ name: 'Test RP', id: 'test.com' }); + 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('defaults to metamask.io rpID and MetaMask rpName', () => { + const controller = createController({ rpID: undefined }); + const options = controller.generateRegistrationOptions(); + expect(options.rp.id).toBe('metamask.io'); + expect(options.rp.name).toBe('MetaMask'); }); }); describe('generateAuthenticationOptions', () => { it('throws when passkey is not enrolled', () => { - const controller = new PasskeyController({ - messenger: getPasskeyMessenger(), - }); + const controller = createController(); expect(() => controller.generateAuthenticationOptions()).toThrow( 'Passkey is not enrolled', ); }); - }); - describe('generateRegistrationOptions', () => { - it('returns options whose challenge matches a subsequent completion flow', async () => { - const controller = new PasskeyController({ - messenger: getPasskeyMessenger(), - }); - const options = controller.generateRegistrationOptions({ - rp: { name: 'Test RP', id: 'example.com' }, - }); - expect(options.rp.name).toBe('Test RP'); - expect(options.rp.id).toBe('example.com'); - expect(options.challenge).toBeDefined(); - expect(options.user.id).toBeDefined(); - expect(options.extensions?.prf?.eval.first).toBeDefined(); - expect(options.hints).toStrictEqual(['client-device', 'hybrid']); - expect(options.pubKeyCredParams.map((param) => param.alg)).toStrictEqual([ - -8, -7, -257, - ]); + it('returns options with PRF for prf-enrolled credentials', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); - const credentialId = 'QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo='; + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(9)); + const controller = createController(); + controller.generateRegistrationOptions(); await controller.protectVaultKeyWithPasskey({ - registrationResponse: minimalRegistrationResponse( - options.challenge, - credentialId, - ), - vaultKey: 'user-encryption-key-test', + registrationResponse: minimalRegistrationResponse({ + clientExtensionResults: prfResults(prfFirst, true), + }), + vaultKey: 'k', }); - expect(controller.isPasskeyEnrolled()).toBe(true); - expect(controller.state.passkeyRecord?.credentialId).toBe(credentialId); - expect(controller.state.passkeyRecord?.derivationMethod).toBe( - 'userHandle', - ); + 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 = new PasskeyController({ - messenger: getPasskeyMessenger(), - }); + const controller = createController(); await expect( controller.protectVaultKeyWithPasskey({ - registrationResponse: minimalRegistrationResponse('x', 'y'), + registrationResponse: minimalRegistrationResponse(), vaultKey: 'k', }), ).rejects.toThrow('No active passkey registration session'); }); - it('throws when challenge verification fails', async () => { - const controller = new PasskeyController({ - messenger: getPasskeyMessenger(), + it('throws when verification fails', async () => { + mockVerifyRegistrationResponse.mockResolvedValue({ + verified: false, }); - const options = controller.generateRegistrationOptions(); + + const controller = createController(); + controller.generateRegistrationOptions(); + await expect( controller.protectVaultKeyWithPasskey({ - registrationResponse: minimalRegistrationResponse( - 'wrong-challenge', - 'QUJD', - ), + registrationResponse: minimalRegistrationResponse(), vaultKey: 'k', }), - ).rejects.toThrow('Passkey registration challenge verification failed'); - expect(options.challenge).not.toBe('wrong-challenge'); + ).rejects.toThrow('Passkey registration verification failed'); }); - it('uses prf derivation when extension results include PRF output', async () => { - const controller = new PasskeyController({ - messenger: getPasskeyMessenger(), + 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', }); - const options = controller.generateRegistrationOptions(); - const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(9)); + 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( - options.challenge, - 'UFJGRW5jcnlwdGlvbktleUlkMTI=', - { - clientExtensionResults: { - prf: { enabled: true, results: { first: prfFirst } }, - }, - }, - ), + registrationResponse: minimalRegistrationResponse({ + clientExtensionResults: prfResults(prfFirst, true), + }), vaultKey: 'vault-key-prf-path', }); expect(controller.state.passkeyRecord?.derivationMethod).toBe('prf'); - expect(controller.state.passkeyRecord?.prfSalt).toBe( - options.extensions?.prf?.eval.first, - ); + expect(controller.state.passkeyRecord?.prfSalt).toBeDefined(); }); }); describe('retrieveVaultKeyWithPasskey', () => { - it('throws when there is no authentication session', async () => { - const controller = new PasskeyController({ - messenger: getPasskeyMessenger(), - }); - const regOpts = controller.generateRegistrationOptions(); - await controller.protectVaultKeyWithPasskey({ - registrationResponse: minimalRegistrationResponse( - regOpts.challenge, - 'bm9TZXNzaW9u', - ), - vaultKey: 'k', - }); + it('throws when passkey is not enrolled', async () => { + setupAuthenticationMocks(); + const controller = createController(); await expect( controller.retrieveVaultKeyWithPasskey( - minimalAuthenticationResponse('c', 'bm9TZXNzaW9u', 'uh'), + minimalAuthenticationResponse('uh'), ), - ).rejects.toThrow('No active passkey authentication session'); + ).rejects.toThrow('Passkey is not enrolled'); }); - it('throws when challenge verification fails', async () => { - const controller = new PasskeyController({ - messenger: getPasskeyMessenger(), - }); - const regOpts = controller.generateRegistrationOptions(); + it('throws when there is no authentication session', async () => { + setupRegistrationMocks(); + const controller = createController(); + controller.generateRegistrationOptions(); await controller.protectVaultKeyWithPasskey({ - registrationResponse: minimalRegistrationResponse( - regOpts.challenge, - 'dmVyaWZ5Q3JlZA==', - ), + registrationResponse: minimalRegistrationResponse(), vaultKey: 'k', }); - const authOpts = controller.generateAuthenticationOptions(); await expect( controller.retrieveVaultKeyWithPasskey( - minimalAuthenticationResponse( - 'wrong-challenge', - 'dmVyaWZ5Q3JlZA==', - regOpts.user.id, - ), + minimalAuthenticationResponse('uh'), ), - ).rejects.toThrow('Passkey authentication challenge verification failed'); - expect(authOpts.challenge).not.toBe('wrong-challenge'); + ).rejects.toThrow('No active passkey authentication session'); }); - it('throws when userHandle derivation record lacks userHandle on assertion', async () => { - const controller = new PasskeyController({ - messenger: getPasskeyMessenger(), - }); - const regOpts = controller.generateRegistrationOptions(); + it('throws when verification fails', async () => { + setupRegistrationMocks(); + const controller = createController(); + controller.generateRegistrationOptions(); await controller.protectVaultKeyWithPasskey({ - registrationResponse: minimalRegistrationResponse( - regOpts.challenge, - 'dXNlckhhbmRsZUlk', - ), + registrationResponse: minimalRegistrationResponse(), vaultKey: 'k', }); - const authOpts = controller.generateAuthenticationOptions(); + + mockVerifyAuthenticationResponse.mockResolvedValue({ + verified: false, + authenticationInfo: {}, + }); + + controller.generateAuthenticationOptions(); await expect( controller.retrieveVaultKeyWithPasskey( - minimalAuthenticationResponse( - authOpts.challenge, - 'dXNlckhhbmRsZUlk', - undefined, - ), + minimalAuthenticationResponse('uh'), ), - ).rejects.toThrow('Passkey assertion missing required key material'); + ).rejects.toThrow('Passkey authentication verification failed'); }); - it('clears the authentication session after a successful retrieval', async () => { - const controller = new PasskeyController({ - messenger: getPasskeyMessenger(), - }); - const regOpts = controller.generateRegistrationOptions(); + 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( - regOpts.challenge, - 'c2Vzc2lvbkNsZWFy', - ), + registrationResponse: minimalRegistrationResponse({ + clientExtensionResults: prfResults(prfFirst), + }), vaultKey: 'secret', }); - const authOpts = controller.generateAuthenticationOptions(); + + controller.generateAuthenticationOptions(); await controller.retrieveVaultKeyWithPasskey( - minimalAuthenticationResponse( - authOpts.challenge, - 'c2Vzc2lvbkNsZWFy', - regOpts.user.id, - ), + minimalAuthenticationResponse(undefined, { + clientExtensionResults: prfResults(prfFirst), + }), ); await expect( controller.retrieveVaultKeyWithPasskey( - minimalAuthenticationResponse( - 'x', - 'c2Vzc2lvbkNsZWFy', - regOpts.user.id, - ), + minimalAuthenticationResponse(undefined, { + clientExtensionResults: prfResults(prfFirst), + }), ), ).rejects.toThrow('No active passkey authentication session'); }); }); describe('registration and authentication round-trip (userHandle)', () => { - it('retrieves the same vault key that was supplied at registration', async () => { - const controller = new PasskeyController({ - messenger: getPasskeyMessenger(), - }); - const regOptions = controller.generateRegistrationOptions(); - const credentialId = 'Um91bmR0cmlwQ3JlZA=='; - const vaultKey = 'roundtrip-vault-key-value'; + 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( - regOptions.challenge, - credentialId, - ), + registrationResponse: minimalRegistrationResponse(), vaultKey, }); - const authOptions = controller.generateAuthenticationOptions(); - const out = await controller.retrieveVaultKeyWithPasskey( - minimalAuthenticationResponse( - authOptions.challenge, - credentialId, - regOptions.user.id, - ), + expect(controller.state.passkeyRecord?.derivationMethod).toBe( + 'userHandle', ); - expect(out).toBe(vaultKey); - expect(controller.state.passkeyRecord).not.toBeNull(); + 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 () => { - const controller = new PasskeyController({ - messenger: getPasskeyMessenger(), - }); - const regOptions = controller.generateRegistrationOptions(); - const credentialId = 'UFJGUm91bmR0cmlwSWQ='; + 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( - regOptions.challenge, - credentialId, - { - clientExtensionResults: { - prf: { results: { first: prfFirst } }, - }, - }, - ), + registrationResponse: minimalRegistrationResponse({ + clientExtensionResults: prfResults(prfFirst), + }), vaultKey, }); - const authOptions = controller.generateAuthenticationOptions(); + controller.generateAuthenticationOptions(); const out = await controller.retrieveVaultKeyWithPasskey( - minimalAuthenticationResponse( - authOptions.challenge, - credentialId, - undefined, - { - clientExtensionResults: { - prf: { results: { first: prfFirst } }, - }, - }, - ), + minimalAuthenticationResponse(undefined, { + clientExtensionResults: prfResults(prfFirst), + }), ); expect(out).toBe(vaultKey); @@ -389,71 +479,75 @@ describe('PasskeyController', () => { }); describe('renewVaultKeyProtection', () => { - it('updates the passkey wrap when before/after vault keys match the ceremony', async () => { - const controller = new PasskeyController({ - messenger: getPasskeyMessenger(), - }); - const regOptions = controller.generateRegistrationOptions(); - const credentialId = 'UmV3cmFwQ3JlZGVudGlhbA=='; + 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( - regOptions.challenge, - credentialId, - ), + registrationResponse: minimalRegistrationResponse({ + clientExtensionResults: prfResults(prfFirst), + }), vaultKey: beforeKey, }); - const authOptions = controller.generateAuthenticationOptions(); - const authResponse = minimalAuthenticationResponse( - authOptions.challenge, - credentialId, - regOptions.user.id, - ); + controller.generateAuthenticationOptions(); const afterKey = 'vault-key-after-password'; - await controller.renewVaultKeyProtection({ - authenticationResponse: authResponse, + authenticationResponse: minimalAuthenticationResponse(undefined, { + clientExtensionResults: prfResults(prfFirst), + }), oldVaultKey: beforeKey, newVaultKey: afterKey, }); - const authOptions2 = controller.generateAuthenticationOptions(); + controller.generateAuthenticationOptions(); const unwrapped = await controller.retrieveVaultKeyWithPasskey( - minimalAuthenticationResponse( - authOptions2.challenge, - credentialId, - regOptions.user.id, - ), + minimalAuthenticationResponse(undefined, { + clientExtensionResults: prfResults(prfFirst), + }), ); expect(unwrapped).toBe(afterKey); }); - it('throws when the live vault key does not match the protected vault key', async () => { - const controller = new PasskeyController({ - messenger: getPasskeyMessenger(), - }); - const regOptions = controller.generateRegistrationOptions(); - const credentialId = 'bWlzbWF0Y2hLZXk='; + 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( - regOptions.challenge, - credentialId, - ), + registrationResponse: minimalRegistrationResponse({ + clientExtensionResults: prfResults(prfFirst), + }), vaultKey: 'actual-wrapped-key', }); - const authOptions = controller.generateAuthenticationOptions(); + controller.generateAuthenticationOptions(); await expect( controller.renewVaultKeyProtection({ - authenticationResponse: minimalAuthenticationResponse( - authOptions.challenge, - credentialId, - regOptions.user.id, - ), + authenticationResponse: minimalAuthenticationResponse(undefined, { + clientExtensionResults: prfResults(prfFirst), + }), oldVaultKey: 'wrong-expected-key', newVaultKey: 'new-key', }), @@ -465,15 +559,11 @@ describe('PasskeyController', () => { describe('removePasskey', () => { it('clears stored record and resets enrollment', async () => { - const controller = new PasskeyController({ - messenger: getPasskeyMessenger(), - }); - const opts = controller.generateRegistrationOptions(); + setupRegistrationMocks(); + const controller = createController(); + controller.generateRegistrationOptions(); await controller.protectVaultKeyWithPasskey({ - registrationResponse: minimalRegistrationResponse( - opts.challenge, - 'Y2xlYXI=', - ), + registrationResponse: minimalRegistrationResponse(), vaultKey: 'k', }); expect(controller.isPasskeyEnrolled()).toBe(true); @@ -483,4 +573,70 @@ describe('PasskeyController', () => { 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 counter:0 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, + }), + ); + }); + }); }); diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index 002b18ea359..cb72c18a2e4 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -9,18 +9,21 @@ import { randomBytes } from '@noble/ciphers/webcrypto'; import { COSEALG } from './constants'; import { decryptWithKey, deriveEncryptionKey, encryptWithKey } from './crypto'; -import { bytesToBase64URL, base64URLToBytes } from './encoding'; +import { base64URLToBytes, bytesToBase64URL } from './encoding'; import type { + PasskeyAuthenticationOptions, PasskeyAuthenticationResponse, - Base64URLString as PasskeyBase64URLString, - PasskeyRegistrationOptions, + PasskeyAuthenticationSession, PasskeyRecord, - PasskeyRegistrationSession, - PasskeyAuthenticationOptions, + PasskeyRegistrationOptions, PasskeyRegistrationResponse, - PasskeyAuthenticationSession, + PasskeyRegistrationSession, + PrfClientExtensionResults, } from './types'; -import { verifyChallengeInClientData } from './webauthn'; +import { + verifyRegistrationResponse, + verifyAuthenticationResponse, +} from './webauthn'; const controllerName = 'PasskeyController'; @@ -77,18 +80,24 @@ export class PasskeyController extends BaseController< PasskeyControllerState, PasskeyControllerMessenger > { - /** In-memory registration ceremony (not persisted, not part of `state`). */ #registrationSession: PasskeyRegistrationSession | null = null; - /** In-memory authentication ceremony challenge (not persisted). */ #authenticationSession: PasskeyAuthenticationSession | null = null; + readonly #rpID: string; + + readonly #expectedOrigin: string | string[]; + constructor({ messenger, state, + rpID, + expectedOrigin, }: { messenger: PasskeyControllerMessenger; state?: Partial; + rpID?: string; + expectedOrigin?: string | string[]; }) { super({ messenger, @@ -97,6 +106,9 @@ export class PasskeyController extends BaseController< state: { ...getDefaultPasskeyControllerState(), ...state }, }); + this.#rpID = rpID ?? 'metamask.io'; + this.#expectedOrigin = expectedOrigin ?? []; + this.messenger.registerMethodActionHandlers( this, MESSENGER_EXPOSED_METHODS, @@ -123,29 +135,27 @@ export class PasskeyController extends BaseController< } /** - * Generates passkey registration options. + * Generates passkey registration options synchronously. * * @param creationOptionsConfig - Configuration for the registration options. * @param creationOptionsConfig.rp - Configuration for the relying party. * @param creationOptionsConfig.rp.name - Name of the relying party. * @param creationOptionsConfig.rp.id - ID of the relying party. - * @returns Public key credential request options for `navigator.credentials.create`. + * @returns Public key credential creation options JSON for + * `navigator.credentials.create`. */ generateRegistrationOptions(creationOptionsConfig?: { rp?: { name?: string; id?: string }; }): PasskeyRegistrationOptions { - // create registration session - const userHandle = bytesToBase64URL(randomBytes(64)); - const prfSalt = bytesToBase64URL(randomBytes(32)); - const challenge = bytesToBase64URL(randomBytes(32)); - this.#registrationSession = { userHandle, prfSalt, challenge }; + const prfSalt = bytesToBase64URL(randomBytes(32).slice()); + const userHandle = bytesToBase64URL(randomBytes(64).slice()); + const challenge = bytesToBase64URL(randomBytes(32).slice()); + + const rpID = creationOptionsConfig?.rp?.id ?? this.#rpID; + const rpName = creationOptionsConfig?.rp?.name ?? 'MetaMask'; - // build registration options const options: PasskeyRegistrationOptions = { - rp: { - name: creationOptionsConfig?.rp?.name ?? 'MetaMask', - id: creationOptionsConfig?.rp?.id, - }, + rp: { name: rpName, id: rpID }, user: { id: userHandle, name: 'MetaMask User', @@ -157,25 +167,32 @@ export class PasskeyController extends BaseController< { alg: COSEALG.ES256, type: 'public-key' }, { alg: COSEALG.RS256, type: 'public-key' }, ], + timeout: 60000, authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred', authenticatorAttachment: 'platform', }, attestation: 'direct', - hints: ['client-device', 'hybrid'], extensions: { prf: { eval: { first: prfSalt } }, }, }; + this.#registrationSession = { + userHandle, + prfSalt, + challenge, + }; + return options; } /** - * Generates passkey authentication options. + * Generates passkey authentication options synchronously. * - * @returns Public key credential request options for `navigator.credentials.get`. + * @returns Public key credential request options JSON for + * `navigator.credentials.get`. */ generateAuthenticationOptions(): PasskeyAuthenticationOptions { const record = this.#getPasskeyRecord(); @@ -183,31 +200,41 @@ export class PasskeyController extends BaseController< throw new Error('Passkey is not enrolled'); } - // generate challenge - const challenge = bytesToBase64URL(randomBytes(32)); - this.#authenticationSession = { challenge }; + 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, - allowCredentials: [{ type: 'public-key', id: record.credentialId }], + rpId: this.#rpID, + allowCredentials: [ + { + id: record.credentialId, + type: 'public-key', + transports: record.transports, + }, + ], userVerification: 'preferred', - hints: ['client-device', 'hybrid'], + timeout: 60000, + extensions, }; - if (record.derivationMethod === 'prf' && record.prfSalt) { - options.extensions = { - prf: { eval: { first: record.prfSalt } }, - }; - } + this.#authenticationSession = { challenge }; return options; } /** - * Verifies the registration challenge in `clientDataJSON`, derives a wrapping key via HKDF (PRF output or `userHandle`), protects the supplied vault encryption key with AES-GCM, and persists `PasskeyRecord`. + * Verifies the registration response, derives a wrapping key via HKDF + * (PRF output or userHandle), protects the supplied vault encryption key + * with AES-GCM, and persists a PasskeyRecord. * * @param params - Protection parameters. - * @param params.registrationResponse - Registration result JSON from the browser ceremony. + * @param params.registrationResponse - Registration result JSON from the + * browser ceremony. * @param params.vaultKey - Vault encryption key to protect. */ async protectVaultKeyWithPasskey(params: { @@ -219,71 +246,81 @@ export class PasskeyController extends BaseController< throw new Error('No active passkey registration session'); } - // verify challenge const { registrationResponse, vaultKey } = params; - const ok = verifyChallengeInClientData( - registrationResponse.response.clientDataJSON, - session.challenge, - 'webauthn.create', - ); - if (!ok) { - throw new Error('Passkey registration challenge verification failed'); + + 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'); } - // derive encryption key from registration response + const { registrationInfo } = verification; + const { encKey, derivationMethod } = this.#deriveKeyFromRegistrationResponse(registrationResponse, session); - // encrypt vault key const { ciphertext, iv } = encryptWithKey(vaultKey, encKey); - // persist passkey record - const record: PasskeyRecord = { - credentialId: registrationResponse.id, + this.#setPasskeyRecord({ + credentialId: registrationInfo.credentialId, derivationMethod, encryptedVaultKey: ciphertext, iv, prfSalt: derivationMethod === 'prf' ? session.prfSalt : undefined, - }; - this.#setPasskeyRecord(record); + publicKey: bytesToBase64URL(registrationInfo.publicKey), + transports: registrationInfo.transports, + }); - // clear registration session this.#registrationSession = null; } /** - * Verifies the authentication challenge in `clientDataJSON`, derives a wrapping key via HKDF (PRF output or `userHandle`), decrypts the protected vault key with AES-GCM, and returns the decrypted vault key. + * Verifies the authentication response, derives a wrapping key, decrypts + * the protected vault key, and returns it. * - * @param authenticationResponse - Authentication result JSON from the browser ceremony. + * @param authenticationResponse - Authentication result JSON from the + * browser ceremony. * @returns Decrypted vault key. */ async retrieveVaultKeyWithPasskey( authenticationResponse: PasskeyAuthenticationResponse, ): Promise { - // derive encryption key + const record = this.#getPasskeyRecord(); + if (!record) { + throw new Error('Passkey is not enrolled'); + } + + await this.#verifyAuthentication(authenticationResponse, record); + const encKey = this.#deriveKeyFromAuthenticationResponse( authenticationResponse, + record, ); - // decrypt vault key - const passkeyRecord = this.#getPasskeyRecord(); - if (!passkeyRecord) { - throw new Error('Passkey is not enrolled'); - } - const { encryptedVaultKey, iv } = passkeyRecord; - const vaultKey = decryptWithKey(encryptedVaultKey, iv, encKey); + const vaultKey = decryptWithKey( + record.encryptedVaultKey, + record.iv, + encKey, + ); - // clear authentication session this.#authenticationSession = null; return vaultKey; } /** - * Verifies the authentication challenge in `clientDataJSON`, derives a wrapping key via HKDF (PRF output or `userHandle`), decrypts the protected vault key with AES-GCM, and persists the new protected vault key. + * Verifies the authentication response, re-derives the wrapping key, checks + * the old vault key matches, then re-wraps with the new vault key. * * @param params - Renewal parameters. - * @param params.authenticationResponse - Authentication result JSON from the browser ceremony. + * @param params.authenticationResponse - Authentication result JSON from the + * browser ceremony. * @param params.oldVaultKey - Serialized vault key before password change. * @param params.newVaultKey - Serialized vault key after password change. */ @@ -292,21 +329,26 @@ export class PasskeyController extends BaseController< oldVaultKey: string; newVaultKey: string; }): Promise { - // derive encryption key - const { authenticationResponse, oldVaultKey } = params; + 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 = this.#deriveKeyFromAuthenticationResponse( authenticationResponse, + record, ); - // decrypt vault key - const passkeyRecord = this.#getPasskeyRecord(); - if (!passkeyRecord) { - throw new Error('Passkey is not enrolled'); - } - const { encryptedVaultKey, iv } = passkeyRecord; - const decryptedVaultKey = decryptWithKey(encryptedVaultKey, iv, encKey); + const decryptedVaultKey = decryptWithKey( + record.encryptedVaultKey, + record.iv, + encKey, + ); - // verify old vault key if (decryptedVaultKey !== oldVaultKey) { this.#authenticationSession = null; throw new Error( @@ -314,23 +356,20 @@ export class PasskeyController extends BaseController< ); } - // encrypt new vault key - const { newVaultKey } = params; const { ciphertext, iv: newIv } = encryptWithKey(newVaultKey, encKey); - // persist new passkey record this.#setPasskeyRecord({ - ...passkeyRecord, + ...record, encryptedVaultKey: ciphertext, iv: newIv, }); - // clear authentication session this.#authenticationSession = null; } /** - * Clears the passkey record and resets the registration and authentication sessions. + * Clears the passkey record and resets the registration and authentication + * sessions. */ removePasskey(): void { this.update((state) => { @@ -340,10 +379,45 @@ export class PasskeyController extends BaseController< this.#authenticationSession = null; } + /** + * Verifies the authentication response using full WebAuthn verification. + * + * @param authenticationResponse - Authentication result JSON. + * @param record - The stored passkey record containing publicKey. + */ + 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: 0, + transports: record.transports, + }, + requireUserVerification: false, + }); + + if (!verification.verified) { + throw new Error('Passkey authentication verification failed'); + } + } + /** * Derives the encryption key from the registration response. * - * @param registrationResponse - Registration result JSON from the browser ceremony. + * @param registrationResponse - Registration result JSON from the browser + * ceremony. * @param session - Registration session. * @returns Derived encryption key and derivation method. */ @@ -355,53 +429,40 @@ export class PasskeyController extends BaseController< derivationMethod: 'prf' | 'userHandle'; } { const credentialId = registrationResponse.id; - const prf = registrationResponse.clientExtensionResults?.prf; + 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 PasskeyBase64URLString) + ? base64URLToBytes(prfFirst as string) : base64URLToBytes(session.userHandle); const encKey = deriveEncryptionKey(ikm, base64URLToBytes(credentialId)); return { encKey, derivationMethod }; } /** - * Verifies the WebAuthn authentication challenge and derives the encryption key - * for the enrolled credential. + * Derives the encryption key from the authentication response. * - * @param authenticationResponse - Authentication result JSON from the browser ceremony. - * @returns Derived encryption key for decrypt/re-encrypt operations. + * @param authenticationResponse - Authentication result JSON. + * @param record - The stored passkey record. + * @returns Derived encryption key. */ #deriveKeyFromAuthenticationResponse( authenticationResponse: PasskeyAuthenticationResponse, + record: PasskeyRecord, ): Uint8Array { - const session = this.#authenticationSession; - if (!session) { - throw new Error('No active passkey authentication session'); - } - const record = this.#getPasskeyRecord(); - if (!record) { - throw new Error('Passkey is not enrolled'); - } - - const ok = verifyChallengeInClientData( - authenticationResponse.response.clientDataJSON, - session.challenge, - 'webauthn.get', - ); - if (!ok) { - throw new Error('Passkey authentication challenge verification failed'); - } const { userHandle } = authenticationResponse.response; - const prfFirst = - authenticationResponse.clientExtensionResults?.prf?.results?.first; + const prfFirst = ( + authenticationResponse.clientExtensionResults as PrfClientExtensionResults + )?.prf?.results?.first; let ikm: Uint8Array; if (record.derivationMethod === 'prf') { - ikm = base64URLToBytes(prfFirst as PasskeyBase64URLString); + ikm = base64URLToBytes(prfFirst as string); } else if (userHandle) { ikm = base64URLToBytes(userHandle); } else { diff --git a/packages/passkey-controller/src/constants.ts b/packages/passkey-controller/src/constants.ts index 5967555b602..13c3ee706fb 100644 --- a/packages/passkey-controller/src/constants.ts +++ b/packages/passkey-controller/src/constants.ts @@ -1,9 +1,7 @@ -export const PASSKEY_HKDF_INFO = 'MetaMask-passkey-encryption-v1'; - /** * COSE Algorithms * - * https://www.iana.org/assignments/cose/cose.xhtml#algorithms + * @see https://www.iana.org/assignments/cose/cose.xhtml#algorithms */ export enum COSEALG { ES256 = -7, @@ -19,3 +17,56 @@ export enum COSEALG { 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/crypto.test.ts b/packages/passkey-controller/src/crypto.test.ts index 9e6d270854e..754a272c1cb 100644 --- a/packages/passkey-controller/src/crypto.test.ts +++ b/packages/passkey-controller/src/crypto.test.ts @@ -25,7 +25,7 @@ describe('crypto', () => { new Uint8Array(8).fill(4), ); const { ciphertext, iv } = encryptWithKey('secret', keyA); - expect(() => decryptWithKey(ciphertext, iv, keyB)).toThrow(); + expect(() => decryptWithKey(ciphertext, iv, keyB)).toThrow('aes/gcm'); }); }); }); diff --git a/packages/passkey-controller/src/crypto.ts b/packages/passkey-controller/src/crypto.ts index 7245ad9a6d8..eb331609aae 100644 --- a/packages/passkey-controller/src/crypto.ts +++ b/packages/passkey-controller/src/crypto.ts @@ -4,7 +4,7 @@ import { randomBytes } from '@noble/ciphers/webcrypto'; import { hkdf } from '@noble/hashes/hkdf'; import { sha256 } from '@noble/hashes/sha2'; -import { PASSKEY_HKDF_INFO } from './constants'; +const PASSKEY_HKDF_INFO = 'metamask:passkey:encryption-key:v1'; const AES_GCM_IV_LENGTH = 12; diff --git a/packages/passkey-controller/src/encoding.test.ts b/packages/passkey-controller/src/encoding.test.ts index 5b776ba3869..beed5b7a572 100644 --- a/packages/passkey-controller/src/encoding.test.ts +++ b/packages/passkey-controller/src/encoding.test.ts @@ -1,17 +1,48 @@ import { bytesToBase64URL, base64URLToBytes } from './encoding'; describe('encoding', () => { - describe('bytesToBase64URL / base64URLToBytes', () => { - it('round-trips random bytes', () => { - const input = new Uint8Array([0, 255, 128, 1, 2, 3]); - const encoded = bytesToBase64URL(input); - expect(encoded).not.toMatch(/[+/=]/u); - expect(base64URLToBytes(encoded)).toStrictEqual(input); + describe('bytesToBase64URL', () => { + it('encodes an empty array', () => { + expect(bytesToBase64URL(new Uint8Array([]))).toBe(''); }); - it('round-trips empty input', () => { - const encoded = bytesToBase64URL(new Uint8Array(0)); - expect(base64URLToBytes(encoded)).toStrictEqual(new Uint8Array(0)); + 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/encoding.ts b/packages/passkey-controller/src/encoding.ts index ca190e7926d..03f2ba14ef0 100644 --- a/packages/passkey-controller/src/encoding.ts +++ b/packages/passkey-controller/src/encoding.ts @@ -1,15 +1,38 @@ -import { base64ToBytes, bytesToBase64 } from '@metamask/utils'; +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 { - const base64 = bytesToBase64(bytes); - return base64.replace(/\+/gu, '-').replace(/\//gu, '_').replace(/[=]+$/u, ''); + return bytesToBase64(bytes) + .replace(/\+/gu, '-') + .replace(/\//gu, '_') + .replace(/[=]+$/u, ''); } -export function base64URLToBytes(input: string): Uint8Array { - let base64 = input.replace(/-/gu, '+').replace(/_/gu, '/'); - const pad = base64.length % 4; - if (pad !== 0) { - base64 += '='.repeat(4 - pad); - } - return base64ToBytes(base64); +/** + * 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/helpers/decodeAttestationObject.ts b/packages/passkey-controller/src/helpers/decodeAttestationObject.ts new file mode 100644 index 00000000000..d798d964626 --- /dev/null +++ b/packages/passkey-controller/src/helpers/decodeAttestationObject.ts @@ -0,0 +1,38 @@ +import { decodePartialCBOR } from '@levischuck/tiny-cbor'; + +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; +}; + +/** + * 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/helpers/decodeClientDataJSON.ts b/packages/passkey-controller/src/helpers/decodeClientDataJSON.ts new file mode 100644 index 00000000000..23886eaa198 --- /dev/null +++ b/packages/passkey-controller/src/helpers/decodeClientDataJSON.ts @@ -0,0 +1,24 @@ +import { base64URLToBytes } from '../encoding'; + +export type ClientDataJSON = { + type: string; + challenge: string; + origin: string; + crossOrigin?: boolean; + tokenBinding?: { + id?: string; + status: 'present' | 'supported' | 'not-supported'; + }; +}; + +/** + * 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/helpers/index.ts b/packages/passkey-controller/src/helpers/index.ts new file mode 100644 index 00000000000..735b8347fb6 --- /dev/null +++ b/packages/passkey-controller/src/helpers/index.ts @@ -0,0 +1,17 @@ +export { + decodeClientDataJSON, + type ClientDataJSON, +} from './decodeClientDataJSON'; +export { + parseAuthenticatorData, + type ParsedAuthenticatorData, + type AuthenticatorDataFlags, +} from './parseAuthenticatorData'; +export { + decodeAttestationObject, + type AttestationObject, + type AttestationStatement, + type AttestationFormat, +} from './decodeAttestationObject'; +export { verifySignature } from './verifySignature'; +export { matchExpectedRPID } from './matchExpectedRPID'; diff --git a/packages/passkey-controller/src/helpers/matchExpectedRPID.ts b/packages/passkey-controller/src/helpers/matchExpectedRPID.ts new file mode 100644 index 00000000000..88bdb702a1e --- /dev/null +++ b/packages/passkey-controller/src/helpers/matchExpectedRPID.ts @@ -0,0 +1,44 @@ +import { sha256 } from '@noble/hashes/sha2'; + +import { bytesToHex } from '../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/helpers/parseAuthenticatorData.ts b/packages/passkey-controller/src/helpers/parseAuthenticatorData.ts new file mode 100644 index 00000000000..827783e8137 --- /dev/null +++ b/packages/passkey-controller/src/helpers/parseAuthenticatorData.ts @@ -0,0 +1,114 @@ +import { decodePartialCBOR } from '@levischuck/tiny-cbor'; + +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; +}; + +/* 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 extensionsDataBuffer = authData.slice(pointer); + const [decoded] = decodePartialCBOR( + new Uint8Array(extensionsDataBuffer), + 0, + ) as [Map, number]; + result.extensionsData = decoded; + result.extensionsDataBuffer = extensionsDataBuffer; + } + + return result; +} + +/* eslint-enable no-bitwise */ diff --git a/packages/passkey-controller/src/helpers/verifySignature.ts b/packages/passkey-controller/src/helpers/verifySignature.ts new file mode 100644 index 00000000000..348aa4ccb17 --- /dev/null +++ b/packages/passkey-controller/src/helpers/verifySignature.ts @@ -0,0 +1,184 @@ +import { ed25519 } from '@noble/curves/ed25519'; +import { p256, p384 } from '@noble/curves/nist'; +import { sha256 } from '@noble/hashes/sha2'; + +import { COSEALG, COSECRV, COSEKEYS, COSEKTY } from '../constants'; +import { bytesToBase64URL } from '../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. + * + * @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); + const hash = sha256(data); + + switch (crv) { + case COSECRV.P256: + return p256.verify(signature, hash, uncompressed); + case COSECRV.P384: + return p384.verify(signature, hash, 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/index.ts b/packages/passkey-controller/src/index.ts index a1b9c37e380..d425658c675 100644 --- a/packages/passkey-controller/src/index.ts +++ b/packages/passkey-controller/src/index.ts @@ -6,7 +6,6 @@ export type { PasskeyControllerState, PasskeyControllerMessenger, } from './PasskeyController'; -export * from './constants'; -export * from './encoding'; export type * from './types'; -export * from './webauthn'; +export { bytesToBase64URL, base64URLToBytes } from './encoding'; +export { COSEALG, COSEKEYS, COSEKTY, COSECRV } from './constants'; diff --git a/packages/passkey-controller/src/types.ts b/packages/passkey-controller/src/types.ts index 8e188bed50f..ea7bb284236 100644 --- a/packages/passkey-controller/src/types.ts +++ b/packages/passkey-controller/src/types.ts @@ -2,8 +2,19 @@ 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 */ + /** WebAuthn credential ID (base64url) */ credentialId: Base64URLString; /** PRF or userHandle */ derivationMethod: PasskeyDerivationMethod; @@ -13,6 +24,10 @@ export type PasskeyRecord = { prfSalt?: Base64URLString; /** vault key encrypted with passkey-derived key */ encryptedVaultKey: Base64String; + /** Credential public key for signature verification (base64url-encoded COSE key) */ + publicKey: Base64URLString; + /** Authenticator transports for allowCredentials hints */ + transports?: AuthenticatorTransportFuture[]; }; /** In-memory registration session: creation material + RP challenge bytes. */ @@ -27,135 +42,86 @@ export type PasskeyAuthenticationSession = { challenge: Base64URLString; }; -/** WebAuthn types */ - -export type Base64URLString = string; - -export type ResidentKeyRequirement = 'discouraged' | 'preferred' | 'required'; -export type UserVerificationRequirement = - | 'discouraged' - | 'preferred' - | 'required'; -export type AuthenticatorAttachment = 'cross-platform' | 'platform'; -export type AttestationConveyancePreference = - | 'direct' - | 'enterprise' - | 'indirect' - | 'none'; -export type PublicKeyCredentialHint = - | 'hybrid' - | 'security-key' - | 'client-device'; - -export type PublicKeyCredentialRpEntity = { - id?: string; - name: string; -}; - -export type COSEAlgorithmIdentifier = number; -export type PublicKeyCredentialType = 'public-key'; - -export type PublicKeyCredentialParameters = { - alg: COSEAlgorithmIdentifier; - type: PublicKeyCredentialType; +/** + * PRF extension types not covered by DOM typings. + */ +export type PrfEvalExtension = { + eval: { + first: Base64URLString; + }; }; -export type PublicKeyCredentialUserEntity = { - id: Base64URLString; - name: string; - displayName: string; +export type PrfClientExtensionResults = { + prf?: { + enabled?: boolean; + results?: { first?: Base64URLString }; + }; }; -export type AuthenticatorSelectionCriteria = { - authenticatorAttachment?: AuthenticatorAttachment; - requireResidentKey?: boolean; - residentKey?: ResidentKeyRequirement; - userVerification?: UserVerificationRequirement; -}; +// ─── WebAuthn Options/Response JSON types ──────────────────────────────────── -export type PublicKeyCredentialDescriptor = { +export type PublicKeyCredentialDescriptorJSON = { id: Base64URLString; type: 'public-key'; -}; - -export type PrfEvalExtension = { - eval: { - first: Base64URLString; - }; -}; - -export type AuthenticationExtensionsClientInputs = { - prf?: PrfEvalExtension; + transports?: AuthenticatorTransportFuture[]; }; export type PasskeyRegistrationOptions = { - rp: PublicKeyCredentialRpEntity; - user: PublicKeyCredentialUserEntity; + rp: { name: string; id: string }; + user: { + id: Base64URLString; + name: string; + displayName: string; + }; challenge: Base64URLString; - pubKeyCredParams: PublicKeyCredentialParameters[]; + pubKeyCredParams: { alg: number; type: 'public-key' }[]; timeout?: number; - excludeCredentials?: PublicKeyCredentialDescriptor[]; - authenticatorSelection?: AuthenticatorSelectionCriteria; - attestation?: AttestationConveyancePreference; - /** WebAuthn L3 credential hints (`client-device`, `hybrid`, etc.). */ - hints?: string[]; - extensions?: AuthenticationExtensionsClientInputs; + 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 AuthenticatorTransportFuture = string; - export type PasskeyRegistrationResponse = { id: Base64URLString; rawId: Base64URLString; + type: 'public-key'; response: { clientDataJSON: Base64URLString; attestationObject: Base64URLString; - transports?: AuthenticatorTransportFuture[]; - }; - type: string; - clientExtensionResults?: { - prf?: { - enabled?: boolean; - results?: { first?: Base64URLString }; - }; + transports?: string[]; + publicKeyAlgorithm?: number; + publicKey?: Base64URLString; + authenticatorData?: Base64URLString; }; + authenticatorAttachment?: 'cross-platform' | 'platform'; + clientExtensionResults: Record; }; export type PasskeyAuthenticationOptions = { challenge: Base64URLString; timeout?: number; rpId?: string; - allowCredentials?: PublicKeyCredentialDescriptor[]; - userVerification?: UserVerificationRequirement; - hints?: PublicKeyCredentialHint[]; - extensions?: AuthenticationExtensionsClientInputs; + allowCredentials?: PublicKeyCredentialDescriptorJSON[]; + userVerification?: 'discouraged' | 'preferred' | 'required'; + extensions?: Record; }; export type PasskeyAuthenticationResponse = { id: Base64URLString; rawId: Base64URLString; + type: 'public-key'; response: { clientDataJSON: Base64URLString; authenticatorData: Base64URLString; signature: Base64URLString; userHandle?: Base64URLString; }; - type: string; - clientExtensionResults?: { - prf?: { - enabled?: boolean; - results?: { first?: Base64URLString }; - }; - }; -}; - -export type ClientDataJSON = { - type: string; - challenge: string; - origin: string; - crossOrigin?: boolean; - tokenBinding?: { - id?: string; - status: 'present' | 'supported' | 'not-supported'; - }; + authenticatorAttachment?: 'cross-platform' | 'platform'; + clientExtensionResults: Record; }; diff --git a/packages/passkey-controller/src/webauthn.test.ts b/packages/passkey-controller/src/webauthn.test.ts index 61852eeed10..41f2c39d656 100644 --- a/packages/passkey-controller/src/webauthn.test.ts +++ b/packages/passkey-controller/src/webauthn.test.ts @@ -1,82 +1,1716 @@ +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 } from '@noble/hashes/sha2'; + +import { COSEALG, COSEKEYS, COSEKTY, COSECRV } from './constants'; import { bytesToBase64URL } from './encoding'; +import type { + PasskeyRegistrationResponse, + PasskeyAuthenticationResponse, +} from './types'; import { - verifyChallengeInClientData, - webauthnWireBinaryToBytes, + verifyRegistrationResponse, + verifyAuthenticationResponse, } from './webauthn'; -function clientDataToWire( - type: 'webauthn.create' | 'webauthn.get', - challenge: string, +// --------------------------------------------------------------------------- +// 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, - challenge, - origin: 'https://example.test', + type: overrides?.type ?? 'webauthn.create', + challenge: overrides?.challenge ?? TEST_CHALLENGE, + origin: overrides?.origin ?? TEST_ORIGIN, }); return bytesToBase64URL(new TextEncoder().encode(json)); } -describe('webauthn', () => { - describe('webauthnWireBinaryToBytes', () => { - it('accepts base64url string', () => { - const wire = bytesToBase64URL(new TextEncoder().encode('hello')); - expect(new TextDecoder().decode(webauthnWireBinaryToBytes(wire))).toBe( - 'hello', - ); +/** + * 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, }); - it('accepts ArrayBuffer', () => { - const arrayBuffer = new Uint8Array([1, 2, 3]).buffer; - expect(webauthnWireBinaryToBytes(arrayBuffer)).toStrictEqual( - new Uint8Array([1, 2, 3]), - ); + 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, }); - it('accepts Uint8Array view', () => { - const u8 = new Uint8Array([9, 8, 7]); - expect(webauthnWireBinaryToBytes(u8)).toStrictEqual(u8); + 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, }); - it('accepts numeric arrays as byte sequences', () => { - expect(webauthnWireBinaryToBytes([10, 20, 30])).toStrictEqual( - new Uint8Array([10, 20, 30]), - ); + 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, }); - it('throws on unsupported wire shape', () => { - expect(() => webauthnWireBinaryToBytes(123)).toThrow(TypeError); + 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)); - describe('verifyChallengeInClientData', () => { - it('returns true when type and challenge match', () => { - const challenge = 'test-challenge-b64url'; - const wire = clientDataToWire('webauthn.create', challenge); - expect( - verifyChallengeInClientData(wire, challenge, 'webauthn.create'), - ).toBe(true); + // flags: UP (0x01) + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x01, + counter: 1, }); - it('returns false when challenge differs', () => { - const wire = clientDataToWire('webauthn.get', 'expected'); - expect(verifyChallengeInClientData(wire, 'other', 'webauthn.get')).toBe( - false, - ); + 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, + }, }); - it('returns false when type differs', () => { - const challenge = 'c'; - const wire = clientDataToWire('webauthn.create', challenge); - expect(verifyChallengeInClientData(wire, challenge, 'webauthn.get')).toBe( - false, - ); + 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, }); - it('returns false on invalid JSON', () => { - const wire = bytesToBase64URL(new TextEncoder().encode('not-json')); - expect(verifyChallengeInClientData(wire, 'x', 'webauthn.create')).toBe( - false, - ); + 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); + }); +}); + +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)); + + // Build authData with AT flag but missing credential data by building manually + // flags: UP (0x01) | AT (0x40) = 0x41 but credentialID present, no pubkey + // This is tested via the "no credential ID" path with no AT flag + // Instead, let's test for missing AAGUID + 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 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('./helpers/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 = sha256(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('./helpers/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); + }); +}); + +describe('matchExpectedRPID edge cases', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports, n/global-require + const { matchExpectedRPID } = require('./helpers/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', () => { + const rpIdHash = sha256(new TextEncoder().encode('example.com')); + expect(() => matchExpectedRPID(rpIdHash, ['x.com'])).toThrow( + 'Unexpected RP ID hash', + ); + }); +}); + +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('./helpers/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/src/webauthn.ts b/packages/passkey-controller/src/webauthn.ts index e2445ef5302..eabadc77d8d 100644 --- a/packages/passkey-controller/src/webauthn.ts +++ b/packages/passkey-controller/src/webauthn.ts @@ -1,23 +1,413 @@ -import { base64URLToBytes } from './encoding'; -import type { Base64URLString as PasskeyBase64URLString } from './types'; +import { decodePartialCBOR } from '@levischuck/tiny-cbor'; +import { sha256 } from '@noble/hashes/sha2'; -export function verifyChallengeInClientData( - clientDataJSON: PasskeyBase64URLString, - expectedChallenge: PasskeyBase64URLString, - expectedType: 'webauthn.create' | 'webauthn.get', -): boolean { - let parsed: { type?: unknown; challenge?: unknown }; - try { - const jsonBytes = base64URLToBytes(clientDataJSON); - const jsonText = new TextDecoder().decode(jsonBytes); - parsed = JSON.parse(jsonText) as { type?: unknown; challenge?: unknown }; - } catch { - return false; +import { COSEALG, COSEKEYS } from './constants'; +import { base64URLToBytes, bytesToBase64URL, bytesToHex } from './encoding'; +import { decodeAttestationObject } from './helpers/decodeAttestationObject'; +import { decodeClientDataJSON } from './helpers/decodeClientDataJSON'; +import { matchExpectedRPID } from './helpers/matchExpectedRPID'; +import { parseAuthenticatorData } from './helpers/parseAuthenticatorData'; +import type { ParsedAuthenticatorData } from './helpers/parseAuthenticatorData'; +import { verifySignature } from './helpers/verifySignature'; +import type { + PasskeyRegistrationResponse, + PasskeyAuthenticationResponse, + AuthenticatorTransportFuture, +} from './types'; + +// ─── Registration ──────────────────────────────────────────────────────────── + +export type VerifiedRegistrationResponse = + | { verified: false; registrationInfo?: never } + | { + verified: true; + registrationInfo: { + credentialId: string; + publicKey: Uint8Array; + counter: number; + transports?: AuthenticatorTransportFuture[]; + aaguid: string; + attestationFormat: string; + userVerified: boolean; + }; + }; + +/** + * Verify a WebAuthn registration response per W3C WebAuthn Level 3 §7.1. + * + * Checks: credential ID/type, clientDataJSON (type, challenge, origin), + * attestation object (rpIdHash, flags, algorithm, attestation format), + * and packed self-attestation signature when applicable. + * + * @param opts - Verification options. + * @param opts.response - The registration response from the authenticator. + * @param opts.expectedChallenge - The expected challenge string. + * @param opts.expectedOrigin - The expected origin(s). + * @param opts.expectedRPID - The expected Relying Party ID. + * @param opts.requireUserVerification - Whether UV must be set. + * @param opts.supportedAlgorithmIDs - Allowed COSE algorithm identifiers. + * @returns Verification result with credential info on success. + */ +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 attestation (self-attestation case: no x5c, signature over + * authData || SHA-256(clientDataJSON) with the credential key itself). + * + * @param attStmt - The attestation statement map. + * @param attStmt.get - Map accessor for statement fields. + * @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. + * @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 signature = attStmt.get('sig') as Uint8Array | undefined; + const x5c = attStmt.get('x5c') as Uint8Array[] | undefined; + + 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 clientDataHash = sha256(base64URLToBytes(clientDataJSONB64url)); + const signatureBase = concatUint8Arrays(authData, clientDataHash); + + return verifySignature({ + cosePublicKey, + signature, + data: signatureBase, + }); +} + +// ─── Authentication ────────────────────────────────────────────────────────── + +export type VerifiedAuthenticationResponse = { + verified: boolean; + authenticationInfo: { + credentialId: string; + newCounter: number; + userVerified: boolean; + origin: string; + rpID: string; + }; +}; + +/** + * Verify a WebAuthn authentication response per W3C WebAuthn Level 3 §7.2. + * + * Checks: credential ID/type, clientDataJSON (type, challenge, origin), + * authenticatorData (rpIdHash, flags), counter monotonicity, and signature + * verification against the stored credential public key. + * + * @param opts - Verification options. + * @param opts.response - The authentication response from the authenticator. + * @param opts.expectedChallenge - The expected challenge string. + * @param opts.expectedOrigin - The expected origin(s). + * @param opts.expectedRPID - The expected Relying Party ID. + * @param opts.credential - The stored credential to verify against. + * @param opts.credential.id - The credential ID. + * @param opts.credential.publicKey - The COSE-encoded public key bytes. + * @param opts.credential.counter - The last known counter value. + * @param opts.credential.transports - Optional authenticator transports. + * @param opts.requireUserVerification - Whether UV must be set. + * @returns Verification result with authentication info. + */ +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 (parsed.type !== expectedType || typeof parsed.challenge !== 'string') { - return false; + if (requireUserVerification && !flags.uv) { + throw new Error( + 'User verification required, but user could not be verified', + ); } - return parsed.challenge === expectedChallenge; + 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, + }, + }; +} + +// ─── Utilities ─────────────────────────────────────────────────────────────── + +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/yarn.lock b/yarn.lock index 9145ff65377..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" @@ -4426,11 +4433,13 @@ __metadata: 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" @@ -5347,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: From 9dfd697d9288707f908c14ac330b7d6c5eb94c84 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Thu, 16 Apr 2026 09:52:50 +0700 Subject: [PATCH 07/44] feat: add passkey verification and refactor code --- .../src/PasskeyController.test.ts | 72 ++- .../src/PasskeyController.ts | 223 +++++----- .../passkey-controller/src/helpers/index.ts | 17 - packages/passkey-controller/src/index.ts | 8 +- .../src/key-derivation.test.ts | 223 ++++++++++ .../passkey-controller/src/key-derivation.ts | 86 ++++ packages/passkey-controller/src/types.ts | 70 +-- .../src/{ => utils}/crypto.test.ts | 0 .../src/{ => utils}/crypto.ts | 0 .../src/{ => utils}/encoding.test.ts | 0 .../src/{ => utils}/encoding.ts | 0 .../passkey-controller/src/utils/index.ts | 2 + packages/passkey-controller/src/webauthn.ts | 413 ------------------ .../src/{ => webauthn}/constants.ts | 0 .../decodeAttestationObject.ts | 22 +- .../decodeClientDataJSON.ts | 14 +- .../passkey-controller/src/webauthn/index.ts | 15 + .../matchExpectedRPID.ts | 2 +- .../parseAuthenticatorData.ts | 34 +- .../passkey-controller/src/webauthn/types.ts | 124 ++++++ .../webauthn/verifyAuthenticationResponse.ts | 183 ++++++++ .../webauthn/verifyRegistrationResponse.ts | 289 ++++++++++++ .../{helpers => webauthn}/verifySignature.ts | 14 +- .../src/{ => webauthn}/webauthn.test.ts | 209 ++++++++- 24 files changed, 1328 insertions(+), 692 deletions(-) delete mode 100644 packages/passkey-controller/src/helpers/index.ts create mode 100644 packages/passkey-controller/src/key-derivation.test.ts create mode 100644 packages/passkey-controller/src/key-derivation.ts rename packages/passkey-controller/src/{ => utils}/crypto.test.ts (100%) rename packages/passkey-controller/src/{ => utils}/crypto.ts (100%) rename packages/passkey-controller/src/{ => utils}/encoding.test.ts (100%) rename packages/passkey-controller/src/{ => utils}/encoding.ts (100%) create mode 100644 packages/passkey-controller/src/utils/index.ts delete mode 100644 packages/passkey-controller/src/webauthn.ts rename packages/passkey-controller/src/{ => webauthn}/constants.ts (100%) rename packages/passkey-controller/src/{helpers => webauthn}/decodeAttestationObject.ts (51%) rename packages/passkey-controller/src/{helpers => webauthn}/decodeClientDataJSON.ts (59%) create mode 100644 packages/passkey-controller/src/webauthn/index.ts rename packages/passkey-controller/src/{helpers => webauthn}/matchExpectedRPID.ts (96%) rename packages/passkey-controller/src/{helpers => webauthn}/parseAuthenticatorData.ts (77%) create mode 100644 packages/passkey-controller/src/webauthn/types.ts create mode 100644 packages/passkey-controller/src/webauthn/verifyAuthenticationResponse.ts create mode 100644 packages/passkey-controller/src/webauthn/verifyRegistrationResponse.ts rename packages/passkey-controller/src/{helpers => webauthn}/verifySignature.ts (91%) rename packages/passkey-controller/src/{ => webauthn}/webauthn.test.ts (88%) diff --git a/packages/passkey-controller/src/PasskeyController.test.ts b/packages/passkey-controller/src/PasskeyController.test.ts index 1a811992b1f..625a43e9107 100644 --- a/packages/passkey-controller/src/PasskeyController.test.ts +++ b/packages/passkey-controller/src/PasskeyController.test.ts @@ -5,12 +5,11 @@ import { PasskeyController, } from './PasskeyController'; import type { PasskeyControllerMessenger } from './PasskeyController'; +import type { PasskeyRecord, PrfClientExtensionResults } from './types'; import type { - PasskeyRecord, - PrfClientExtensionResults, PasskeyRegistrationResponse, PasskeyAuthenticationResponse, -} from './types'; +} from './webauthn'; type ExtOutputsWithPrf = Record & PrfClientExtensionResults; @@ -29,6 +28,7 @@ const mockVerifyRegistrationResponse = jest.fn(); const mockVerifyAuthenticationResponse = jest.fn(); jest.mock('./webauthn', () => ({ + ...jest.requireActual('./webauthn'), verifyRegistrationResponse: (...args: unknown[]): unknown => mockVerifyRegistrationResponse(...args), verifyAuthenticationResponse: (...args: unknown[]): unknown => @@ -179,6 +179,7 @@ describe('PasskeyController', () => { encryptedVaultKey: 'YQ==', iv: 'YWFhYWFhYWFhYQ==', publicKey: TEST_PUBLIC_KEY, + counter: 0, transports: ['internal'], }; const controller = createController({ @@ -599,7 +600,7 @@ describe('PasskeyController', () => { }); describe('verifyAuthenticationResponse parameters', () => { - it('passes credential with publicKey and counter:0 to verification', async () => { + it('passes credential with publicKey and stored counter to verification', async () => { setupRegistrationMocks(); setupAuthenticationMocks(); @@ -638,5 +639,68 @@ describe('PasskeyController', () => { }), ); }); + + 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 index cb72c18a2e4..fe024b0cfd1 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -7,22 +7,27 @@ import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; import { randomBytes } from '@noble/ciphers/webcrypto'; -import { COSEALG } from './constants'; -import { decryptWithKey, deriveEncryptionKey, encryptWithKey } from './crypto'; -import { base64URLToBytes, bytesToBase64URL } from './encoding'; +import { + deriveKeyFromAuthenticationResponse, + deriveKeyFromRegistrationResponse, +} from './key-derivation'; import type { - PasskeyAuthenticationOptions, - PasskeyAuthenticationResponse, PasskeyAuthenticationSession, PasskeyRecord, - PasskeyRegistrationOptions, - PasskeyRegistrationResponse, PasskeyRegistrationSession, - PrfClientExtensionResults, } from './types'; +import { decryptWithKey, encryptWithKey } from './utils/crypto'; +import { base64URLToBytes, bytesToBase64URL } from './utils/encoding'; import { - verifyRegistrationResponse, + COSEALG, verifyAuthenticationResponse, + verifyRegistrationResponse, +} from './webauthn'; +import type { + PasskeyAuthenticationOptions, + PasskeyAuthenticationResponse, + PasskeyRegistrationOptions, + PasskeyRegistrationResponse, } from './webauthn'; const controllerName = 'PasskeyController'; @@ -62,6 +67,11 @@ export type PasskeyControllerMessenger = Messenger< 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 }; } @@ -75,6 +85,18 @@ const passkeyControllerMetadata = { }, } 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, @@ -135,14 +157,21 @@ export class PasskeyController extends BaseController< } /** - * Generates passkey registration options synchronously. + * Builds a `PublicKeyCredentialCreationOptions` object for the browser + * WebAuthn `navigator.credentials.create()` call. + * + * Generates fresh random values for the challenge, userHandle, and PRF + * salt, then stores them in an in-memory registration session so they + * can be verified later in {@link protectVaultKeyWithPasskey}. * - * @param creationOptionsConfig - Configuration for the registration options. - * @param creationOptionsConfig.rp - Configuration for the relying party. - * @param creationOptionsConfig.rp.name - Name of the relying party. - * @param creationOptionsConfig.rp.id - ID of the relying party. - * @returns Public key credential creation options JSON for - * `navigator.credentials.create`. + * @param creationOptionsConfig - Optional overrides for the relying + * party identity. + * @param creationOptionsConfig.rp - Relying party configuration. + * @param creationOptionsConfig.rp.name - Display name shown to the user + * during the ceremony (defaults to `"MetaMask"`). + * @param creationOptionsConfig.rp.id - RP ID domain (defaults to the + * value passed to the constructor). + * @returns Options JSON ready to pass to `navigator.credentials.create()`. */ generateRegistrationOptions(creationOptionsConfig?: { rp?: { name?: string; id?: string }; @@ -158,8 +187,8 @@ export class PasskeyController extends BaseController< rp: { name: rpName, id: rpID }, user: { id: userHandle, - name: 'MetaMask User', - displayName: 'MetaMask', + name: 'MetaMask Wallet', + displayName: 'MetaMask Wallet', }, challenge, pubKeyCredParams: [ @@ -189,10 +218,15 @@ export class PasskeyController extends BaseController< } /** - * Generates passkey authentication options synchronously. + * Builds a `PublicKeyCredentialRequestOptions` object for the browser + * WebAuthn `navigator.credentials.get()` call. * - * @returns Public key credential request options JSON for - * `navigator.credentials.get`. + * Generates a fresh challenge and stores it in an in-memory + * authentication session for later verification in + * {@link retrieveVaultKeyWithPasskey} or {@link renewVaultKeyProtection}. + * + * @returns Options JSON ready to pass to `navigator.credentials.get()`. + * @throws If no passkey is currently enrolled. */ generateAuthenticationOptions(): PasskeyAuthenticationOptions { const record = this.#getPasskeyRecord(); @@ -228,14 +262,25 @@ export class PasskeyController extends BaseController< } /** - * Verifies the registration response, derives a wrapping key via HKDF - * (PRF output or userHandle), protects the supplied vault encryption key - * with AES-GCM, and persists a PasskeyRecord. + * Completes passkey enrollment by verifying the registration response, + * wrapping the vault key, and persisting the credential. + * + * Steps performed: + * 1. Verifies the authenticator's registration response (challenge, + * origin, RP ID, attestation). + * 2. Derives an AES-256 wrapping key via HKDF from the PRF output or + * the random userHandle. + * 3. Encrypts the vault key with AES-256-GCM using the derived key. + * 4. Persists a {@link PasskeyRecord} with the encrypted vault key, + * credential public key, and derivation metadata. * * @param params - Protection parameters. - * @param params.registrationResponse - Registration result JSON from the - * browser ceremony. - * @param params.vaultKey - Vault encryption key to protect. + * @param params.registrationResponse - The credential result from + * `navigator.credentials.create()`. + * @param params.vaultKey - The plaintext vault encryption key to wrap. + * @throws If no registration session is active (call + * {@link generateRegistrationOptions} first). + * @throws If WebAuthn verification fails. */ async protectVaultKeyWithPasskey(params: { registrationResponse: PasskeyRegistrationResponse; @@ -262,8 +307,10 @@ export class PasskeyController extends BaseController< const { registrationInfo } = verification; - const { encKey, derivationMethod } = - this.#deriveKeyFromRegistrationResponse(registrationResponse, session); + const { encKey, derivationMethod } = deriveKeyFromRegistrationResponse( + registrationResponse, + session, + ); const { ciphertext, iv } = encryptWithKey(vaultKey, encKey); @@ -274,6 +321,7 @@ export class PasskeyController extends BaseController< iv, prfSalt: derivationMethod === 'prf' ? session.prfSalt : undefined, publicKey: bytesToBase64URL(registrationInfo.publicKey), + counter: registrationInfo.counter, transports: registrationInfo.transports, }); @@ -281,12 +329,16 @@ export class PasskeyController extends BaseController< } /** - * Verifies the authentication response, derives a wrapping key, decrypts - * the protected vault key, and returns it. + * Unlocks the vault by verifying the authentication response and + * decrypting the protected vault key. * - * @param authenticationResponse - Authentication result JSON from the - * browser ceremony. - * @returns Decrypted vault key. + * @param authenticationResponse - The credential result from + * `navigator.credentials.get()`. + * @returns The decrypted plaintext vault encryption key. + * @throws If no passkey is enrolled. + * @throws If no authentication session is active (call + * {@link generateAuthenticationOptions} first). + * @throws If WebAuthn verification or decryption fails. */ async retrieveVaultKeyWithPasskey( authenticationResponse: PasskeyAuthenticationResponse, @@ -298,7 +350,7 @@ export class PasskeyController extends BaseController< await this.#verifyAuthentication(authenticationResponse, record); - const encKey = this.#deriveKeyFromAuthenticationResponse( + const encKey = deriveKeyFromAuthenticationResponse( authenticationResponse, record, ); @@ -315,14 +367,22 @@ export class PasskeyController extends BaseController< } /** - * Verifies the authentication response, re-derives the wrapping key, checks - * the old vault key matches, then re-wraps with the new vault key. + * Re-wraps the vault key after a password change without re-enrolling + * the passkey. + * + * Authenticates via the existing passkey, verifies that decryption + * yields the expected old vault key, then re-encrypts with the new + * vault key using the same derived wrapping key. * * @param params - Renewal parameters. - * @param params.authenticationResponse - Authentication result JSON from the - * browser ceremony. - * @param params.oldVaultKey - Serialized vault key before password change. - * @param params.newVaultKey - Serialized vault key after password change. + * @param params.authenticationResponse - The credential result from + * `navigator.credentials.get()`. + * @param params.oldVaultKey - The vault key that was active before the + * password change (used as a consistency check). + * @param params.newVaultKey - The new vault key to wrap. + * @throws If no passkey is enrolled. + * @throws If no authentication session is active. + * @throws If the decrypted vault key does not match `oldVaultKey`. */ async renewVaultKeyProtection(params: { authenticationResponse: PasskeyAuthenticationResponse; @@ -338,7 +398,7 @@ export class PasskeyController extends BaseController< await this.#verifyAuthentication(authenticationResponse, record); - const encKey = this.#deriveKeyFromAuthenticationResponse( + const encKey = deriveKeyFromAuthenticationResponse( authenticationResponse, record, ); @@ -368,8 +428,10 @@ export class PasskeyController extends BaseController< } /** - * Clears the passkey record and resets the registration and authentication - * sessions. + * Removes the enrolled passkey and clears all in-memory ceremony sessions. + * + * After calling this method, the vault key can no longer be recovered + * via passkey authentication until a new passkey is enrolled. */ removePasskey(): void { this.update((state) => { @@ -380,10 +442,12 @@ export class PasskeyController extends BaseController< } /** - * Verifies the authentication response using full WebAuthn verification. + * Verifies the authentication response using full WebAuthn verification + * and persists the updated signature counter for replay detection. * * @param authenticationResponse - Authentication result JSON. - * @param record - The stored passkey record containing publicKey. + * @param record - The stored passkey record containing publicKey and + * last known counter value. */ async #verifyAuthentication( authenticationResponse: PasskeyAuthenticationResponse, @@ -402,73 +466,22 @@ export class PasskeyController extends BaseController< credential: { id: record.credentialId, publicKey: base64URLToBytes(record.publicKey), - counter: 0, + 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'); } - } - - /** - * Derives the encryption key from the registration response. - * - * @param registrationResponse - Registration result JSON from the browser - * ceremony. - * @param session - Registration session. - * @returns Derived encryption key and derivation method. - */ - #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 the encryption key from the authentication response. - * - * @param authenticationResponse - Authentication result JSON. - * @param record - The stored passkey record. - * @returns Derived encryption key. - */ - #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)); + this.#setPasskeyRecord({ + ...record, + counter: verification.authenticationInfo.newCounter, + }); } } diff --git a/packages/passkey-controller/src/helpers/index.ts b/packages/passkey-controller/src/helpers/index.ts deleted file mode 100644 index 735b8347fb6..00000000000 --- a/packages/passkey-controller/src/helpers/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { - decodeClientDataJSON, - type ClientDataJSON, -} from './decodeClientDataJSON'; -export { - parseAuthenticatorData, - type ParsedAuthenticatorData, - type AuthenticatorDataFlags, -} from './parseAuthenticatorData'; -export { - decodeAttestationObject, - type AttestationObject, - type AttestationStatement, - type AttestationFormat, -} from './decodeAttestationObject'; -export { verifySignature } from './verifySignature'; -export { matchExpectedRPID } from './matchExpectedRPID'; diff --git a/packages/passkey-controller/src/index.ts b/packages/passkey-controller/src/index.ts index d425658c675..75a43e31092 100644 --- a/packages/passkey-controller/src/index.ts +++ b/packages/passkey-controller/src/index.ts @@ -7,5 +7,9 @@ export type { PasskeyControllerMessenger, } from './PasskeyController'; export type * from './types'; -export { bytesToBase64URL, base64URLToBytes } from './encoding'; -export { COSEALG, COSEKEYS, COSEKTY, COSECRV } from './constants'; +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 index ea7bb284236..55acf7e2172 100644 --- a/packages/passkey-controller/src/types.ts +++ b/packages/passkey-controller/src/types.ts @@ -26,6 +26,8 @@ export type PasskeyRecord = { 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[]; }; @@ -57,71 +59,3 @@ export type PrfClientExtensionResults = { results?: { first?: Base64URLString }; }; }; - -// ─── WebAuthn Options/Response JSON types ──────────────────────────────────── - -export type PublicKeyCredentialDescriptorJSON = { - id: Base64URLString; - type: 'public-key'; - transports?: AuthenticatorTransportFuture[]; -}; - -export type PasskeyRegistrationOptions = { - rp: { name: string; id: string }; - user: { - id: Base64URLString; - name: string; - displayName: string; - }; - challenge: Base64URLString; - 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: Base64URLString; - rawId: Base64URLString; - type: 'public-key'; - response: { - clientDataJSON: Base64URLString; - attestationObject: Base64URLString; - transports?: string[]; - publicKeyAlgorithm?: number; - publicKey?: Base64URLString; - authenticatorData?: Base64URLString; - }; - authenticatorAttachment?: 'cross-platform' | 'platform'; - clientExtensionResults: Record; -}; - -export type PasskeyAuthenticationOptions = { - challenge: Base64URLString; - timeout?: number; - rpId?: string; - allowCredentials?: PublicKeyCredentialDescriptorJSON[]; - userVerification?: 'discouraged' | 'preferred' | 'required'; - extensions?: Record; -}; - -export type PasskeyAuthenticationResponse = { - id: Base64URLString; - rawId: Base64URLString; - type: 'public-key'; - response: { - clientDataJSON: Base64URLString; - authenticatorData: Base64URLString; - signature: Base64URLString; - userHandle?: Base64URLString; - }; - authenticatorAttachment?: 'cross-platform' | 'platform'; - clientExtensionResults: Record; -}; diff --git a/packages/passkey-controller/src/crypto.test.ts b/packages/passkey-controller/src/utils/crypto.test.ts similarity index 100% rename from packages/passkey-controller/src/crypto.test.ts rename to packages/passkey-controller/src/utils/crypto.test.ts diff --git a/packages/passkey-controller/src/crypto.ts b/packages/passkey-controller/src/utils/crypto.ts similarity index 100% rename from packages/passkey-controller/src/crypto.ts rename to packages/passkey-controller/src/utils/crypto.ts diff --git a/packages/passkey-controller/src/encoding.test.ts b/packages/passkey-controller/src/utils/encoding.test.ts similarity index 100% rename from packages/passkey-controller/src/encoding.test.ts rename to packages/passkey-controller/src/utils/encoding.test.ts diff --git a/packages/passkey-controller/src/encoding.ts b/packages/passkey-controller/src/utils/encoding.ts similarity index 100% rename from packages/passkey-controller/src/encoding.ts rename to packages/passkey-controller/src/utils/encoding.ts 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.ts b/packages/passkey-controller/src/webauthn.ts deleted file mode 100644 index eabadc77d8d..00000000000 --- a/packages/passkey-controller/src/webauthn.ts +++ /dev/null @@ -1,413 +0,0 @@ -import { decodePartialCBOR } from '@levischuck/tiny-cbor'; -import { sha256 } from '@noble/hashes/sha2'; - -import { COSEALG, COSEKEYS } from './constants'; -import { base64URLToBytes, bytesToBase64URL, bytesToHex } from './encoding'; -import { decodeAttestationObject } from './helpers/decodeAttestationObject'; -import { decodeClientDataJSON } from './helpers/decodeClientDataJSON'; -import { matchExpectedRPID } from './helpers/matchExpectedRPID'; -import { parseAuthenticatorData } from './helpers/parseAuthenticatorData'; -import type { ParsedAuthenticatorData } from './helpers/parseAuthenticatorData'; -import { verifySignature } from './helpers/verifySignature'; -import type { - PasskeyRegistrationResponse, - PasskeyAuthenticationResponse, - AuthenticatorTransportFuture, -} from './types'; - -// ─── Registration ──────────────────────────────────────────────────────────── - -export type VerifiedRegistrationResponse = - | { verified: false; registrationInfo?: never } - | { - verified: true; - registrationInfo: { - credentialId: string; - publicKey: Uint8Array; - counter: number; - transports?: AuthenticatorTransportFuture[]; - aaguid: string; - attestationFormat: string; - userVerified: boolean; - }; - }; - -/** - * Verify a WebAuthn registration response per W3C WebAuthn Level 3 §7.1. - * - * Checks: credential ID/type, clientDataJSON (type, challenge, origin), - * attestation object (rpIdHash, flags, algorithm, attestation format), - * and packed self-attestation signature when applicable. - * - * @param opts - Verification options. - * @param opts.response - The registration response from the authenticator. - * @param opts.expectedChallenge - The expected challenge string. - * @param opts.expectedOrigin - The expected origin(s). - * @param opts.expectedRPID - The expected Relying Party ID. - * @param opts.requireUserVerification - Whether UV must be set. - * @param opts.supportedAlgorithmIDs - Allowed COSE algorithm identifiers. - * @returns Verification result with credential info on success. - */ -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 attestation (self-attestation case: no x5c, signature over - * authData || SHA-256(clientDataJSON) with the credential key itself). - * - * @param attStmt - The attestation statement map. - * @param attStmt.get - Map accessor for statement fields. - * @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. - * @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 signature = attStmt.get('sig') as Uint8Array | undefined; - const x5c = attStmt.get('x5c') as Uint8Array[] | undefined; - - 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 clientDataHash = sha256(base64URLToBytes(clientDataJSONB64url)); - const signatureBase = concatUint8Arrays(authData, clientDataHash); - - return verifySignature({ - cosePublicKey, - signature, - data: signatureBase, - }); -} - -// ─── Authentication ────────────────────────────────────────────────────────── - -export type VerifiedAuthenticationResponse = { - verified: boolean; - authenticationInfo: { - credentialId: string; - newCounter: number; - userVerified: boolean; - origin: string; - rpID: string; - }; -}; - -/** - * Verify a WebAuthn authentication response per W3C WebAuthn Level 3 §7.2. - * - * Checks: credential ID/type, clientDataJSON (type, challenge, origin), - * authenticatorData (rpIdHash, flags), counter monotonicity, and signature - * verification against the stored credential public key. - * - * @param opts - Verification options. - * @param opts.response - The authentication response from the authenticator. - * @param opts.expectedChallenge - The expected challenge string. - * @param opts.expectedOrigin - The expected origin(s). - * @param opts.expectedRPID - The expected Relying Party ID. - * @param opts.credential - The stored credential to verify against. - * @param opts.credential.id - The credential ID. - * @param opts.credential.publicKey - The COSE-encoded public key bytes. - * @param opts.credential.counter - The last known counter value. - * @param opts.credential.transports - Optional authenticator transports. - * @param opts.requireUserVerification - Whether UV must be set. - * @returns Verification result with authentication info. - */ -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, - }, - }; -} - -// ─── Utilities ─────────────────────────────────────────────────────────────── - -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/constants.ts b/packages/passkey-controller/src/webauthn/constants.ts similarity index 100% rename from packages/passkey-controller/src/constants.ts rename to packages/passkey-controller/src/webauthn/constants.ts diff --git a/packages/passkey-controller/src/helpers/decodeAttestationObject.ts b/packages/passkey-controller/src/webauthn/decodeAttestationObject.ts similarity index 51% rename from packages/passkey-controller/src/helpers/decodeAttestationObject.ts rename to packages/passkey-controller/src/webauthn/decodeAttestationObject.ts index d798d964626..21c3a4da218 100644 --- a/packages/passkey-controller/src/helpers/decodeAttestationObject.ts +++ b/packages/passkey-controller/src/webauthn/decodeAttestationObject.ts @@ -1,26 +1,6 @@ import { decodePartialCBOR } from '@levischuck/tiny-cbor'; -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; -}; +import type { AttestationObject } from './types'; /** * CBOR-decode an attestationObject buffer into a Map with `fmt`, `attStmt`, diff --git a/packages/passkey-controller/src/helpers/decodeClientDataJSON.ts b/packages/passkey-controller/src/webauthn/decodeClientDataJSON.ts similarity index 59% rename from packages/passkey-controller/src/helpers/decodeClientDataJSON.ts rename to packages/passkey-controller/src/webauthn/decodeClientDataJSON.ts index 23886eaa198..a94504177aa 100644 --- a/packages/passkey-controller/src/helpers/decodeClientDataJSON.ts +++ b/packages/passkey-controller/src/webauthn/decodeClientDataJSON.ts @@ -1,15 +1,5 @@ -import { base64URLToBytes } from '../encoding'; - -export type ClientDataJSON = { - type: string; - challenge: string; - origin: string; - crossOrigin?: boolean; - tokenBinding?: { - id?: string; - status: 'present' | 'supported' | 'not-supported'; - }; -}; +import type { ClientDataJSON } from './types'; +import { base64URLToBytes } from '../utils/encoding'; /** * Decode an authenticator's base64url-encoded clientDataJSON to JSON. 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/helpers/matchExpectedRPID.ts b/packages/passkey-controller/src/webauthn/matchExpectedRPID.ts similarity index 96% rename from packages/passkey-controller/src/helpers/matchExpectedRPID.ts rename to packages/passkey-controller/src/webauthn/matchExpectedRPID.ts index 88bdb702a1e..01e489a27c5 100644 --- a/packages/passkey-controller/src/helpers/matchExpectedRPID.ts +++ b/packages/passkey-controller/src/webauthn/matchExpectedRPID.ts @@ -1,6 +1,6 @@ import { sha256 } from '@noble/hashes/sha2'; -import { bytesToHex } from '../encoding'; +import { bytesToHex } from '../utils/encoding'; /** * Compare two Uint8Arrays for equality in constant time. diff --git a/packages/passkey-controller/src/helpers/parseAuthenticatorData.ts b/packages/passkey-controller/src/webauthn/parseAuthenticatorData.ts similarity index 77% rename from packages/passkey-controller/src/helpers/parseAuthenticatorData.ts rename to packages/passkey-controller/src/webauthn/parseAuthenticatorData.ts index 827783e8137..9b52d08732c 100644 --- a/packages/passkey-controller/src/helpers/parseAuthenticatorData.ts +++ b/packages/passkey-controller/src/webauthn/parseAuthenticatorData.ts @@ -1,25 +1,6 @@ import { decodePartialCBOR } from '@levischuck/tiny-cbor'; -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; -}; +import type { ParsedAuthenticatorData, AuthenticatorDataFlags } from './types'; /* eslint-disable no-bitwise */ @@ -99,13 +80,18 @@ export function parseAuthenticatorData( } if (flags.ed) { - const extensionsDataBuffer = authData.slice(pointer); - const [decoded] = decodePartialCBOR( - new Uint8Array(extensionsDataBuffer), + const remaining = authData.slice(pointer); + const [decoded, consumed] = decodePartialCBOR( + new Uint8Array(remaining), 0, ) as [Map, number]; result.extensionsData = decoded; - result.extensionsDataBuffer = extensionsDataBuffer; + result.extensionsDataBuffer = remaining.slice(0, consumed); + pointer += consumed; + } + + if (authData.byteLength > pointer) { + throw new Error('Leftover bytes detected while parsing authenticator data'); } return result; 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/helpers/verifySignature.ts b/packages/passkey-controller/src/webauthn/verifySignature.ts similarity index 91% rename from packages/passkey-controller/src/helpers/verifySignature.ts rename to packages/passkey-controller/src/webauthn/verifySignature.ts index 348aa4ccb17..3166086ab0c 100644 --- a/packages/passkey-controller/src/helpers/verifySignature.ts +++ b/packages/passkey-controller/src/webauthn/verifySignature.ts @@ -1,9 +1,9 @@ import { ed25519 } from '@noble/curves/ed25519'; import { p256, p384 } from '@noble/curves/nist'; -import { sha256 } from '@noble/hashes/sha2'; +import { sha256, sha384 } from '@noble/hashes/sha2'; -import { COSEALG, COSECRV, COSEKEYS, COSEKTY } from '../constants'; -import { bytesToBase64URL } from '../encoding'; +import { COSEALG, COSECRV, COSEKEYS, COSEKTY } from './constants'; +import { bytesToBase64URL } from '../utils/encoding'; type COSEPublicKey = Map; @@ -41,6 +41,9 @@ function getKeyType(cosePublicKey: COSEPublicKey): number { /** * 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. @@ -60,13 +63,12 @@ function verifyEC2( } const uncompressed = concatBytes(new Uint8Array([0x04]), xCoord, yCoord); - const hash = sha256(data); switch (crv) { case COSECRV.P256: - return p256.verify(signature, hash, uncompressed); + return p256.verify(signature, sha256(data), uncompressed); case COSECRV.P384: - return p384.verify(signature, hash, uncompressed); + return p384.verify(signature, sha384(data), uncompressed); default: throw new Error(`Unsupported EC2 curve: ${crv}`); } diff --git a/packages/passkey-controller/src/webauthn.test.ts b/packages/passkey-controller/src/webauthn/webauthn.test.ts similarity index 88% rename from packages/passkey-controller/src/webauthn.test.ts rename to packages/passkey-controller/src/webauthn/webauthn.test.ts index 41f2c39d656..add5d4ff2be 100644 --- a/packages/passkey-controller/src/webauthn.test.ts +++ b/packages/passkey-controller/src/webauthn/webauthn.test.ts @@ -2,18 +2,16 @@ 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 } from '@noble/hashes/sha2'; +import { sha256, sha384 } from '@noble/hashes/sha2'; import { COSEALG, COSEKEYS, COSEKTY, COSECRV } from './constants'; -import { bytesToBase64URL } from './encoding'; import type { PasskeyRegistrationResponse, PasskeyAuthenticationResponse, } from './types'; -import { - verifyRegistrationResponse, - verifyAuthenticationResponse, -} from './webauthn'; +import { verifyAuthenticationResponse } from './verifyAuthenticationResponse'; +import { verifyRegistrationResponse } from './verifyRegistrationResponse'; +import { bytesToBase64URL } from '../utils/encoding'; // --------------------------------------------------------------------------- // Test Helpers @@ -944,6 +942,65 @@ describe('verifyAuthenticationResponse', () => { 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', () => { @@ -1090,10 +1147,6 @@ describe('verifyRegistrationResponse edge cases', () => { it('rejects missing public key', async () => { const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); - // Build authData with AT flag but missing credential data by building manually - // flags: UP (0x01) | AT (0x40) = 0x41 but credentialID present, no pubkey - // This is tested via the "no credential ID" path with no AT flag - // Instead, let's test for missing AAGUID const authData = buildAuthenticatorData({ rpIdHash, flags: 0x01, @@ -1113,6 +1166,80 @@ describe('verifyRegistrationResponse edge cases', () => { ).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); @@ -1310,7 +1437,7 @@ describe('verifyRegistrationResponse edge cases', () => { describe('verifySignature', () => { // eslint-disable-next-line @typescript-eslint/no-require-imports, n/global-require - const { verifySignature } = require('./helpers/verifySignature'); + const { verifySignature } = require('./verifySignature'); it('verifies P-384 EC2 signature', async () => { const privateKey = p384.utils.randomPrivateKey(); @@ -1324,7 +1451,7 @@ describe('verifySignature', () => { coseMap.set(COSEKEYS.Y, publicKeyRaw.slice(49, 97)); const data = new Uint8Array(32).fill(0xcc); - const hash = sha256(data); + const hash = sha384(data); const ecdsaSig = p384.sign(hash, privateKey); const result = await verifySignature({ @@ -1525,9 +1652,7 @@ describe('verifySignature', () => { describe('parseAuthenticatorData edge cases', () => { /* eslint-disable @typescript-eslint/no-require-imports, n/global-require */ - const { - parseAuthenticatorData, - } = require('./helpers/parseAuthenticatorData'); + const { parseAuthenticatorData } = require('./parseAuthenticatorData'); /* eslint-enable @typescript-eslint/no-require-imports, n/global-require */ it('throws for authenticator data shorter than 37 bytes', () => { @@ -1559,11 +1684,48 @@ describe('parseAuthenticatorData edge cases', () => { 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('./helpers/matchExpectedRPID'); + const { matchExpectedRPID } = require('./matchExpectedRPID'); it('throws when no RP ID matches', () => { const rpIdHash = sha256(new TextEncoder().encode('example.com')); @@ -1578,11 +1740,20 @@ describe('matchExpectedRPID edge cases', () => { }); it('constant-time compare rejects different lengths', () => { - const rpIdHash = sha256(new TextEncoder().encode('example.com')); - expect(() => matchExpectedRPID(rpIdHash, ['x.com'])).toThrow( + // 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', () => { @@ -1628,7 +1799,7 @@ describe('verifySignature RSA hash variants', () => { /* eslint-disable @typescript-eslint/no-require-imports, n/global-require */ const { verifySignature: verifySignatureHelper, - } = require('./helpers/verifySignature'); + } = require('./verifySignature'); /* eslint-enable @typescript-eslint/no-require-imports, n/global-require */ async function generateRSAKeyPairAndSign( From e54d2250a6cbb5d53fce386fb5972b9f2f9280e6 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Thu, 16 Apr 2026 15:21:39 +0700 Subject: [PATCH 08/44] feat: accept prf availability from browser when generating registration options --- .../src/PasskeyController.test.ts | 58 ++++++++++ .../src/PasskeyController.ts | 101 ++++++++---------- 2 files changed, 103 insertions(+), 56 deletions(-) diff --git a/packages/passkey-controller/src/PasskeyController.test.ts b/packages/passkey-controller/src/PasskeyController.test.ts index 625a43e9107..9ab88d0b141 100644 --- a/packages/passkey-controller/src/PasskeyController.test.ts +++ b/packages/passkey-controller/src/PasskeyController.test.ts @@ -231,6 +231,64 @@ describe('PasskeyController', () => { expect(options.rp.id).toBe('metamask.io'); expect(options.rp.name).toBe('MetaMask'); }); + + 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', () => { diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index fe024b0cfd1..efaa7fda670 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -157,32 +157,40 @@ export class PasskeyController extends BaseController< } /** - * Builds a `PublicKeyCredentialCreationOptions` object for the browser - * WebAuthn `navigator.credentials.create()` call. + * Produces WebAuthn credential creation options for passkey enrollment. * - * Generates fresh random values for the challenge, userHandle, and PRF - * salt, then stores them in an in-memory registration session so they - * can be verified later in {@link protectVaultKeyWithPasskey}. + * Must be called before {@link protectVaultKeyWithPasskey}. * - * @param creationOptionsConfig - Optional overrides for the relying - * party identity. - * @param creationOptionsConfig.rp - Relying party configuration. - * @param creationOptionsConfig.rp.name - Display name shown to the user - * during the ceremony (defaults to `"MetaMask"`). + * @param creationOptionsConfig - Optional configuration overrides. + * @param creationOptionsConfig.rp - Relying party identity overrides. + * @param creationOptionsConfig.rp.name - RP display name (defaults to + * `"MetaMask"`). * @param creationOptionsConfig.rp.id - RP ID domain (defaults to the * value passed to the constructor). - * @returns Options JSON ready to pass to `navigator.credentials.create()`. + * @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?: { rp?: { name?: string; id?: string }; + prfAvailable?: boolean; }): PasskeyRegistrationOptions { - const prfSalt = bytesToBase64URL(randomBytes(32).slice()); + 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 rpID = creationOptionsConfig?.rp?.id ?? this.#rpID; const rpName = creationOptionsConfig?.rp?.name ?? 'MetaMask'; + const extensions: Record = {}; + if (prfSalt) { + extensions.prf = { eval: { first: prfSalt } }; + } + const options: PasskeyRegistrationOptions = { rp: { name: rpName, id: rpID }, user: { @@ -203,14 +211,12 @@ export class PasskeyController extends BaseController< authenticatorAttachment: 'platform', }, attestation: 'direct', - extensions: { - prf: { eval: { first: prfSalt } }, - }, + ...(Object.keys(extensions).length > 0 ? { extensions } : {}), }; this.#registrationSession = { userHandle, - prfSalt, + prfSalt: prfSalt ?? '', challenge, }; @@ -218,14 +224,13 @@ export class PasskeyController extends BaseController< } /** - * Builds a `PublicKeyCredentialRequestOptions` object for the browser - * WebAuthn `navigator.credentials.get()` call. + * Produces WebAuthn credential request options for passkey + * authentication. * - * Generates a fresh challenge and stores it in an in-memory - * authentication session for later verification in - * {@link retrieveVaultKeyWithPasskey} or {@link renewVaultKeyProtection}. + * Must be called before {@link retrieveVaultKeyWithPasskey} or + * {@link renewVaultKeyProtection}. * - * @returns Options JSON ready to pass to `navigator.credentials.get()`. + * @returns Options JSON for `navigator.credentials.get()`. * @throws If no passkey is currently enrolled. */ generateAuthenticationOptions(): PasskeyAuthenticationOptions { @@ -262,25 +267,16 @@ export class PasskeyController extends BaseController< } /** - * Completes passkey enrollment by verifying the registration response, - * wrapping the vault key, and persisting the credential. - * - * Steps performed: - * 1. Verifies the authenticator's registration response (challenge, - * origin, RP ID, attestation). - * 2. Derives an AES-256 wrapping key via HKDF from the PRF output or - * the random userHandle. - * 3. Encrypts the vault key with AES-256-GCM using the derived key. - * 4. Persists a {@link PasskeyRecord} with the encrypted vault key, - * credential public key, and derivation metadata. + * 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 plaintext vault encryption key to wrap. + * @param params.vaultKey - The vault encryption key to protect. * @throws If no registration session is active (call * {@link generateRegistrationOptions} first). - * @throws If WebAuthn verification fails. + * @throws If registration verification fails. */ async protectVaultKeyWithPasskey(params: { registrationResponse: PasskeyRegistrationResponse; @@ -329,16 +325,15 @@ export class PasskeyController extends BaseController< } /** - * Unlocks the vault by verifying the authentication response and - * decrypting the protected vault key. + * Retrieves the vault key protected by the enrolled passkey. * * @param authenticationResponse - The credential result from * `navigator.credentials.get()`. - * @returns The decrypted plaintext vault encryption key. + * @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 WebAuthn verification or decryption fails. + * @throws If authentication verification or key recovery fails. */ async retrieveVaultKeyWithPasskey( authenticationResponse: PasskeyAuthenticationResponse, @@ -367,22 +362,20 @@ export class PasskeyController extends BaseController< } /** - * Re-wraps the vault key after a password change without re-enrolling - * the passkey. + * Replaces the protected vault key without re-enrolling the passkey. * - * Authenticates via the existing passkey, verifies that decryption - * yields the expected old vault key, then re-encrypts with the new - * vault key using the same derived wrapping key. + * 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 that was active before the - * password change (used as a consistency check). - * @param params.newVaultKey - The new vault key to wrap. + * @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 the decrypted vault key does not match `oldVaultKey`. + * @throws If `oldVaultKey` does not match the currently protected key. */ async renewVaultKeyProtection(params: { authenticationResponse: PasskeyAuthenticationResponse; @@ -428,10 +421,7 @@ export class PasskeyController extends BaseController< } /** - * Removes the enrolled passkey and clears all in-memory ceremony sessions. - * - * After calling this method, the vault key can no longer be recovered - * via passkey authentication until a new passkey is enrolled. + * Unenrolls the passkey, removing the protected vault key material. */ removePasskey(): void { this.update((state) => { @@ -442,12 +432,11 @@ export class PasskeyController extends BaseController< } /** - * Verifies the authentication response using full WebAuthn verification - * and persists the updated signature counter for replay detection. + * Verifies a WebAuthn authentication response against the enrolled + * credential. * * @param authenticationResponse - Authentication result JSON. - * @param record - The stored passkey record containing publicKey and - * last known counter value. + * @param record - The enrolled passkey record to verify against. */ async #verifyAuthentication( authenticationResponse: PasskeyAuthenticationResponse, From f9fb7994ddaebafc2c41c4697ea2e8645c6700c9 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Thu, 16 Apr 2026 16:38:21 +0700 Subject: [PATCH 09/44] feat: use rp ID and name from platform --- .../src/PasskeyController.test.ts | 30 +++++++++++++------ .../src/PasskeyController.ts | 26 +++++++--------- 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/packages/passkey-controller/src/PasskeyController.test.ts b/packages/passkey-controller/src/PasskeyController.test.ts index 9ab88d0b141..7f1b6690c35 100644 --- a/packages/passkey-controller/src/PasskeyController.test.ts +++ b/packages/passkey-controller/src/PasskeyController.test.ts @@ -59,12 +59,15 @@ function getPasskeyMessenger(): PasskeyControllerMessenger { }) 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, }); @@ -198,7 +201,12 @@ describe('PasskeyController', () => { it('is callable via messenger method action', () => { const messenger = getPasskeyMessenger(); // eslint-disable-next-line @typescript-eslint/no-unused-vars - const controller = new PasskeyController({ messenger }); + const controller = new PasskeyController({ + messenger, + rpID: TEST_RP_ID, + rpName: TEST_RP_NAME, + expectedOrigin: TEST_ORIGIN, + }); expect(messenger.call('PasskeyController:isPasskeyEnrolled')).toBe(false); }); }); @@ -207,11 +215,12 @@ describe('PasskeyController', () => { it('returns options with PRF extension and challenge', () => { const controller = createController(); - const options = controller.generateRegistrationOptions({ - rp: { name: 'Test RP', id: 'test.com' }, - }); + const options = controller.generateRegistrationOptions(); - expect(options.rp).toStrictEqual({ name: 'Test RP', id: 'test.com' }); + 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([ @@ -225,11 +234,14 @@ describe('PasskeyController', () => { ).toBeDefined(); }); - it('defaults to metamask.io rpID and MetaMask rpName', () => { - const controller = createController({ rpID: undefined }); + 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('metamask.io'); - expect(options.rp.name).toBe('MetaMask'); + expect(options.rp.id).toBe('custom-rp.io'); + expect(options.rp.name).toBe('Custom RP'); }); it('includes PRF extension when prfAvailable is true', () => { diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index efaa7fda670..9978a4d612a 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -108,18 +108,22 @@ export class PasskeyController extends BaseController< readonly #rpID: string; + readonly #rpName: string; + readonly #expectedOrigin: string | string[]; constructor({ messenger, state, rpID, + rpName, expectedOrigin, }: { messenger: PasskeyControllerMessenger; state?: Partial; - rpID?: string; - expectedOrigin?: string | string[]; + rpID: string; + rpName: string; + expectedOrigin: string | string[]; }) { super({ messenger, @@ -128,8 +132,9 @@ export class PasskeyController extends BaseController< state: { ...getDefaultPasskeyControllerState(), ...state }, }); - this.#rpID = rpID ?? 'metamask.io'; - this.#expectedOrigin = expectedOrigin ?? []; + this.#rpID = rpID; + this.#rpName = rpName; + this.#expectedOrigin = expectedOrigin; this.messenger.registerMethodActionHandlers( this, @@ -161,19 +166,13 @@ export class PasskeyController extends BaseController< * * Must be called before {@link protectVaultKeyWithPasskey}. * - * @param creationOptionsConfig - Optional configuration overrides. - * @param creationOptionsConfig.rp - Relying party identity overrides. - * @param creationOptionsConfig.rp.name - RP display name (defaults to - * `"MetaMask"`). - * @param creationOptionsConfig.rp.id - RP ID domain (defaults to the - * value passed to the constructor). + * @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?: { - rp?: { name?: string; id?: string }; prfAvailable?: boolean; }): PasskeyRegistrationOptions { const includePrf = creationOptionsConfig?.prfAvailable !== false; @@ -183,16 +182,13 @@ export class PasskeyController extends BaseController< const userHandle = bytesToBase64URL(randomBytes(64).slice()); const challenge = bytesToBase64URL(randomBytes(32).slice()); - const rpID = creationOptionsConfig?.rp?.id ?? this.#rpID; - const rpName = creationOptionsConfig?.rp?.name ?? 'MetaMask'; - const extensions: Record = {}; if (prfSalt) { extensions.prf = { eval: { first: prfSalt } }; } const options: PasskeyRegistrationOptions = { - rp: { name: rpName, id: rpID }, + rp: { name: this.#rpName, id: this.#rpID }, user: { id: userHandle, name: 'MetaMask Wallet', From afe1777792083a3a92d0cf1fddcb5a3ec3de8950 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Thu, 16 Apr 2026 16:58:34 +0700 Subject: [PATCH 10/44] chore: refactor docs --- packages/passkey-controller/CHANGELOG.md | 39 +++--- packages/passkey-controller/README.md | 115 +++++++++++++++++- .../src/PasskeyController.ts | 20 ++- packages/passkey-controller/src/index.ts | 12 +- teams.json | 1 + 5 files changed, 149 insertions(+), 38 deletions(-) diff --git a/packages/passkey-controller/CHANGELOG.md b/packages/passkey-controller/CHANGELOG.md index 6ff0709716a..9a983156ab5 100644 --- a/packages/passkey-controller/CHANGELOG.md +++ b/packages/passkey-controller/CHANGELOG.md @@ -9,34 +9,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Full WebAuthn response verification for both registration and authentication, ported from `@simplewebauthn/server`: +- `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 -- `publicKey` field on `PasskeyRecord` — stores the COSE-encoded credential public key (base64url) for signature verification during authentication -- `transports` field on `PasskeyRecord` — stores authenticator transport hints for `allowCredentials` +- 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` -- Internal `helpers/` directory with verification utilities: `decodeClientDataJSON`, `parseAuthenticatorData`, `decodeAttestationObject`, `verifySignature`, `matchExpectedRPID` -- `rpID` and `expectedOrigin` constructor parameters for `PasskeyController` -- `@levischuck/tiny-cbor` dependency for CBOR decoding of attestation objects and COSE public keys -- `@noble/curves` dependency for EC2 (P-256, P-384) and EdDSA (Ed25519) signature verification -- `PasskeyController.renewVaultKeyProtection` — verifies a WebAuthn authentication response, confirms the currently protected vault key matches the pre-rotation value, then re-protects and persists the record for a new vault encryption key -- Self-contained WebAuthn types: `PasskeyRegistrationOptions`, `PasskeyRegistrationResponse`, `PasskeyAuthenticationOptions`, `PasskeyAuthenticationResponse`, `AuthenticatorTransportFuture`, `Base64URLString` -- Comprehensive test suite with 81 tests including real cryptographic signature verification (ES256, ES384, Ed25519, RS256, RS384, RS512) - -### Changed - -- **BREAKING:** `generateRegistrationOptions` is now synchronous (was async with `@simplewebauthn/server`) -- **BREAKING:** `generateAuthenticationOptions` is now synchronous (was async with `@simplewebauthn/server`) -- **BREAKING:** All WebAuthn types are now self-contained (no longer re-exported from `@simplewebauthn/server`) -- **BREAKING:** HKDF `info` label used by `deriveEncryptionKey` is now `metamask:passkey:encryption-key:v1` -- Registration and authentication options are now generated internally (challenge, userHandle, PRF salt are self-generated using `@noble/ciphers/webcrypto` randomBytes) -- Refactored authentication handling so `retrieveVaultKeyWithPasskey` shares challenge verification and wrapping-key derivation with the vault key protection renewal path - -### Removed - -- **BREAKING:** Removed `@simplewebauthn/server` dependency (Node.js package incompatible with browser extension runtime) -- **BREAKING:** Removed `PASSKEY_HKDF_INFO` from the package public exports [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/passkey-controller/README.md b/packages/passkey-controller/README.md index 171c1962da3..f73631bcc07 100644 --- a/packages/passkey-controller/README.md +++ b/packages/passkey-controller/README.md @@ -1,11 +1,122 @@ -# @metamask/passkey-controller +# `@metamask/passkey-controller` -Controller and utilities for passkey-based wallet unlock with adaptive PRF + userHandle key derivation. +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(); // clears all passkey state +``` + +## 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/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index 9978a4d612a..c491bc5f07d 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -38,28 +38,26 @@ export type PasskeyControllerState = { passkeyRecord: PasskeyRecord | null; }; -type PasskeyControllerGetStateAction = ControllerGetStateAction< +export type PasskeyControllerGetStateAction = ControllerGetStateAction< typeof controllerName, PasskeyControllerState >; -type PasskeyControllerMessengerMethodActions = { - [Method in (typeof MESSENGER_EXPOSED_METHODS)[number]]: { - type: `${typeof controllerName}:${Method}`; - handler: PasskeyController[Method]; - }; -}[(typeof MESSENGER_EXPOSED_METHODS)[number]]; +export type PasskeyControllerIsPasskeyEnrolledAction = { + type: `${typeof controllerName}:isPasskeyEnrolled`; + handler: PasskeyController['isPasskeyEnrolled']; +}; -type PasskeyControllerActions = +export type PasskeyControllerActions = | PasskeyControllerGetStateAction - | PasskeyControllerMessengerMethodActions; + | PasskeyControllerIsPasskeyEnrolledAction; -type PasskeyControllerStateChangeEvent = ControllerStateChangeEvent< +export type PasskeyControllerStateChangeEvent = ControllerStateChangeEvent< typeof controllerName, PasskeyControllerState >; -type PasskeyControllerEvents = PasskeyControllerStateChangeEvent; +export type PasskeyControllerEvents = PasskeyControllerStateChangeEvent; export type PasskeyControllerMessenger = Messenger< typeof controllerName, diff --git a/packages/passkey-controller/src/index.ts b/packages/passkey-controller/src/index.ts index 75a43e31092..8cbb08a3614 100644 --- a/packages/passkey-controller/src/index.ts +++ b/packages/passkey-controller/src/index.ts @@ -5,8 +5,18 @@ export { export type { PasskeyControllerState, PasskeyControllerMessenger, + PasskeyControllerGetStateAction, + PasskeyControllerIsPasskeyEnrolledAction, + PasskeyControllerActions, + PasskeyControllerStateChangeEvent, + PasskeyControllerEvents, } from './PasskeyController'; -export type * from './types'; +export type { + PasskeyDerivationMethod, + PasskeyRecord, + PrfEvalExtension, + PrfClientExtensionResults, +} from './types'; export type { PasskeyRegistrationOptions, PasskeyRegistrationResponse, 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", From 6f7b1c7cbbb389f1a696082838b016c08cecca23 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Fri, 17 Apr 2026 00:46:51 +0700 Subject: [PATCH 11/44] feat: allow clearing state in passkey controller --- packages/passkey-controller/CHANGELOG.md | 1 + packages/passkey-controller/README.md | 4 +++- .../src/PasskeyController.test.ts | 17 +++++++++++++++++ .../passkey-controller/src/PasskeyController.ts | 15 ++++++++++++--- 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/packages/passkey-controller/CHANGELOG.md b/packages/passkey-controller/CHANGELOG.md index 9a983156ab5..0af934fdb8b 100644 --- a/packages/passkey-controller/CHANGELOG.md +++ b/packages/passkey-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 diff --git a/packages/passkey-controller/README.md b/packages/passkey-controller/README.md index f73631bcc07..c617ab538e9 100644 --- a/packages/passkey-controller/README.md +++ b/packages/passkey-controller/README.md @@ -93,7 +93,9 @@ await controller.renewVaultKeyProtection({ ```typescript controller.isPasskeyEnrolled(); // boolean -controller.removePasskey(); // clears all passkey state +controller.removePasskey(); // user-facing unenroll + +controller.clearState(); // same persisted reset + session drop; use for app lifecycle (e.g. wallet reset) ``` ## API diff --git a/packages/passkey-controller/src/PasskeyController.test.ts b/packages/passkey-controller/src/PasskeyController.test.ts index 7f1b6690c35..28657a5db56 100644 --- a/packages/passkey-controller/src/PasskeyController.test.ts +++ b/packages/passkey-controller/src/PasskeyController.test.ts @@ -645,6 +645,23 @@ describe('PasskeyController', () => { }); }); + 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(); diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index c491bc5f07d..3be22051c59 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -415,16 +415,25 @@ export class PasskeyController extends BaseController< } /** - * Unenrolls the passkey, removing the protected vault key material. + * 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. */ - removePasskey(): void { + clearState(): void { this.update((state) => { - state.passkeyRecord = null; + 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. From 29cdfba4b1aa8f375b898db7efe4bbf9d9a29ed7 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Sat, 18 Apr 2026 20:35:06 +0700 Subject: [PATCH 12/44] feat: allow multiple passkey ceremony at the same time --- packages/passkey-controller/CHANGELOG.md | 35 +- packages/passkey-controller/README.md | 4 +- .../src/PasskeyController.test.ts | 633 +++++++++++++++--- .../src/PasskeyController.ts | 149 +++-- .../src/ceremony-manager.test.ts | 155 +++++ .../src/ceremony-manager.ts | 169 +++++ packages/passkey-controller/src/index.ts | 6 + .../src/key-derivation.test.ts | 42 +- .../passkey-controller/src/key-derivation.ts | 22 +- packages/passkey-controller/src/types.ts | 20 +- 10 files changed, 1035 insertions(+), 200 deletions(-) create mode 100644 packages/passkey-controller/src/ceremony-manager.test.ts create mode 100644 packages/passkey-controller/src/ceremony-manager.ts diff --git a/packages/passkey-controller/CHANGELOG.md b/packages/passkey-controller/CHANGELOG.md index 0af934fdb8b..74800d0e87f 100644 --- a/packages/passkey-controller/CHANGELOG.md +++ b/packages/passkey-controller/CHANGELOG.md @@ -9,26 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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` +- Initial `@metamask/passkey-controller` package: passkey-based vault key protection using WebAuthn, orchestrating enrollment, authentication, and vault key wrap/unwrap with AES-256-GCM and HKDF-derived keys. +- `PasskeyController` API: + - `generateRegistrationOptions` / `protectVaultKeyWithPasskey` — enrollment and vault key protection + - `generateAuthenticationOptions` / `retrieveVaultKeyWithPasskey` — unlock and vault key recovery + - `renewVaultKeyProtection` — re-wrap vault key for password-change flows without re-enrolling the passkey + - `removePasskey` — unenroll and clear key material + - `isPasskeyEnrolled` — enrollment check (exposed via messenger) + - `clearState` — reset persisted state and clear in-flight WebAuthn ceremony state (for app lifecycle, e.g. wallet reset) +- Adaptive key derivation during enrollment: **PRF** (WebAuthn PRF extension output as HKDF input) or **userHandle** fallback; PRF path used only when `prf.results.first` is a non-empty string. +- Self-contained WebAuthn verification (no Node server): `clientDataJSON` and `authenticatorData` checks, signature verification (`@noble/curves` + Web Crypto RSA), attestation formats `none` and `packed` self-attestation. +- In-flight **ceremony** coordination (distinct from user login sessions): challenge-keyed registration/authentication state in `src/ceremony-manager.ts` (`CeremonyManager` and timing/capacity constants), TTL aligned with WebAuthn `timeout`, and a cap on concurrent ceremonies per flow so multiple tabs/contexts do not overwrite a single in-memory entry. +- Exported timing and limits from package entry: `WEBAUTHN_TIMEOUT_MS`, `SESSION_TTL_SLACK_MS`, `SESSION_MAX_AGE_MS`, `MAX_CONCURRENT_PASSKEY_CEREMONIES` (`PasskeyController` does not re-export these; import from `@metamask/passkey-controller` or `./ceremony-manager` in the monorepo). +- Exported controller types (`PasskeyControllerState`, messenger types, actions, events) and WebAuthn option/response types; internal ceremony payload types `PasskeyRegistrationCeremony` / `PasskeyAuthenticationCeremony` in `types.ts`. +- AES-256-GCM helpers and COSE enums (`COSEALG`, `COSEKEYS`, `COSEKTY`, `COSECRV`). [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/passkey-controller/README.md b/packages/passkey-controller/README.md index c617ab538e9..99240fd10ec 100644 --- a/packages/passkey-controller/README.md +++ b/packages/passkey-controller/README.md @@ -14,7 +14,7 @@ or 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. +1. **Generate options** — call a synchronous method that returns options JSON and records **in-flight ceremony** state (challenge-keyed; not a user login session). 2. **Verify response** — pass the authenticator's response back to the controller, which verifies the WebAuthn signature and performs the cryptographic operation (protect or retrieve the vault key). ### Key derivation strategies @@ -95,7 +95,7 @@ controller.isPasskeyEnrolled(); // boolean controller.removePasskey(); // user-facing unenroll -controller.clearState(); // same persisted reset + session drop; use for app lifecycle (e.g. wallet reset) +controller.clearState(); // same persisted reset + clears in-flight ceremony state; use for app lifecycle (e.g. wallet reset) ``` ## API diff --git a/packages/passkey-controller/src/PasskeyController.test.ts b/packages/passkey-controller/src/PasskeyController.test.ts index 28657a5db56..1440a61f19e 100644 --- a/packages/passkey-controller/src/PasskeyController.test.ts +++ b/packages/passkey-controller/src/PasskeyController.test.ts @@ -1,5 +1,6 @@ import { Messenger } from '@metamask/messenger'; +import { SESSION_MAX_AGE_MS, WEBAUTHN_TIMEOUT_MS } from './ceremony-manager'; import { getDefaultPasskeyControllerState, PasskeyController, @@ -75,6 +76,7 @@ function createController( function minimalRegistrationResponse( overrides?: Partial, + challenge: string = TEST_CHALLENGE, ): PasskeyRegistrationResponse { return { id: TEST_CREDENTIAL_ID, @@ -85,7 +87,7 @@ function minimalRegistrationResponse( new TextEncoder().encode( JSON.stringify({ type: 'webauthn.create', - challenge: TEST_CHALLENGE, + challenge, origin: TEST_ORIGIN, }), ), @@ -101,6 +103,7 @@ function minimalRegistrationResponse( function minimalAuthenticationResponse( userHandle?: string, overrides?: Partial, + challenge: string = TEST_CHALLENGE, ): PasskeyAuthenticationResponse { return { id: TEST_CREDENTIAL_ID, @@ -111,7 +114,7 @@ function minimalAuthenticationResponse( new TextEncoder().encode( JSON.stringify({ type: 'webauthn.get', - challenge: TEST_CHALLENGE, + challenge, origin: TEST_ORIGIN, }), ), @@ -229,6 +232,7 @@ describe('PasskeyController', () => { { alg: -257, type: 'public-key' }, ]); expect(options.attestation).toBe('direct'); + expect(options.timeout).toBe(WEBAUTHN_TIMEOUT_MS); expect( (options.extensions as Record)?.prf, ).toBeDefined(); @@ -284,7 +288,10 @@ describe('PasskeyController', () => { const userHandle = regOptions.user.id; await controller.protectVaultKeyWithPasskey({ - registrationResponse: minimalRegistrationResponse(), + registrationResponse: minimalRegistrationResponse( + undefined, + regOptions.challenge, + ), vaultKey, }); @@ -297,7 +304,11 @@ describe('PasskeyController', () => { expect(authOptions.extensions).toStrictEqual({}); const retrieved = await controller.retrieveVaultKeyWithPasskey( - minimalAuthenticationResponse(userHandle), + minimalAuthenticationResponse( + userHandle, + undefined, + authOptions.challenge, + ), ); expect(retrieved).toBe(vaultKey); }); @@ -318,11 +329,14 @@ describe('PasskeyController', () => { const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(9)); const controller = createController(); - controller.generateRegistrationOptions(); + const regOpts = controller.generateRegistrationOptions(); await controller.protectVaultKeyWithPasskey({ - registrationResponse: minimalRegistrationResponse({ - clientExtensionResults: prfResults(prfFirst, true), - }), + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst, true), + }, + regOpts.challenge, + ), vaultKey: 'k', }); @@ -342,14 +356,14 @@ describe('PasskeyController', () => { }); describe('protectVaultKeyWithPasskey', () => { - it('throws when there is no active registration session', async () => { + it('throws when there is no active registration ceremony', async () => { const controller = createController(); await expect( controller.protectVaultKeyWithPasskey({ registrationResponse: minimalRegistrationResponse(), vaultKey: 'k', }), - ).rejects.toThrow('No active passkey registration session'); + ).rejects.toThrow('No active passkey registration ceremony'); }); it('throws when verification fails', async () => { @@ -358,23 +372,58 @@ describe('PasskeyController', () => { }); const controller = createController(); - controller.generateRegistrationOptions(); + const regOpts = controller.generateRegistrationOptions(); await expect( controller.protectVaultKeyWithPasskey({ - registrationResponse: minimalRegistrationResponse(), + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), vaultKey: 'k', }), ).rejects.toThrow('Passkey registration verification failed'); }); + it('propagates when verifyRegistrationResponse rejects and clears ceremony state', async () => { + mockVerifyRegistrationResponse.mockRejectedValue( + new Error('verify-error'), + ); + + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'k', + }), + ).rejects.toThrow('verify-error'); + + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'k', + }), + ).rejects.toThrow('No active passkey registration ceremony'); + }); + it('stores passkey record with publicKey after successful verification', async () => { setupRegistrationMocks(); const controller = createController(); - controller.generateRegistrationOptions(); + const regOpts = controller.generateRegistrationOptions(); await controller.protectVaultKeyWithPasskey({ - registrationResponse: minimalRegistrationResponse(), + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), vaultKey: 'test-vault-key', }); @@ -389,19 +438,67 @@ describe('PasskeyController', () => { it('uses prf derivation when extension results include PRF output', async () => { setupRegistrationMocks(); const controller = createController(); - controller.generateRegistrationOptions(); + const regOpts = controller.generateRegistrationOptions(); const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(9)); await controller.protectVaultKeyWithPasskey({ - registrationResponse: minimalRegistrationResponse({ - clientExtensionResults: prfResults(prfFirst, true), - }), + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst, true), + }, + regOpts.challenge, + ), vaultKey: 'vault-key-prf-path', }); expect(controller.state.passkeyRecord?.derivationMethod).toBe('prf'); expect(controller.state.passkeyRecord?.prfSalt).toBeDefined(); }); + + it('uses userHandle derivation when PRF was requested but registration returns no PRF output bytes', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const vaultKey = 'vault-prf-requested-no-output'; + + const regOptions = controller.generateRegistrationOptions({ + prfAvailable: true, + }); + expect( + (regOptions.extensions as Record)?.prf, + ).toBeDefined(); + + const userHandle = regOptions.user.id; + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: { + prf: { enabled: true }, + } as ExtOutputsWithPrf, + }, + regOptions.challenge, + ), + vaultKey, + }); + + expect(controller.state.passkeyRecord?.derivationMethod).toBe( + 'userHandle', + ); + expect(controller.state.passkeyRecord?.prfSalt).toBeUndefined(); + + const authOptions = controller.generateAuthenticationOptions(); + expect(authOptions.extensions).toStrictEqual({}); + + const retrieved = await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + userHandle, + undefined, + authOptions.challenge, + ), + ); + expect(retrieved).toBe(vaultKey); + }); }); describe('retrieveVaultKeyWithPasskey', () => { @@ -415,12 +512,15 @@ describe('PasskeyController', () => { ).rejects.toThrow('Passkey is not enrolled'); }); - it('throws when there is no authentication session', async () => { + it('throws when there is no authentication ceremony', async () => { setupRegistrationMocks(); const controller = createController(); - controller.generateRegistrationOptions(); + const regOpts = controller.generateRegistrationOptions(); await controller.protectVaultKeyWithPasskey({ - registrationResponse: minimalRegistrationResponse(), + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), vaultKey: 'k', }); @@ -428,15 +528,18 @@ describe('PasskeyController', () => { controller.retrieveVaultKeyWithPasskey( minimalAuthenticationResponse('uh'), ), - ).rejects.toThrow('No active passkey authentication session'); + ).rejects.toThrow('No active passkey authentication ceremony'); }); it('throws when verification fails', async () => { setupRegistrationMocks(); const controller = createController(); - controller.generateRegistrationOptions(); + const regOpts = controller.generateRegistrationOptions(); await controller.protectVaultKeyWithPasskey({ - registrationResponse: minimalRegistrationResponse(), + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), vaultKey: 'k', }); @@ -445,35 +548,42 @@ describe('PasskeyController', () => { authenticationInfo: {}, }); - controller.generateAuthenticationOptions(); + const authOpts = controller.generateAuthenticationOptions(); await expect( controller.retrieveVaultKeyWithPasskey( - minimalAuthenticationResponse('uh'), + minimalAuthenticationResponse('uh', undefined, authOpts.challenge), ), ).rejects.toThrow('Passkey authentication verification failed'); }); - it('clears the authentication session after successful retrieval (prf)', async () => { + it('clears the authentication ceremony after successful retrieval (prf)', async () => { setupRegistrationMocks(); setupAuthenticationMocks(); const controller = createController(); const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(99)); - controller.generateRegistrationOptions(); + const regOpts = controller.generateRegistrationOptions(); await controller.protectVaultKeyWithPasskey({ - registrationResponse: minimalRegistrationResponse({ - clientExtensionResults: prfResults(prfFirst), - }), + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), vaultKey: 'secret', }); - controller.generateAuthenticationOptions(); + const authOpts = controller.generateAuthenticationOptions(); await controller.retrieveVaultKeyWithPasskey( - minimalAuthenticationResponse(undefined, { - clientExtensionResults: prfResults(prfFirst), - }), + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), ); await expect( @@ -482,7 +592,7 @@ describe('PasskeyController', () => { clientExtensionResults: prfResults(prfFirst), }), ), - ).rejects.toThrow('No active passkey authentication session'); + ).rejects.toThrow('No active passkey authentication ceremony'); }); }); @@ -494,10 +604,13 @@ describe('PasskeyController', () => { const controller = createController(); const vaultKey = 'userhandle-roundtrip-key'; - controller.generateRegistrationOptions(); + const regOpts = controller.generateRegistrationOptions(); await controller.protectVaultKeyWithPasskey({ - registrationResponse: minimalRegistrationResponse(), + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), vaultKey, }); @@ -505,17 +618,25 @@ describe('PasskeyController', () => { 'userHandle', ); - controller.generateAuthenticationOptions(); + let authOpts = controller.generateAuthenticationOptions(); await expect( controller.retrieveVaultKeyWithPasskey( - minimalAuthenticationResponse('bWlzbWF0Y2hlZFVzZXJIYW5kbGU'), + minimalAuthenticationResponse( + 'bWlzbWF0Y2hlZFVzZXJIYW5kbGU', + undefined, + authOpts.challenge, + ), ), ).rejects.toThrow('aes/gcm'); - controller.generateAuthenticationOptions(); + authOpts = controller.generateAuthenticationOptions(); await expect( controller.retrieveVaultKeyWithPasskey( - minimalAuthenticationResponse(undefined), + minimalAuthenticationResponse( + undefined, + undefined, + authOpts.challenge, + ), ), ).rejects.toThrow('Passkey assertion missing required key material'); }); @@ -530,19 +651,26 @@ describe('PasskeyController', () => { const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); const vaultKey = 'prf-roundtrip-key'; - controller.generateRegistrationOptions(); + const regOpts = controller.generateRegistrationOptions(); await controller.protectVaultKeyWithPasskey({ - registrationResponse: minimalRegistrationResponse({ - clientExtensionResults: prfResults(prfFirst), - }), + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), vaultKey, }); - controller.generateAuthenticationOptions(); + const authOpts = controller.generateAuthenticationOptions(); const out = await controller.retrieveVaultKeyWithPasskey( - minimalAuthenticationResponse(undefined, { - clientExtensionResults: prfResults(prfFirst), - }), + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), ); expect(out).toBe(vaultKey); @@ -570,29 +698,40 @@ describe('PasskeyController', () => { const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); const beforeKey = 'vault-key-before-password'; - controller.generateRegistrationOptions(); + const regOpts = controller.generateRegistrationOptions(); await controller.protectVaultKeyWithPasskey({ - registrationResponse: minimalRegistrationResponse({ - clientExtensionResults: prfResults(prfFirst), - }), + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), vaultKey: beforeKey, }); - controller.generateAuthenticationOptions(); + let authOpts = controller.generateAuthenticationOptions(); const afterKey = 'vault-key-after-password'; await controller.renewVaultKeyProtection({ - authenticationResponse: minimalAuthenticationResponse(undefined, { - clientExtensionResults: prfResults(prfFirst), - }), + authenticationResponse: minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), oldVaultKey: beforeKey, newVaultKey: afterKey, }); - controller.generateAuthenticationOptions(); + authOpts = controller.generateAuthenticationOptions(); const unwrapped = await controller.retrieveVaultKeyWithPasskey( - minimalAuthenticationResponse(undefined, { - clientExtensionResults: prfResults(prfFirst), - }), + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), ); expect(unwrapped).toBe(afterKey); }); @@ -604,21 +743,28 @@ describe('PasskeyController', () => { const controller = createController(); const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); - controller.generateRegistrationOptions(); + const regOpts = controller.generateRegistrationOptions(); await controller.protectVaultKeyWithPasskey({ - registrationResponse: minimalRegistrationResponse({ - clientExtensionResults: prfResults(prfFirst), - }), + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), vaultKey: 'actual-wrapped-key', }); - controller.generateAuthenticationOptions(); + const authOpts = controller.generateAuthenticationOptions(); await expect( controller.renewVaultKeyProtection({ - authenticationResponse: minimalAuthenticationResponse(undefined, { - clientExtensionResults: prfResults(prfFirst), - }), + authenticationResponse: minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), oldVaultKey: 'wrong-expected-key', newVaultKey: 'new-key', }), @@ -626,15 +772,126 @@ describe('PasskeyController', () => { 'Passkey authentication does not match the current vault key', ); }); + + it('throws when there is no authentication ceremony', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + vaultKey: 'wrapped', + }); + + await expect( + controller.renewVaultKeyProtection({ + authenticationResponse: minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + TEST_CHALLENGE, + ), + oldVaultKey: 'wrapped', + newVaultKey: 'new', + }), + ).rejects.toThrow('No active passkey authentication ceremony'); + }); + + it('removes authentication ceremony when verification fails during renewal', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + vaultKey: 'wrapped', + }); + + mockVerifyAuthenticationResponse.mockResolvedValue({ + verified: false, + authenticationInfo: {}, + }); + + const authOpts = controller.generateAuthenticationOptions(); + await expect( + controller.renewVaultKeyProtection({ + authenticationResponse: minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + oldVaultKey: 'wrapped', + newVaultKey: 'new', + }), + ).rejects.toThrow('Passkey authentication verification failed'); + + mockVerifyAuthenticationResponse.mockResolvedValue({ + verified: true, + authenticationInfo: { + credentialId: TEST_CREDENTIAL_ID, + newCounter: 0, + userVerified: true, + origin: TEST_ORIGIN, + rpID: TEST_RP_ID, + }, + }); + + const authOptsRetry = controller.generateAuthenticationOptions(); + await controller.renewVaultKeyProtection({ + authenticationResponse: minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOptsRetry.challenge, + ), + oldVaultKey: 'wrapped', + newVaultKey: 'rotated', + }); + + const authOptsFinal = controller.generateAuthenticationOptions(); + const unwrapped = await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOptsFinal.challenge, + ), + ); + expect(unwrapped).toBe('rotated'); + }); }); describe('removePasskey', () => { it('clears stored record and resets enrollment', async () => { setupRegistrationMocks(); const controller = createController(); - controller.generateRegistrationOptions(); + const regOpts = controller.generateRegistrationOptions(); await controller.protectVaultKeyWithPasskey({ - registrationResponse: minimalRegistrationResponse(), + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), vaultKey: 'k', }); expect(controller.isPasskeyEnrolled()).toBe(true); @@ -649,9 +906,12 @@ describe('PasskeyController', () => { it('clears stored record and resets enrollment', async () => { setupRegistrationMocks(); const controller = createController(); - controller.generateRegistrationOptions(); + const regOpts = controller.generateRegistrationOptions(); await controller.protectVaultKeyWithPasskey({ - registrationResponse: minimalRegistrationResponse(), + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), vaultKey: 'k', }); expect(controller.isPasskeyEnrolled()).toBe(true); @@ -670,9 +930,12 @@ describe('PasskeyController', () => { expectedOrigin: 'chrome-extension://abc123', }); - controller.generateRegistrationOptions(); + const regOpts = controller.generateRegistrationOptions(); await controller.protectVaultKeyWithPasskey({ - registrationResponse: minimalRegistrationResponse(), + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), vaultKey: 'k', }); @@ -694,21 +957,28 @@ describe('PasskeyController', () => { const controller = createController(); const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); - controller.generateRegistrationOptions(); + const regOpts = controller.generateRegistrationOptions(); await controller.protectVaultKeyWithPasskey({ - registrationResponse: minimalRegistrationResponse({ - clientExtensionResults: prfResults(prfFirst), - }), + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), vaultKey: 'k', }); - controller.generateAuthenticationOptions(); + const authOpts = controller.generateAuthenticationOptions(); try { await controller.retrieveVaultKeyWithPasskey( - minimalAuthenticationResponse(undefined, { - clientExtensionResults: prfResults(prfFirst), - }), + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), ); } catch { // key derivation result doesn't matter here @@ -732,11 +1002,14 @@ describe('PasskeyController', () => { const controller = createController(); const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); - controller.generateRegistrationOptions(); + const regOpts = controller.generateRegistrationOptions(); await controller.protectVaultKeyWithPasskey({ - registrationResponse: minimalRegistrationResponse({ - clientExtensionResults: prfResults(prfFirst), - }), + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), vaultKey: 'k', }); @@ -753,11 +1026,15 @@ describe('PasskeyController', () => { }, }); - controller.generateAuthenticationOptions(); + let authOpts = controller.generateAuthenticationOptions(); await controller.retrieveVaultKeyWithPasskey( - minimalAuthenticationResponse(undefined, { - clientExtensionResults: prfResults(prfFirst), - }), + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), ); expect(controller.state.passkeyRecord?.counter).toBe(5); @@ -773,11 +1050,15 @@ describe('PasskeyController', () => { }, }); - controller.generateAuthenticationOptions(); + authOpts = controller.generateAuthenticationOptions(); await controller.retrieveVaultKeyWithPasskey( - minimalAuthenticationResponse(undefined, { - clientExtensionResults: prfResults(prfFirst), - }), + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), ); expect(mockVerifyAuthenticationResponse).toHaveBeenLastCalledWith( @@ -790,4 +1071,164 @@ describe('PasskeyController', () => { expect(controller.state.passkeyRecord?.counter).toBe(10); }); }); + + describe('concurrent WebAuthn ceremonies', () => { + it('completes authentication using the first challenge after a second generateAuthenticationOptions', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(7)); + const vaultKey = 'multi-auth-ceremony'; + + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + vaultKey, + }); + + const authOpts1 = controller.generateAuthenticationOptions(); + const authOpts2 = controller.generateAuthenticationOptions(); + + const retrieved = await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts1.challenge, + ), + ); + + expect(retrieved).toBe(vaultKey); + + const authOpts3 = controller.generateAuthenticationOptions(); + expect( + await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts2.challenge, + ), + ), + ).toBe(vaultKey); + + expect( + await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts3.challenge, + ), + ), + ).toBe(vaultKey); + }); + + it('completes registration using the first challenge after a second generateRegistrationOptions', async () => { + setupRegistrationMocks(); + const controller = createController(); + const vaultKey = 'multi-reg-ceremony'; + + const regOpts1 = controller.generateRegistrationOptions(); + controller.generateRegistrationOptions(); + + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts1.challenge, + ), + vaultKey, + }); + + expect(controller.isPasskeyEnrolled()).toBe(true); + expect(controller.state.passkeyRecord).not.toBeNull(); + }); + }); + + describe('ceremony TTL', () => { + it('drops expired registration ceremonies before protectVaultKeyWithPasskey', async () => { + jest.useFakeTimers('modern'); + jest.setSystemTime(1_000_000); + setupRegistrationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + + jest.setSystemTime(1_000_000 + SESSION_MAX_AGE_MS + 1); + + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'k', + }), + ).rejects.toThrow('No active passkey registration ceremony'); + + jest.useRealTimers(); + }); + + it('removes authentication ceremony entry when verification fails', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + const userHandle = regOpts.user.id; + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'k', + }); + + mockVerifyAuthenticationResponse.mockResolvedValue({ + verified: false, + authenticationInfo: {}, + }); + + const authOpts = controller.generateAuthenticationOptions(); + + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + userHandle, + undefined, + authOpts.challenge, + ), + ), + ).rejects.toThrow('Passkey authentication verification failed'); + + mockVerifyAuthenticationResponse.mockResolvedValue({ + verified: true, + authenticationInfo: { + credentialId: TEST_CREDENTIAL_ID, + newCounter: 0, + userVerified: true, + origin: TEST_ORIGIN, + rpID: TEST_RP_ID, + }, + }); + + const authOptsRetry = controller.generateAuthenticationOptions(); + expect( + await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + userHandle, + undefined, + authOptsRetry.challenge, + ), + ), + ).toBe('k'); + }); + }); }); diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index 3be22051c59..3c56907c43a 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -7,15 +7,12 @@ import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; import { randomBytes } from '@noble/ciphers/webcrypto'; +import { WEBAUTHN_TIMEOUT_MS, CeremonyManager } from './ceremony-manager'; import { deriveKeyFromAuthenticationResponse, deriveKeyFromRegistrationResponse, } from './key-derivation'; -import type { - PasskeyAuthenticationSession, - PasskeyRecord, - PasskeyRegistrationSession, -} from './types'; +import type { PasskeyRecord } from './types'; import { decryptWithKey, encryptWithKey } from './utils/crypto'; import { base64URLToBytes, bytesToBase64URL } from './utils/encoding'; import { @@ -29,6 +26,7 @@ import type { PasskeyRegistrationOptions, PasskeyRegistrationResponse, } from './webauthn'; +import { decodeClientDataJSON } from './webauthn/decodeClientDataJSON'; const controllerName = 'PasskeyController'; @@ -100,9 +98,7 @@ export class PasskeyController extends BaseController< PasskeyControllerState, PasskeyControllerMessenger > { - #registrationSession: PasskeyRegistrationSession | null = null; - - #authenticationSession: PasskeyAuthenticationSession | null = null; + readonly #ceremonyManager = new CeremonyManager(); readonly #rpID: string; @@ -150,6 +146,10 @@ export class PasskeyController extends BaseController< return this.state.passkeyRecord; } + #getChallengeFromClientData(clientDataJSON: string): string { + return decodeClientDataJSON(clientDataJSON).challenge; + } + /** * Checks if the passkey is enrolled. * @@ -198,7 +198,7 @@ export class PasskeyController extends BaseController< { alg: COSEALG.ES256, type: 'public-key' }, { alg: COSEALG.RS256, type: 'public-key' }, ], - timeout: 60000, + timeout: WEBAUTHN_TIMEOUT_MS, authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred', @@ -208,11 +208,12 @@ export class PasskeyController extends BaseController< ...(Object.keys(extensions).length > 0 ? { extensions } : {}), }; - this.#registrationSession = { + this.#ceremonyManager.saveRegistrationCeremony(challenge, { userHandle, prfSalt: prfSalt ?? '', challenge, - }; + createdAt: Date.now(), + }); return options; } @@ -251,11 +252,14 @@ export class PasskeyController extends BaseController< }, ], userVerification: 'preferred', - timeout: 60000, + timeout: WEBAUTHN_TIMEOUT_MS, extensions, }; - this.#authenticationSession = { challenge }; + this.#ceremonyManager.saveAuthenticationCeremony(challenge, { + challenge, + createdAt: Date.now(), + }); return options; } @@ -268,7 +272,7 @@ export class PasskeyController extends BaseController< * @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 + * @throws If no registration ceremony is active (call * {@link generateRegistrationOptions} first). * @throws If registration verification fails. */ @@ -276,22 +280,29 @@ export class PasskeyController extends BaseController< registrationResponse: PasskeyRegistrationResponse; vaultKey: string; }): Promise { - const session = this.#registrationSession; - if (!session) { - throw new Error('No active passkey registration session'); - } - const { registrationResponse, vaultKey } = params; + const challengeKey = this.#getChallengeFromClientData( + registrationResponse.response.clientDataJSON, + ); + const registrationCeremony = + this.#ceremonyManager.getRegistrationCeremony(challengeKey); + if (!registrationCeremony) { + throw new Error('No active passkey registration ceremony'); + } const verification = await verifyRegistrationResponse({ response: registrationResponse, - expectedChallenge: session.challenge, + expectedChallenge: registrationCeremony.challenge, expectedOrigin: this.#expectedOrigin, expectedRPID: this.#rpID, requireUserVerification: false, + }).catch((error) => { + this.#ceremonyManager.deleteRegistrationCeremony(challengeKey); + throw error; }); if (!verification.verified || !verification.registrationInfo) { + this.#ceremonyManager.deleteRegistrationCeremony(challengeKey); throw new Error('Passkey registration verification failed'); } @@ -299,7 +310,7 @@ export class PasskeyController extends BaseController< const { encKey, derivationMethod } = deriveKeyFromRegistrationResponse( registrationResponse, - session, + registrationCeremony, ); const { ciphertext, iv } = encryptWithKey(vaultKey, encKey); @@ -309,13 +320,14 @@ export class PasskeyController extends BaseController< derivationMethod, encryptedVaultKey: ciphertext, iv, - prfSalt: derivationMethod === 'prf' ? session.prfSalt : undefined, + prfSalt: + derivationMethod === 'prf' ? registrationCeremony.prfSalt : undefined, publicKey: bytesToBase64URL(registrationInfo.publicKey), counter: registrationInfo.counter, transports: registrationInfo.transports, }); - this.#registrationSession = null; + this.#ceremonyManager.deleteRegistrationCeremony(challengeKey); } /** @@ -325,7 +337,7 @@ export class PasskeyController extends BaseController< * `navigator.credentials.get()`. * @returns The recovered vault encryption key. * @throws If no passkey is enrolled. - * @throws If no authentication session is active (call + * @throws If no authentication ceremony is active (call * {@link generateAuthenticationOptions} first). * @throws If authentication verification or key recovery fails. */ @@ -337,20 +349,40 @@ export class PasskeyController extends BaseController< throw new Error('Passkey is not enrolled'); } - await this.#verifyAuthentication(authenticationResponse, record); + const challengeKey = this.#getChallengeFromClientData( + authenticationResponse.response.clientDataJSON, + ); + const authenticationCeremony = + this.#ceremonyManager.getAuthenticationCeremony(challengeKey); + if (!authenticationCeremony) { + throw new Error('No active passkey authentication ceremony'); + } + + try { + await this.#verifyAuthentication( + authenticationResponse, + record, + authenticationCeremony.challenge, + ); + } catch (error) { + this.#ceremonyManager.deleteAuthenticationCeremony(challengeKey); + throw error; + } + + const updatedRecord = this.#getPasskeyRecord() as PasskeyRecord; const encKey = deriveKeyFromAuthenticationResponse( authenticationResponse, - record, + updatedRecord, ); const vaultKey = decryptWithKey( - record.encryptedVaultKey, - record.iv, + updatedRecord.encryptedVaultKey, + updatedRecord.iv, encKey, ); - this.#authenticationSession = null; + this.#ceremonyManager.deleteAuthenticationCeremony(challengeKey); return vaultKey; } @@ -368,7 +400,7 @@ export class PasskeyController extends BaseController< * (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 no authentication ceremony is active. * @throws If `oldVaultKey` does not match the currently protected key. */ async renewVaultKeyProtection(params: { @@ -383,21 +415,41 @@ export class PasskeyController extends BaseController< throw new Error('Passkey is not enrolled'); } - await this.#verifyAuthentication(authenticationResponse, record); + const challengeKey = this.#getChallengeFromClientData( + authenticationResponse.response.clientDataJSON, + ); + const authenticationCeremony = + this.#ceremonyManager.getAuthenticationCeremony(challengeKey); + if (!authenticationCeremony) { + throw new Error('No active passkey authentication ceremony'); + } + + try { + await this.#verifyAuthentication( + authenticationResponse, + record, + authenticationCeremony.challenge, + ); + } catch (error) { + this.#ceremonyManager.deleteAuthenticationCeremony(challengeKey); + throw error; + } + + const recordAfterVerify = this.#getPasskeyRecord() as PasskeyRecord; const encKey = deriveKeyFromAuthenticationResponse( authenticationResponse, - record, + recordAfterVerify, ); const decryptedVaultKey = decryptWithKey( - record.encryptedVaultKey, - record.iv, + recordAfterVerify.encryptedVaultKey, + recordAfterVerify.iv, encKey, ); if (decryptedVaultKey !== oldVaultKey) { - this.#authenticationSession = null; + this.#ceremonyManager.deleteAuthenticationCeremony(challengeKey); throw new Error( 'Passkey authentication does not match the current vault key', ); @@ -406,25 +458,20 @@ export class PasskeyController extends BaseController< const { ciphertext, iv: newIv } = encryptWithKey(newVaultKey, encKey); this.#setPasskeyRecord({ - ...record, + ...recordAfterVerify, encryptedVaultKey: ciphertext, iv: newIv, }); - this.#authenticationSession = null; + this.#ceremonyManager.deleteAuthenticationCeremony(challengeKey); } - /** - * 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. - */ + /** Resets state and clears in-flight registration/authentication ceremonies. */ clearState(): void { this.update((state) => { Object.assign(state, getDefaultPasskeyControllerState()); }); - this.#registrationSession = null; - this.#authenticationSession = null; + this.#ceremonyManager.clear(); } /** @@ -440,19 +487,17 @@ export class PasskeyController extends BaseController< * * @param authenticationResponse - Authentication result JSON. * @param record - The enrolled passkey record to verify against. + * @param expectedChallenge - Challenge for this ceremony (from in-memory + * ceremony state). */ async #verifyAuthentication( authenticationResponse: PasskeyAuthenticationResponse, record: PasskeyRecord, + expectedChallenge: string, ): Promise { - const session = this.#authenticationSession; - if (!session) { - throw new Error('No active passkey authentication session'); - } - const verification = await verifyAuthenticationResponse({ response: authenticationResponse, - expectedChallenge: session.challenge, + expectedChallenge, expectedOrigin: this.#expectedOrigin, expectedRPID: this.#rpID, credential: { @@ -461,9 +506,7 @@ export class PasskeyController extends BaseController< 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. + // UV optional for device compatibility; vault key remains password-gated. requireUserVerification: false, }); diff --git a/packages/passkey-controller/src/ceremony-manager.test.ts b/packages/passkey-controller/src/ceremony-manager.test.ts new file mode 100644 index 00000000000..88bf3094ed6 --- /dev/null +++ b/packages/passkey-controller/src/ceremony-manager.test.ts @@ -0,0 +1,155 @@ +import { + CeremonyManager, + MAX_CONCURRENT_PASSKEY_CEREMONIES, + SESSION_MAX_AGE_MS, +} from './ceremony-manager'; + +describe('CeremonyManager', () => { + const baseReg = { userHandle: 'u', prfSalt: '' }; + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('registration flow', () => { + it('save stores ceremony state retrievable by challenge', () => { + const manager = new CeremonyManager(); + const now = 1_000_000; + jest.setSystemTime(now); + manager.saveRegistrationCeremony('chal1', { + ...baseReg, + challenge: 'chal1', + createdAt: now, + }); + expect(manager.getRegistrationCeremony('chal1')).toMatchObject({ + challenge: 'chal1', + createdAt: now, + }); + }); + + it('getRegistrationCeremony prunes entries older than SESSION_MAX_AGE_MS before lookup', () => { + const manager = new CeremonyManager(); + const tOld = 100_000; + const tNew = 150_000; + const pruneAt = 200_000; + jest.setSystemTime(tOld); + manager.saveRegistrationCeremony('old', { + ...baseReg, + challenge: 'old', + createdAt: tOld, + }); + jest.setSystemTime(tNew); + manager.saveRegistrationCeremony('new', { + ...baseReg, + challenge: 'new', + createdAt: tNew, + }); + jest.setSystemTime(pruneAt); + expect(pruneAt - tOld).toBeGreaterThan(SESSION_MAX_AGE_MS); + expect(manager.getRegistrationCeremony('new')).toMatchObject({ + challenge: 'new', + createdAt: tNew, + }); + expect(manager.getRegistrationCeremony('old')).toBeUndefined(); + }); + + it('evicts oldest createdAt when at capacity', () => { + const manager = new CeremonyManager(); + const cap = MAX_CONCURRENT_PASSKEY_CEREMONIES; + for (let i = 0; i <= cap; i += 1) { + const createdAt = 10 + i; + jest.setSystemTime(createdAt); + manager.saveRegistrationCeremony(`k${i}`, { + ...baseReg, + challenge: `k${i}`, + createdAt, + }); + } + expect(manager.getRegistrationCeremony('k0')).toBeUndefined(); + expect(manager.getRegistrationCeremony('k1')).toMatchObject({ + challenge: 'k1', + createdAt: 11, + }); + }); + + it('delete removes a single entry', () => { + const manager = new CeremonyManager(); + jest.setSystemTime(0); + manager.saveRegistrationCeremony('x', { + ...baseReg, + challenge: 'x', + createdAt: 0, + }); + expect(manager.deleteRegistrationCeremony('x')).toBe(true); + expect(manager.getRegistrationCeremony('x')).toBeUndefined(); + expect(manager.deleteRegistrationCeremony('missing')).toBe(false); + }); + + it('clear removes registration entries', () => { + const manager = new CeremonyManager(); + jest.setSystemTime(0); + manager.saveRegistrationCeremony('a', { + ...baseReg, + challenge: 'a', + createdAt: 0, + }); + manager.saveRegistrationCeremony('b', { + ...baseReg, + challenge: 'b', + createdAt: 0, + }); + manager.clear(); + expect(manager.getRegistrationCeremony('a')).toBeUndefined(); + expect(manager.getRegistrationCeremony('b')).toBeUndefined(); + }); + }); + + it('registration and authentication maps are independent', () => { + const manager = new CeremonyManager(); + const now = 1_000_000; + jest.setSystemTime(now); + + manager.saveRegistrationCeremony('reg-chal', { + userHandle: 'uh', + prfSalt: '', + challenge: 'reg-chal', + createdAt: now, + }); + manager.saveAuthenticationCeremony('auth-chal', { + challenge: 'auth-chal', + createdAt: now, + }); + + expect(manager.getRegistrationCeremony('reg-chal')).toBeDefined(); + expect(manager.getAuthenticationCeremony('auth-chal')).toBeDefined(); + + jest.setSystemTime(now + SESSION_MAX_AGE_MS + 1); + expect(manager.getRegistrationCeremony('reg-chal')).toBeUndefined(); + + jest.setSystemTime(now); + manager.saveRegistrationCeremony('reg2', { + userHandle: 'uh2', + prfSalt: '', + challenge: 'reg2', + createdAt: now, + }); + jest.setSystemTime(now + SESSION_MAX_AGE_MS + 1); + expect(manager.getAuthenticationCeremony('auth-chal')).toBeUndefined(); + + jest.setSystemTime(now); + manager.saveAuthenticationCeremony('auth2', { + challenge: 'auth2', + createdAt: now, + }); + expect(manager.deleteRegistrationCeremony('reg2')).toBe(true); + expect(manager.deleteAuthenticationCeremony('auth2')).toBe(true); + + manager.clear(); + expect(manager.getRegistrationCeremony('reg2')).toBeUndefined(); + expect(manager.getAuthenticationCeremony('auth2')).toBeUndefined(); + }); +}); diff --git a/packages/passkey-controller/src/ceremony-manager.ts b/packages/passkey-controller/src/ceremony-manager.ts new file mode 100644 index 00000000000..31df15dc574 --- /dev/null +++ b/packages/passkey-controller/src/ceremony-manager.ts @@ -0,0 +1,169 @@ +import type { + PasskeyAuthenticationCeremony, + PasskeyRegistrationCeremony, +} from './types'; + +/** WebAuthn `timeout` for credential creation and assertion (ms). */ +export const WEBAUTHN_TIMEOUT_MS = 60_000; + +/** + * Extra allowance beyond {@link WEBAUTHN_TIMEOUT_MS} before in-memory + * ceremony state is discarded (covers slow UX and clock skew). + */ +export const SESSION_TTL_SLACK_MS = 15_000; + +/** + * Maximum age for in-flight registration or authentication ceremony state + * (between options and verified response), not a user login session. + */ +export const SESSION_MAX_AGE_MS = WEBAUTHN_TIMEOUT_MS + SESSION_TTL_SLACK_MS; + +/** + * Upper bound on concurrent in-memory ceremonies per flow type (registration + * vs authentication), for abuse / leak protection. + */ +export const MAX_CONCURRENT_PASSKEY_CEREMONIES = 16; + +type CeremonyFlow = 'registration' | 'authentication'; + +/** + * In-memory store for in-flight WebAuthn ceremonies (registration vs authentication), + * keyed by base64url challenge. Enforces TTL and a per-flow size cap; not user session state. + */ +export class CeremonyManager { + readonly #registrationMap = new Map(); + + readonly #authenticationMap = new Map< + string, + PasskeyAuthenticationCeremony + >(); + + /** + * Challenge-keyed map for prune/capacity helpers. + * + * @param ceremonyType - Which in-flight ceremony map to use. + * @returns The registration or authentication ceremony map for the given flow. + */ + #getMap( + ceremonyType: CeremonyFlow, + ): Map { + return ceremonyType === 'registration' + ? this.#registrationMap + : this.#authenticationMap; + } + + #pruneExpired(ceremonyType: CeremonyFlow): void { + const now = Date.now(); + const map = this.#getMap(ceremonyType); + for (const [key, ceremony] of map) { + if (now - ceremony.createdAt > SESSION_MAX_AGE_MS) { + map.delete(key); + } + } + } + + /** + * Removes the oldest entry (by `createdAt`) until size is below the cap. + * + * @param ceremonyType - Which in-flight ceremony map to evict from. + */ + #enforceCapacity(ceremonyType: CeremonyFlow): void { + const map = this.#getMap(ceremonyType); + while (map.size >= MAX_CONCURRENT_PASSKEY_CEREMONIES) { + let oldestKey: string | undefined; + let oldestTime = Infinity; + for (const [mapKey, ceremony] of map) { + if (ceremony.createdAt < oldestTime) { + oldestTime = ceremony.createdAt; + oldestKey = mapKey; + } + } + if (oldestKey === undefined) { + break; + } + map.delete(oldestKey); + } + } + + /** + * Records registration ceremony state after pruning expired rows and evicting oldest if at cap. + * + * @param challenge - Same base64url challenge as in the creation options `challenge` field. + * @param ceremony - Payload to retrieve when the registration response returns. + */ + saveRegistrationCeremony( + challenge: string, + ceremony: PasskeyRegistrationCeremony, + ): void { + this.#pruneExpired('registration'); + this.#enforceCapacity('registration'); + this.#registrationMap.set(challenge, ceremony); + } + + /** + * Records authentication ceremony state after pruning expired rows and evicting oldest if at cap. + * + * @param challenge - Same base64url challenge as in the request options `challenge` field. + * @param ceremony - Payload to retrieve when the assertion response returns. + */ + saveAuthenticationCeremony( + challenge: string, + ceremony: PasskeyAuthenticationCeremony, + ): void { + this.#pruneExpired('authentication'); + this.#enforceCapacity('authentication'); + this.#authenticationMap.set(challenge, ceremony); + } + + /** + * Returns registration ceremony for a challenge, pruning expired entries on this map first. + * + * @param challenge - Base64url challenge from decoded `clientDataJSON` (matches stored key). + * @returns Stored ceremony, or `undefined` if none or expired. + */ + getRegistrationCeremony( + challenge: string, + ): PasskeyRegistrationCeremony | undefined { + this.#pruneExpired('registration'); + return this.#registrationMap.get(challenge); + } + + /** + * Returns authentication ceremony for a challenge, pruning expired entries on this map first. + * + * @param challenge - Base64url challenge from decoded `clientDataJSON` (matches stored key). + * @returns Stored ceremony, or `undefined` if none or expired. + */ + getAuthenticationCeremony( + challenge: string, + ): PasskeyAuthenticationCeremony | undefined { + this.#pruneExpired('authentication'); + return this.#authenticationMap.get(challenge); + } + + /** + * Removes a registration ceremony by challenge. + * + * @param challenge - Map key for the ceremony to remove. + * @returns Whether an entry was deleted. + */ + deleteRegistrationCeremony(challenge: string): boolean { + return this.#registrationMap.delete(challenge); + } + + /** + * Removes an authentication ceremony by challenge. + * + * @param challenge - Map key for the ceremony to remove. + * @returns Whether an entry was deleted. + */ + deleteAuthenticationCeremony(challenge: string): boolean { + return this.#authenticationMap.delete(challenge); + } + + /** Drops all in-flight registration and authentication ceremonies. */ + clear(): void { + this.#registrationMap.clear(); + this.#authenticationMap.clear(); + } +} diff --git a/packages/passkey-controller/src/index.ts b/packages/passkey-controller/src/index.ts index 8cbb08a3614..18f4bafe105 100644 --- a/packages/passkey-controller/src/index.ts +++ b/packages/passkey-controller/src/index.ts @@ -2,6 +2,12 @@ export { PasskeyController, getDefaultPasskeyControllerState, } from './PasskeyController'; +export { + WEBAUTHN_TIMEOUT_MS, + SESSION_TTL_SLACK_MS, + SESSION_MAX_AGE_MS, + MAX_CONCURRENT_PASSKEY_CEREMONIES, +} from './ceremony-manager'; export type { PasskeyControllerState, PasskeyControllerMessenger, diff --git a/packages/passkey-controller/src/key-derivation.test.ts b/packages/passkey-controller/src/key-derivation.test.ts index 507874b8dce..d600d9e71fc 100644 --- a/packages/passkey-controller/src/key-derivation.test.ts +++ b/packages/passkey-controller/src/key-derivation.test.ts @@ -2,7 +2,7 @@ import { deriveKeyFromRegistrationResponse, deriveKeyFromAuthenticationResponse, } from './key-derivation'; -import type { PasskeyRecord, PasskeyRegistrationSession } from './types'; +import type { PasskeyRecord, PasskeyRegistrationCeremony } from './types'; import type { PasskeyAuthenticationResponse, PasskeyRegistrationResponse, @@ -20,11 +20,12 @@ const USER_HANDLE = b64url('user-handle-bytes'); const PRF_SALT = b64url('prf-salt-bytes'); const PRF_FIRST = b64url('prf-output-bytes'); -function makeSession(): PasskeyRegistrationSession { +function makeRegistrationCeremony(): PasskeyRegistrationCeremony { return { userHandle: USER_HANDLE, prfSalt: PRF_SALT, challenge: b64url('challenge'), + createdAt: 0, }; } @@ -81,7 +82,7 @@ describe('deriveKeyFromRegistrationResponse', () => { const { encKey, derivationMethod } = deriveKeyFromRegistrationResponse( response, - makeSession(), + makeRegistrationCeremony(), ); expect(derivationMethod).toBe('prf'); @@ -89,25 +90,38 @@ describe('deriveKeyFromRegistrationResponse', () => { expect(encKey).toHaveLength(32); }); - it('uses PRF output when prf.enabled is true', () => { + it('uses PRF output when prf.enabled is true and results.first is present', () => { const response = makeRegistrationResponse({ prf: { enabled: true, results: { first: PRF_FIRST } }, }); const { derivationMethod } = deriveKeyFromRegistrationResponse( response, - makeSession(), + makeRegistrationCeremony(), ); expect(derivationMethod).toBe('prf'); }); + it('falls back to userHandle when prf.enabled is true but results.first is absent', () => { + const response = makeRegistrationResponse({ + prf: { enabled: true }, + }); + + const { derivationMethod } = deriveKeyFromRegistrationResponse( + response, + makeRegistrationCeremony(), + ); + + expect(derivationMethod).toBe('userHandle'); + }); + it('falls back to userHandle when PRF is absent', () => { const response = makeRegistrationResponse({}); const { encKey, derivationMethod } = deriveKeyFromRegistrationResponse( response, - makeSession(), + makeRegistrationCeremony(), ); expect(derivationMethod).toBe('userHandle'); @@ -122,7 +136,7 @@ describe('deriveKeyFromRegistrationResponse', () => { const { derivationMethod } = deriveKeyFromRegistrationResponse( response, - makeSession(), + makeRegistrationCeremony(), ); expect(derivationMethod).toBe('userHandle'); @@ -135,18 +149,18 @@ describe('deriveKeyFromRegistrationResponse', () => { const { encKey: key1 } = deriveKeyFromRegistrationResponse( response1, - makeSession(), + makeRegistrationCeremony(), ); const { encKey: key2 } = deriveKeyFromRegistrationResponse( response2, - makeSession(), + makeRegistrationCeremony(), ); expect(key1).not.toStrictEqual(key2); }); it('produces different keys for PRF vs userHandle', () => { - const session = makeSession(); + const registrationCeremony = makeRegistrationCeremony(); const responseWithPrf = makeRegistrationResponse({ prf: { results: { first: PRF_FIRST } }, @@ -155,11 +169,11 @@ describe('deriveKeyFromRegistrationResponse', () => { const { encKey: prfKey } = deriveKeyFromRegistrationResponse( responseWithPrf, - session, + registrationCeremony, ); const { encKey: uhKey } = deriveKeyFromRegistrationResponse( responseWithoutPrf, - session, + registrationCeremony, ); expect(prfKey).not.toStrictEqual(uhKey); @@ -204,11 +218,11 @@ describe('deriveKeyFromAuthenticationResponse', () => { it('produces consistent keys across registration and authentication', () => { const regResponse = makeRegistrationResponse({}); - const session = makeSession(); + const registrationCeremony = makeRegistrationCeremony(); const { encKey: regKey } = deriveKeyFromRegistrationResponse( regResponse, - session, + registrationCeremony, ); const authResponse = makeAuthenticationResponse({}, USER_HANDLE); diff --git a/packages/passkey-controller/src/key-derivation.ts b/packages/passkey-controller/src/key-derivation.ts index 72be72ff62c..e4e45dde614 100644 --- a/packages/passkey-controller/src/key-derivation.ts +++ b/packages/passkey-controller/src/key-derivation.ts @@ -1,6 +1,6 @@ import type { PasskeyRecord, - PasskeyRegistrationSession, + PasskeyRegistrationCeremony, PrfClientExtensionResults, } from './types'; import { deriveEncryptionKey } from './utils/crypto'; @@ -14,20 +14,21 @@ import type { * 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. + * Uses the PRF output as HKDF input key material when + * `clientExtensionResults.prf.results.first` is a non-empty string; + * otherwise falls back to the random `userHandle` created during option + * generation (including when PRF is enabled but no output is present). * * @param registrationResponse - The registration credential result from * `navigator.credentials.create()`. - * @param session - The in-memory registration session that was created + * @param registrationCeremony - In-flight registration ceremony state from * 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, + registrationCeremony: PasskeyRegistrationCeremony, ): { encKey: Uint8Array; derivationMethod: 'prf' | 'userHandle'; @@ -37,13 +38,14 @@ export function deriveKeyFromRegistrationResponse( 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 hasPrfOutput = typeof prfFirst === 'string' && prfFirst.length > 0; + const derivationMethod: 'prf' | 'userHandle' = hasPrfOutput + ? 'prf' + : 'userHandle'; const ikm: Uint8Array = derivationMethod === 'prf' ? base64URLToBytes(prfFirst as string) - : base64URLToBytes(session.userHandle); + : base64URLToBytes(registrationCeremony.userHandle); const encKey = deriveEncryptionKey(ikm, base64URLToBytes(credentialId)); return { encKey, derivationMethod }; } diff --git a/packages/passkey-controller/src/types.ts b/packages/passkey-controller/src/types.ts index 55acf7e2172..3cd280baddc 100644 --- a/packages/passkey-controller/src/types.ts +++ b/packages/passkey-controller/src/types.ts @@ -32,16 +32,28 @@ export type PasskeyRecord = { transports?: AuthenticatorTransportFuture[]; }; -/** In-memory registration session: creation material + RP challenge bytes. */ -export type PasskeyRegistrationSession = { +/** + * In-memory state for one **in-flight** WebAuthn **registration** ceremony + * (from `create()` options until `protectVaultKeyWithPasskey` completes). This is + * not a user login session; it is keyed by challenge and distinct from the full + * spec ceremony (which includes the authenticator round-trip). + */ +export type PasskeyRegistrationCeremony = { userHandle: Base64URLString; prfSalt: Base64URLString; challenge: Base64URLString; + /** When this ceremony was started (ms since epoch); used for TTL pruning. */ + createdAt: number; }; -/** In-memory authentication session: challenge bytes. */ -export type PasskeyAuthenticationSession = { +/** + * In-memory state for one **in-flight** WebAuthn **authentication** ceremony + * (`get()` options until the assertion is verified). Not a user login session. + */ +export type PasskeyAuthenticationCeremony = { challenge: Base64URLString; + /** When this ceremony was started (ms since epoch); used for TTL pruning. */ + createdAt: number; }; /** From ceeb6fc8e8a0d35d6d0d46c915becf9126769cc5 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Sat, 18 Apr 2026 21:33:49 +0700 Subject: [PATCH 13/44] refactor: use lowercase file names for webauthn module --- packages/passkey-controller/src/PasskeyController.ts | 2 +- ...stationObject.ts => decode-attestation-object.ts} | 0 ...eClientDataJSON.ts => decode-client-data-json.ts} | 0 packages/passkey-controller/src/webauthn/index.ts | 5 +++-- ...{matchExpectedRPID.ts => match-expected-rp-id.ts} | 0 ...henticatorData.ts => parse-authenticator-data.ts} | 0 ...Response.ts => verify-authentication-response.ts} | 8 ++++---- ...onResponse.ts => verify-registration-response.ts} | 10 +++++----- .../{verifySignature.ts => verify-signature.ts} | 0 .../passkey-controller/src/webauthn/webauthn.test.ts | 12 ++++++------ 10 files changed, 19 insertions(+), 18 deletions(-) rename packages/passkey-controller/src/webauthn/{decodeAttestationObject.ts => decode-attestation-object.ts} (100%) rename packages/passkey-controller/src/webauthn/{decodeClientDataJSON.ts => decode-client-data-json.ts} (100%) rename packages/passkey-controller/src/webauthn/{matchExpectedRPID.ts => match-expected-rp-id.ts} (100%) rename packages/passkey-controller/src/webauthn/{parseAuthenticatorData.ts => parse-authenticator-data.ts} (100%) rename packages/passkey-controller/src/webauthn/{verifyAuthenticationResponse.ts => verify-authentication-response.ts} (95%) rename packages/passkey-controller/src/webauthn/{verifyRegistrationResponse.ts => verify-registration-response.ts} (96%) rename packages/passkey-controller/src/webauthn/{verifySignature.ts => verify-signature.ts} (100%) diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index 3c56907c43a..7fed461dc40 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -17,6 +17,7 @@ import { decryptWithKey, encryptWithKey } from './utils/crypto'; import { base64URLToBytes, bytesToBase64URL } from './utils/encoding'; import { COSEALG, + decodeClientDataJSON, verifyAuthenticationResponse, verifyRegistrationResponse, } from './webauthn'; @@ -26,7 +27,6 @@ import type { PasskeyRegistrationOptions, PasskeyRegistrationResponse, } from './webauthn'; -import { decodeClientDataJSON } from './webauthn/decodeClientDataJSON'; const controllerName = 'PasskeyController'; diff --git a/packages/passkey-controller/src/webauthn/decodeAttestationObject.ts b/packages/passkey-controller/src/webauthn/decode-attestation-object.ts similarity index 100% rename from packages/passkey-controller/src/webauthn/decodeAttestationObject.ts rename to packages/passkey-controller/src/webauthn/decode-attestation-object.ts diff --git a/packages/passkey-controller/src/webauthn/decodeClientDataJSON.ts b/packages/passkey-controller/src/webauthn/decode-client-data-json.ts similarity index 100% rename from packages/passkey-controller/src/webauthn/decodeClientDataJSON.ts rename to packages/passkey-controller/src/webauthn/decode-client-data-json.ts diff --git a/packages/passkey-controller/src/webauthn/index.ts b/packages/passkey-controller/src/webauthn/index.ts index e51c4089d83..14acb315af9 100644 --- a/packages/passkey-controller/src/webauthn/index.ts +++ b/packages/passkey-controller/src/webauthn/index.ts @@ -1,12 +1,13 @@ export { COSEALG } from './constants'; +export { decodeClientDataJSON } from './decode-client-data-json'; export { verifyRegistrationResponse, type VerifiedRegistrationResponse, -} from './verifyRegistrationResponse'; +} from './verify-registration-response'; export { verifyAuthenticationResponse, type VerifiedAuthenticationResponse, -} from './verifyAuthenticationResponse'; +} from './verify-authentication-response'; export type { PasskeyRegistrationOptions, PasskeyRegistrationResponse, diff --git a/packages/passkey-controller/src/webauthn/matchExpectedRPID.ts b/packages/passkey-controller/src/webauthn/match-expected-rp-id.ts similarity index 100% rename from packages/passkey-controller/src/webauthn/matchExpectedRPID.ts rename to packages/passkey-controller/src/webauthn/match-expected-rp-id.ts diff --git a/packages/passkey-controller/src/webauthn/parseAuthenticatorData.ts b/packages/passkey-controller/src/webauthn/parse-authenticator-data.ts similarity index 100% rename from packages/passkey-controller/src/webauthn/parseAuthenticatorData.ts rename to packages/passkey-controller/src/webauthn/parse-authenticator-data.ts diff --git a/packages/passkey-controller/src/webauthn/verifyAuthenticationResponse.ts b/packages/passkey-controller/src/webauthn/verify-authentication-response.ts similarity index 95% rename from packages/passkey-controller/src/webauthn/verifyAuthenticationResponse.ts rename to packages/passkey-controller/src/webauthn/verify-authentication-response.ts index 38f64676483..6e32f63d53e 100644 --- a/packages/passkey-controller/src/webauthn/verifyAuthenticationResponse.ts +++ b/packages/passkey-controller/src/webauthn/verify-authentication-response.ts @@ -1,12 +1,12 @@ 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 { decodeClientDataJSON } from './decode-client-data-json'; +import { matchExpectedRPID } from './match-expected-rp-id'; +import { parseAuthenticatorData } from './parse-authenticator-data'; import type { ParsedAuthenticatorData } from './types'; import type { PasskeyAuthenticationResponse } from './types'; -import { verifySignature } from './verifySignature'; +import { verifySignature } from './verify-signature'; import type { AuthenticatorTransportFuture } from '../types'; import { base64URLToBytes } from '../utils/encoding'; diff --git a/packages/passkey-controller/src/webauthn/verifyRegistrationResponse.ts b/packages/passkey-controller/src/webauthn/verify-registration-response.ts similarity index 96% rename from packages/passkey-controller/src/webauthn/verifyRegistrationResponse.ts rename to packages/passkey-controller/src/webauthn/verify-registration-response.ts index f6b5d5bae57..8fa5026c6e2 100644 --- a/packages/passkey-controller/src/webauthn/verifyRegistrationResponse.ts +++ b/packages/passkey-controller/src/webauthn/verify-registration-response.ts @@ -2,12 +2,12 @@ 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 { decodeAttestationObject } from './decode-attestation-object'; +import { decodeClientDataJSON } from './decode-client-data-json'; +import { matchExpectedRPID } from './match-expected-rp-id'; +import { parseAuthenticatorData } from './parse-authenticator-data'; import type { PasskeyRegistrationResponse } from './types'; -import { verifySignature } from './verifySignature'; +import { verifySignature } from './verify-signature'; import type { AuthenticatorTransportFuture } from '../types'; import { base64URLToBytes, diff --git a/packages/passkey-controller/src/webauthn/verifySignature.ts b/packages/passkey-controller/src/webauthn/verify-signature.ts similarity index 100% rename from packages/passkey-controller/src/webauthn/verifySignature.ts rename to packages/passkey-controller/src/webauthn/verify-signature.ts diff --git a/packages/passkey-controller/src/webauthn/webauthn.test.ts b/packages/passkey-controller/src/webauthn/webauthn.test.ts index add5d4ff2be..8e128acb771 100644 --- a/packages/passkey-controller/src/webauthn/webauthn.test.ts +++ b/packages/passkey-controller/src/webauthn/webauthn.test.ts @@ -9,8 +9,8 @@ import type { PasskeyRegistrationResponse, PasskeyAuthenticationResponse, } from './types'; -import { verifyAuthenticationResponse } from './verifyAuthenticationResponse'; -import { verifyRegistrationResponse } from './verifyRegistrationResponse'; +import { verifyAuthenticationResponse } from './verify-authentication-response'; +import { verifyRegistrationResponse } from './verify-registration-response'; import { bytesToBase64URL } from '../utils/encoding'; // --------------------------------------------------------------------------- @@ -1437,7 +1437,7 @@ describe('verifyRegistrationResponse edge cases', () => { describe('verifySignature', () => { // eslint-disable-next-line @typescript-eslint/no-require-imports, n/global-require - const { verifySignature } = require('./verifySignature'); + const { verifySignature } = require('./verify-signature'); it('verifies P-384 EC2 signature', async () => { const privateKey = p384.utils.randomPrivateKey(); @@ -1652,7 +1652,7 @@ describe('verifySignature', () => { describe('parseAuthenticatorData edge cases', () => { /* eslint-disable @typescript-eslint/no-require-imports, n/global-require */ - const { parseAuthenticatorData } = require('./parseAuthenticatorData'); + const { parseAuthenticatorData } = require('./parse-authenticator-data'); /* eslint-enable @typescript-eslint/no-require-imports, n/global-require */ it('throws for authenticator data shorter than 37 bytes', () => { @@ -1725,7 +1725,7 @@ describe('parseAuthenticatorData edge cases', () => { describe('matchExpectedRPID edge cases', () => { // eslint-disable-next-line @typescript-eslint/no-require-imports, n/global-require - const { matchExpectedRPID } = require('./matchExpectedRPID'); + const { matchExpectedRPID } = require('./match-expected-rp-id'); it('throws when no RP ID matches', () => { const rpIdHash = sha256(new TextEncoder().encode('example.com')); @@ -1799,7 +1799,7 @@ describe('verifySignature RSA hash variants', () => { /* eslint-disable @typescript-eslint/no-require-imports, n/global-require */ const { verifySignature: verifySignatureHelper, - } = require('./verifySignature'); + } = require('./verify-signature'); /* eslint-enable @typescript-eslint/no-require-imports, n/global-require */ async function generateRSAKeyPairAndSign( From 31bdb6b0ef2a5e7632a41b3ea2921439d08739e0 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Sat, 18 Apr 2026 22:26:41 +0700 Subject: [PATCH 14/44] chore: refactor passkey controller --- .../src/PasskeyController.ts | 268 ++++++++---------- 1 file changed, 115 insertions(+), 153 deletions(-) diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index 7fed461dc40..f64804b470b 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -82,16 +82,10 @@ const passkeyControllerMetadata = { } satisfies StateMetadata; /** - * Manages passkey-based vault key protection using WebAuthn. + * Passkey-based protection for the vault encryption key (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. + * Uses PRF-backed derivation when available; otherwise uses the credential + * `userHandle`. */ export class PasskeyController extends BaseController< typeof controllerName, @@ -160,15 +154,13 @@ export class PasskeyController extends BaseController< } /** - * Produces WebAuthn credential creation options for passkey enrollment. + * Registration options for enrolling a passkey. * - * Must be called before {@link protectVaultKeyWithPasskey}. + * Call 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()`. + * @param creationOptionsConfig.prfAvailable - Omit PRF when `false`. Default `true`. + * @returns Options for `navigator.credentials.create()`. */ generateRegistrationOptions(creationOptionsConfig?: { prfAvailable?: boolean; @@ -219,14 +211,11 @@ export class PasskeyController extends BaseController< } /** - * Produces WebAuthn credential request options for passkey - * authentication. + * WebAuthn request options for authenticating with the enrolled passkey. * - * Must be called before {@link retrieveVaultKeyWithPasskey} or - * {@link renewVaultKeyProtection}. + * Call before {@link retrieveVaultKeyWithPasskey} or {@link renewVaultKeyProtection}. * - * @returns Options JSON for `navigator.credentials.get()`. - * @throws If no passkey is currently enrolled. + * @returns Options for `navigator.credentials.get()`. */ generateAuthenticationOptions(): PasskeyAuthenticationOptions { const record = this.#getPasskeyRecord(); @@ -265,56 +254,53 @@ export class PasskeyController extends BaseController< } /** - * Completes passkey enrollment by verifying the registration response - * and protecting the vault key with the new credential. + * Completes enrollment and binds the vault key to the new passkey. * * @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 ceremony is active (call - * {@link generateRegistrationOptions} first). - * @throws If registration verification fails. + * @param params.registrationResponse - Credential from `navigator.credentials.create()`. + * @param params.vaultKey - Vault encryption key to protect. */ async protectVaultKeyWithPasskey(params: { registrationResponse: PasskeyRegistrationResponse; vaultKey: string; }): Promise { + // get challenge const { registrationResponse, vaultKey } = params; - const challengeKey = this.#getChallengeFromClientData( + const challenge = this.#getChallengeFromClientData( registrationResponse.response.clientDataJSON, ); const registrationCeremony = - this.#ceremonyManager.getRegistrationCeremony(challengeKey); + this.#ceremonyManager.getRegistrationCeremony(challenge); if (!registrationCeremony) { throw new Error('No active passkey registration ceremony'); } - const verification = await verifyRegistrationResponse({ + // verify registration response + const { verified, registrationInfo } = await verifyRegistrationResponse({ response: registrationResponse, expectedChallenge: registrationCeremony.challenge, expectedOrigin: this.#expectedOrigin, expectedRPID: this.#rpID, requireUserVerification: false, }).catch((error) => { - this.#ceremonyManager.deleteRegistrationCeremony(challengeKey); + this.#ceremonyManager.deleteRegistrationCeremony(challenge); throw error; }); - - if (!verification.verified || !verification.registrationInfo) { - this.#ceremonyManager.deleteRegistrationCeremony(challengeKey); + if (!verified || !registrationInfo) { + this.#ceremonyManager.deleteRegistrationCeremony(challenge); throw new Error('Passkey registration verification failed'); } - const { registrationInfo } = verification; - + // derive key const { encKey, derivationMethod } = deriveKeyFromRegistrationResponse( registrationResponse, registrationCeremony, ); + // encrypt vault key const { ciphertext, iv } = encryptWithKey(vaultKey, encKey); + // persist passkey record this.#setPasskeyRecord({ credentialId: registrationInfo.credentialId, derivationMethod, @@ -327,143 +313,88 @@ export class PasskeyController extends BaseController< transports: registrationInfo.transports, }); - this.#ceremonyManager.deleteRegistrationCeremony(challengeKey); + // delete ceremony + this.#ceremonyManager.deleteRegistrationCeremony(challenge); } /** - * Retrieves the vault key protected by the enrolled passkey. + * Returns the decrypted vault encryption key from the passkey authentication + * response. * - * @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 ceremony is active (call - * {@link generateAuthenticationOptions} first). - * @throws If authentication verification or key recovery fails. + * @param authenticationResponse - Credential from `navigator.credentials.get()`. + * @returns The vault encryption key. */ async retrieveVaultKeyWithPasskey( authenticationResponse: PasskeyAuthenticationResponse, ): Promise { - const record = this.#getPasskeyRecord(); - if (!record) { - throw new Error('Passkey is not enrolled'); - } - - const challengeKey = this.#getChallengeFromClientData( - authenticationResponse.response.clientDataJSON, - ); - const authenticationCeremony = - this.#ceremonyManager.getAuthenticationCeremony(challengeKey); - if (!authenticationCeremony) { - throw new Error('No active passkey authentication ceremony'); - } - - try { - await this.#verifyAuthentication( - authenticationResponse, - record, - authenticationCeremony.challenge, - ); - } catch (error) { - this.#ceremonyManager.deleteAuthenticationCeremony(challengeKey); - throw error; - } - - const updatedRecord = this.#getPasskeyRecord() as PasskeyRecord; + // verify authentication response + await this.#verifyAuthentication(authenticationResponse); + // derive key + const passkeyRecord = this.#getPasskeyRecord() as PasskeyRecord; const encKey = deriveKeyFromAuthenticationResponse( authenticationResponse, - updatedRecord, + passkeyRecord, ); + // decrypt vault key const vaultKey = decryptWithKey( - updatedRecord.encryptedVaultKey, - updatedRecord.iv, + passkeyRecord.encryptedVaultKey, + passkeyRecord.iv, encKey, ); - this.#ceremonyManager.deleteAuthenticationCeremony(challengeKey); - 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. + * Updates the vault encryption key for the same passkey (e.g. after a password change). * * @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 ceremony is active. - * @throws If `oldVaultKey` does not match the currently protected key. + * @param params.authenticationResponse - Credential from `navigator.credentials.get()`. + * @param params.oldVaultKey - Expected current vault key. + * @param params.newVaultKey - New vault key to protect. */ async renewVaultKeyProtection(params: { authenticationResponse: PasskeyAuthenticationResponse; oldVaultKey: string; newVaultKey: string; }): Promise { - const { authenticationResponse, oldVaultKey, newVaultKey } = params; - - const record = this.#getPasskeyRecord(); - if (!record) { - throw new Error('Passkey is not enrolled'); - } - - const challengeKey = this.#getChallengeFromClientData( - authenticationResponse.response.clientDataJSON, - ); - const authenticationCeremony = - this.#ceremonyManager.getAuthenticationCeremony(challengeKey); - if (!authenticationCeremony) { - throw new Error('No active passkey authentication ceremony'); - } - - try { - await this.#verifyAuthentication( - authenticationResponse, - record, - authenticationCeremony.challenge, - ); - } catch (error) { - this.#ceremonyManager.deleteAuthenticationCeremony(challengeKey); - throw error; - } - - const recordAfterVerify = this.#getPasskeyRecord() as PasskeyRecord; + // verify authentication response + const { authenticationResponse } = params; + await this.#verifyAuthentication(authenticationResponse); + // derive key + const passkeyRecord = this.#getPasskeyRecord() as PasskeyRecord; const encKey = deriveKeyFromAuthenticationResponse( authenticationResponse, - recordAfterVerify, + passkeyRecord, ); + // decrypt vault key const decryptedVaultKey = decryptWithKey( - recordAfterVerify.encryptedVaultKey, - recordAfterVerify.iv, + passkeyRecord.encryptedVaultKey, + passkeyRecord.iv, encKey, ); + // check if vault key matches + const { oldVaultKey, newVaultKey } = params; if (decryptedVaultKey !== oldVaultKey) { - this.#ceremonyManager.deleteAuthenticationCeremony(challengeKey); throw new Error( 'Passkey authentication does not match the current vault key', ); } + // encrypt new vault key const { ciphertext, iv: newIv } = encryptWithKey(newVaultKey, encKey); + // persist passkey record this.#setPasskeyRecord({ - ...recordAfterVerify, + ...passkeyRecord, encryptedVaultKey: ciphertext, iv: newIv, }); - - this.#ceremonyManager.deleteAuthenticationCeremony(challengeKey); } /** Resets state and clears in-flight registration/authentication ceremonies. */ @@ -482,41 +413,72 @@ export class PasskeyController extends BaseController< } /** - * Verifies a WebAuthn authentication response against the enrolled - * credential. + * Verifies an authentication response for the enrolled passkey. * * @param authenticationResponse - Authentication result JSON. - * @param record - The enrolled passkey record to verify against. - * @param expectedChallenge - Challenge for this ceremony (from in-memory - * ceremony state). + * @param shouldDeleteCeremony - Remove the in-flight ceremony after success (default: true). */ async #verifyAuthentication( authenticationResponse: PasskeyAuthenticationResponse, - record: PasskeyRecord, - expectedChallenge: string, + shouldDeleteCeremony = true, ): Promise { - const verification = await verifyAuthenticationResponse({ - response: authenticationResponse, - expectedChallenge, - expectedOrigin: this.#expectedOrigin, - expectedRPID: this.#rpID, - credential: { - id: record.credentialId, - publicKey: base64URLToBytes(record.publicKey), - counter: record.counter, - transports: record.transports, - }, - // UV optional for device compatibility; vault key remains password-gated. - requireUserVerification: false, - }); + let challenge: string | undefined; + try { + // get challenge + challenge = this.#getChallengeFromClientData( + authenticationResponse.response.clientDataJSON, + ); - if (!verification.verified) { - throw new Error('Passkey authentication verification failed'); + // get passkey record + const record = this.#getPasskeyRecord(); + if (!record) { + throw new Error('Passkey is not enrolled'); + } + + // get authentication ceremony + const authenticationCeremony = + this.#ceremonyManager.getAuthenticationCeremony(challenge); + if (!authenticationCeremony) { + throw new Error('No active passkey authentication ceremony'); + } + + // verify authentication response + const { verified, authenticationInfo } = + await verifyAuthenticationResponse({ + response: authenticationResponse, + expectedChallenge: authenticationCeremony.challenge, + expectedOrigin: this.#expectedOrigin, + expectedRPID: this.#rpID, + credential: { + id: record.credentialId, + publicKey: base64URLToBytes(record.publicKey), + counter: record.counter, + transports: record.transports, + }, + // UV optional for device compatibility; vault key remains password-gated. + requireUserVerification: false, + }); + + if (!verified) { + throw new Error('Passkey authentication verification failed'); + } + + // persist passkey record with updated counter + const updatedRecord: PasskeyRecord = { + ...record, + counter: authenticationInfo.newCounter, + }; + this.#setPasskeyRecord(updatedRecord); + + // delete ceremony if requested + if (shouldDeleteCeremony) { + this.#ceremonyManager.deleteAuthenticationCeremony(challenge); + } + } catch (error) { + if (challenge && shouldDeleteCeremony) { + this.#ceremonyManager.deleteAuthenticationCeremony(challenge); + } + throw error; } - - this.#setPasskeyRecord({ - ...record, - counter: verification.authenticationInfo.newCounter, - }); } } From 69a8dae6b57ea6d3c75f9ef7da1eaf68f2958552 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Sat, 18 Apr 2026 22:37:01 +0700 Subject: [PATCH 15/44] feat: allow verifying passkey authentication --- packages/passkey-controller/CHANGELOG.md | 1 + .../src/PasskeyController.test.ts | 90 +++++++++++++++++++ .../src/PasskeyController.ts | 27 +++++- 3 files changed, 114 insertions(+), 4 deletions(-) diff --git a/packages/passkey-controller/CHANGELOG.md b/packages/passkey-controller/CHANGELOG.md index 74800d0e87f..ccf832b7631 100644 --- a/packages/passkey-controller/CHANGELOG.md +++ b/packages/passkey-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `PasskeyController.verifyPasskeyAuthentication` — returns whether the passkey authentication response is valid without returning the vault key (delegates to the same path as `retrieveVaultKeyWithPasskey`). - Initial `@metamask/passkey-controller` package: passkey-based vault key protection using WebAuthn, orchestrating enrollment, authentication, and vault key wrap/unwrap with AES-256-GCM and HKDF-derived keys. - `PasskeyController` API: - `generateRegistrationOptions` / `protectVaultKeyWithPasskey` — enrollment and vault key protection diff --git a/packages/passkey-controller/src/PasskeyController.test.ts b/packages/passkey-controller/src/PasskeyController.test.ts index 1440a61f19e..bc8e9b9b967 100644 --- a/packages/passkey-controller/src/PasskeyController.test.ts +++ b/packages/passkey-controller/src/PasskeyController.test.ts @@ -596,6 +596,96 @@ describe('PasskeyController', () => { }); }); + describe('verifyPasskeyAuthentication', () => { + it('returns false when passkey is not enrolled', async () => { + setupAuthenticationMocks(); + const controller = createController(); + expect( + await controller.verifyPasskeyAuthentication( + minimalAuthenticationResponse('uh'), + ), + ).toBe(false); + }); + + it('returns false when there is no authentication ceremony', async () => { + setupRegistrationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'k', + }); + + expect( + await controller.verifyPasskeyAuthentication( + minimalAuthenticationResponse('uh'), + ), + ).toBe(false); + }); + + it('returns false when verification fails', async () => { + setupRegistrationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'k', + }); + + mockVerifyAuthenticationResponse.mockResolvedValue({ + verified: false, + authenticationInfo: {}, + }); + + const authOpts = controller.generateAuthenticationOptions(); + + expect( + await controller.verifyPasskeyAuthentication( + minimalAuthenticationResponse('uh', undefined, authOpts.challenge), + ), + ).toBe(false); + }); + + it('returns true on successful authentication (prf)', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(7)); + const vaultKey = 'verify-bool-ok'; + + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + vaultKey, + }); + + const authOpts = controller.generateAuthenticationOptions(); + expect( + await controller.verifyPasskeyAuthentication( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + ), + ).toBe(true); + }); + }); + describe('registration and authentication round-trip (userHandle)', () => { it('retrieves vault key using userHandle derivation', async () => { setupRegistrationMocks(); diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index f64804b470b..4f3243ab4a7 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -213,7 +213,8 @@ export class PasskeyController extends BaseController< /** * WebAuthn request options for authenticating with the enrolled passkey. * - * Call before {@link retrieveVaultKeyWithPasskey} or {@link renewVaultKeyProtection}. + * Call before {@link retrieveVaultKeyWithPasskey}, + * {@link verifyPasskeyAuthentication}, or {@link renewVaultKeyProtection}. * * @returns Options for `navigator.credentials.get()`. */ @@ -328,7 +329,7 @@ export class PasskeyController extends BaseController< authenticationResponse: PasskeyAuthenticationResponse, ): Promise { // verify authentication response - await this.#verifyAuthentication(authenticationResponse); + await this.#verifyAuthenticationResponse(authenticationResponse); // derive key const passkeyRecord = this.#getPasskeyRecord() as PasskeyRecord; @@ -347,6 +348,24 @@ export class PasskeyController extends BaseController< return vaultKey; } + /** + * Returns whether passkey authentication succeeds for this credential (same + * work as {@link retrieveVaultKeyWithPasskey} without exposing the vault key). + * + * @param authenticationResponse - Credential from `navigator.credentials.get()`. + * @returns `true` if authentication succeeds, otherwise `false`. + */ + async verifyPasskeyAuthentication( + authenticationResponse: PasskeyAuthenticationResponse, + ): Promise { + try { + await this.retrieveVaultKeyWithPasskey(authenticationResponse); + return true; + } catch { + return false; + } + } + /** * Updates the vault encryption key for the same passkey (e.g. after a password change). * @@ -362,7 +381,7 @@ export class PasskeyController extends BaseController< }): Promise { // verify authentication response const { authenticationResponse } = params; - await this.#verifyAuthentication(authenticationResponse); + await this.#verifyAuthenticationResponse(authenticationResponse); // derive key const passkeyRecord = this.#getPasskeyRecord() as PasskeyRecord; @@ -418,7 +437,7 @@ export class PasskeyController extends BaseController< * @param authenticationResponse - Authentication result JSON. * @param shouldDeleteCeremony - Remove the in-flight ceremony after success (default: true). */ - async #verifyAuthentication( + async #verifyAuthenticationResponse( authenticationResponse: PasskeyAuthenticationResponse, shouldDeleteCeremony = true, ): Promise { From d1ff0ae2e73f924ea9f8c644aadb223f993369ba Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Mon, 20 Apr 2026 12:59:51 +0700 Subject: [PATCH 16/44] feat: add hints to passkey registration and authentication options --- packages/passkey-controller/src/PasskeyController.ts | 4 +++- packages/passkey-controller/src/index.ts | 1 + packages/passkey-controller/src/webauthn/index.ts | 1 + packages/passkey-controller/src/webauthn/types.ts | 7 +++++++ 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index 4f3243ab4a7..16522cced01 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -192,10 +192,11 @@ export class PasskeyController extends BaseController< ], timeout: WEBAUTHN_TIMEOUT_MS, authenticatorSelection: { - residentKey: 'preferred', userVerification: 'preferred', authenticatorAttachment: 'platform', + residentKey: 'preferred', }, + hints: ['client-device', 'hybrid'], attestation: 'direct', ...(Object.keys(extensions).length > 0 ? { extensions } : {}), }; @@ -242,6 +243,7 @@ export class PasskeyController extends BaseController< }, ], userVerification: 'preferred', + hints: ['client-device', 'hybrid'], timeout: WEBAUTHN_TIMEOUT_MS, extensions, }; diff --git a/packages/passkey-controller/src/index.ts b/packages/passkey-controller/src/index.ts index 18f4bafe105..164e34e1f59 100644 --- a/packages/passkey-controller/src/index.ts +++ b/packages/passkey-controller/src/index.ts @@ -28,4 +28,5 @@ export type { PasskeyRegistrationResponse, PasskeyAuthenticationOptions, PasskeyAuthenticationResponse, + PublicKeyCredentialHint, } from './webauthn'; diff --git a/packages/passkey-controller/src/webauthn/index.ts b/packages/passkey-controller/src/webauthn/index.ts index 14acb315af9..36011c77788 100644 --- a/packages/passkey-controller/src/webauthn/index.ts +++ b/packages/passkey-controller/src/webauthn/index.ts @@ -13,4 +13,5 @@ export type { PasskeyRegistrationResponse, PasskeyAuthenticationOptions, PasskeyAuthenticationResponse, + PublicKeyCredentialHint, } from './types'; diff --git a/packages/passkey-controller/src/webauthn/types.ts b/packages/passkey-controller/src/webauthn/types.ts index f255d7f7a70..e4e28108172 100644 --- a/packages/passkey-controller/src/webauthn/types.ts +++ b/packages/passkey-controller/src/webauthn/types.ts @@ -9,6 +9,11 @@ export type PublicKeyCredentialDescriptorJSON = { transports?: AuthenticatorTransportFuture[]; }; +export type PublicKeyCredentialHint = + | 'hybrid' + | 'security-key' + | 'client-device'; + export type PasskeyRegistrationOptions = { rp: { name: string; id: string }; user: { @@ -26,6 +31,7 @@ export type PasskeyRegistrationOptions = { requireResidentKey?: boolean; userVerification?: 'discouraged' | 'preferred' | 'required'; }; + hints?: PublicKeyCredentialHint[]; attestation?: 'direct' | 'enterprise' | 'indirect' | 'none'; extensions?: Record; }; @@ -52,6 +58,7 @@ export type PasskeyAuthenticationOptions = { rpId?: string; allowCredentials?: PublicKeyCredentialDescriptorJSON[]; userVerification?: 'discouraged' | 'preferred' | 'required'; + hints?: PublicKeyCredentialHint[]; extensions?: Record; }; From e530b6808e12c8b436379f5a77801fb8073b47c8 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Mon, 20 Apr 2026 14:14:59 +0700 Subject: [PATCH 17/44] feat: skip verification of passkey authentication response when renewing vault key protection --- .../src/PasskeyController.test.ts | 87 +++++++------------ .../src/PasskeyController.ts | 19 ++-- 2 files changed, 36 insertions(+), 70 deletions(-) diff --git a/packages/passkey-controller/src/PasskeyController.test.ts b/packages/passkey-controller/src/PasskeyController.test.ts index bc8e9b9b967..79758e36010 100644 --- a/packages/passkey-controller/src/PasskeyController.test.ts +++ b/packages/passkey-controller/src/PasskeyController.test.ts @@ -777,7 +777,7 @@ describe('PasskeyController', () => { oldVaultKey: 'old', newVaultKey: 'new', }), - ).rejects.toThrow('Passkey is not enrolled'); + ).rejects.toThrow(TypeError); }); it('updates the passkey wrap when before/after vault keys match', async () => { @@ -863,7 +863,7 @@ describe('PasskeyController', () => { ); }); - it('throws when there is no authentication ceremony', async () => { + it('completes renewal without an active authentication ceremony (prf)', async () => { setupRegistrationMocks(); setupAuthenticationMocks(); @@ -881,22 +881,32 @@ describe('PasskeyController', () => { vaultKey: 'wrapped', }); - await expect( - controller.renewVaultKeyProtection({ - authenticationResponse: minimalAuthenticationResponse( - undefined, - { - clientExtensionResults: prfResults(prfFirst), - }, - TEST_CHALLENGE, - ), - oldVaultKey: 'wrapped', - newVaultKey: 'new', - }), - ).rejects.toThrow('No active passkey authentication ceremony'); + await controller.renewVaultKeyProtection({ + authenticationResponse: minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + TEST_CHALLENGE, + ), + oldVaultKey: 'wrapped', + newVaultKey: 'new', + }); + + const authOpts = controller.generateAuthenticationOptions(); + const unwrapped = await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + ); + expect(unwrapped).toBe('new'); }); - it('removes authentication ceremony when verification fails during renewal', async () => { + it('does not invoke verifyAuthenticationResponse', async () => { setupRegistrationMocks(); setupAuthenticationMocks(); @@ -914,61 +924,22 @@ describe('PasskeyController', () => { vaultKey: 'wrapped', }); - mockVerifyAuthenticationResponse.mockResolvedValue({ - verified: false, - authenticationInfo: {}, - }); + mockVerifyAuthenticationResponse.mockClear(); const authOpts = controller.generateAuthenticationOptions(); - await expect( - controller.renewVaultKeyProtection({ - authenticationResponse: minimalAuthenticationResponse( - undefined, - { - clientExtensionResults: prfResults(prfFirst), - }, - authOpts.challenge, - ), - oldVaultKey: 'wrapped', - newVaultKey: 'new', - }), - ).rejects.toThrow('Passkey authentication verification failed'); - - mockVerifyAuthenticationResponse.mockResolvedValue({ - verified: true, - authenticationInfo: { - credentialId: TEST_CREDENTIAL_ID, - newCounter: 0, - userVerified: true, - origin: TEST_ORIGIN, - rpID: TEST_RP_ID, - }, - }); - - const authOptsRetry = controller.generateAuthenticationOptions(); await controller.renewVaultKeyProtection({ authenticationResponse: minimalAuthenticationResponse( undefined, { clientExtensionResults: prfResults(prfFirst), }, - authOptsRetry.challenge, + authOpts.challenge, ), oldVaultKey: 'wrapped', newVaultKey: 'rotated', }); - const authOptsFinal = controller.generateAuthenticationOptions(); - const unwrapped = await controller.retrieveVaultKeyWithPasskey( - minimalAuthenticationResponse( - undefined, - { - clientExtensionResults: prfResults(prfFirst), - }, - authOptsFinal.challenge, - ), - ); - expect(unwrapped).toBe('rotated'); + expect(mockVerifyAuthenticationResponse).not.toHaveBeenCalled(); }); }); diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index 16522cced01..07f18ef0856 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -381,11 +381,8 @@ export class PasskeyController extends BaseController< oldVaultKey: string; newVaultKey: string; }): Promise { - // verify authentication response - const { authenticationResponse } = params; - await this.#verifyAuthenticationResponse(authenticationResponse); - // derive key + const { authenticationResponse } = params; const passkeyRecord = this.#getPasskeyRecord() as PasskeyRecord; const encKey = deriveKeyFromAuthenticationResponse( authenticationResponse, @@ -430,18 +427,18 @@ export class PasskeyController extends BaseController< * Unenrolls the passkey, removing the protected vault key material. */ removePasskey(): void { - this.clearState(); + this.update((state) => { + Object.assign(state, getDefaultPasskeyControllerState()); + }); } /** * Verifies an authentication response for the enrolled passkey. * * @param authenticationResponse - Authentication result JSON. - * @param shouldDeleteCeremony - Remove the in-flight ceremony after success (default: true). */ async #verifyAuthenticationResponse( authenticationResponse: PasskeyAuthenticationResponse, - shouldDeleteCeremony = true, ): Promise { let challenge: string | undefined; try { @@ -491,12 +488,10 @@ export class PasskeyController extends BaseController< }; this.#setPasskeyRecord(updatedRecord); - // delete ceremony if requested - if (shouldDeleteCeremony) { - this.#ceremonyManager.deleteAuthenticationCeremony(challenge); - } + // delete ceremony + this.#ceremonyManager.deleteAuthenticationCeremony(challenge); } catch (error) { - if (challenge && shouldDeleteCeremony) { + if (challenge) { this.#ceremonyManager.deleteAuthenticationCeremony(challenge); } throw error; From d6f8ef23825a03390a402c1764c623091bf60903 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Tue, 21 Apr 2026 10:09:32 +0700 Subject: [PATCH 18/44] refactor: restructure passkey record state --- .../src/PasskeyController.test.ts | 55 +++++++++------- .../src/PasskeyController.ts | 53 +++++++-------- packages/passkey-controller/src/index.ts | 3 + .../src/key-derivation.test.ts | 44 +++++++------ .../passkey-controller/src/key-derivation.ts | 32 ++++----- packages/passkey-controller/src/types.ts | 66 ++++++++++++++----- 6 files changed, 152 insertions(+), 101 deletions(-) diff --git a/packages/passkey-controller/src/PasskeyController.test.ts b/packages/passkey-controller/src/PasskeyController.test.ts index 79758e36010..308b692e758 100644 --- a/packages/passkey-controller/src/PasskeyController.test.ts +++ b/packages/passkey-controller/src/PasskeyController.test.ts @@ -180,13 +180,17 @@ describe('PasskeyController', () => { 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'], + credential: { + id: TEST_CREDENTIAL_ID, + publicKey: TEST_PUBLIC_KEY, + counter: 0, + transports: ['internal'], + }, + encryptedVaultKey: { + ciphertext: 'YQ==', + iv: 'YWFhYWFhYWFhYQ==', + }, + keyDerivation: { method: 'userHandle' }, }; const controller = createController({ state: { passkeyRecord: record }, @@ -295,10 +299,9 @@ describe('PasskeyController', () => { vaultKey, }); - expect(controller.state.passkeyRecord?.derivationMethod).toBe( - 'userHandle', - ); - expect(controller.state.passkeyRecord?.prfSalt).toBeUndefined(); + expect(controller.state.passkeyRecord?.keyDerivation).toStrictEqual({ + method: 'userHandle', + }); const authOptions = controller.generateAuthenticationOptions(); expect(authOptions.extensions).toStrictEqual({}); @@ -429,10 +432,10 @@ describe('PasskeyController', () => { 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'); + expect(record?.credential.id).toBe(TEST_CREDENTIAL_ID); + expect(record?.credential.publicKey).toBe(TEST_PUBLIC_KEY); + expect(record?.credential.transports).toStrictEqual(['internal']); + expect(record?.keyDerivation.method).toBe('userHandle'); }); it('uses prf derivation when extension results include PRF output', async () => { @@ -451,8 +454,11 @@ describe('PasskeyController', () => { vaultKey: 'vault-key-prf-path', }); - expect(controller.state.passkeyRecord?.derivationMethod).toBe('prf'); - expect(controller.state.passkeyRecord?.prfSalt).toBeDefined(); + expect(controller.state.passkeyRecord?.keyDerivation.method).toBe('prf'); + expect(controller.state.passkeyRecord?.keyDerivation).toMatchObject({ + method: 'prf', + prfSalt: expect.any(String), + }); }); it('uses userHandle derivation when PRF was requested but registration returns no PRF output bytes', async () => { @@ -482,10 +488,9 @@ describe('PasskeyController', () => { vaultKey, }); - expect(controller.state.passkeyRecord?.derivationMethod).toBe( - 'userHandle', - ); - expect(controller.state.passkeyRecord?.prfSalt).toBeUndefined(); + expect(controller.state.passkeyRecord?.keyDerivation).toStrictEqual({ + method: 'userHandle', + }); const authOptions = controller.generateAuthenticationOptions(); expect(authOptions.extensions).toStrictEqual({}); @@ -704,7 +709,7 @@ describe('PasskeyController', () => { vaultKey, }); - expect(controller.state.passkeyRecord?.derivationMethod).toBe( + expect(controller.state.passkeyRecord?.keyDerivation.method).toBe( 'userHandle', ); @@ -1074,7 +1079,7 @@ describe('PasskeyController', () => { vaultKey: 'k', }); - expect(controller.state.passkeyRecord?.counter).toBe(0); + expect(controller.state.passkeyRecord?.credential.counter).toBe(0); mockVerifyAuthenticationResponse.mockResolvedValue({ verified: true, @@ -1098,7 +1103,7 @@ describe('PasskeyController', () => { ), ); - expect(controller.state.passkeyRecord?.counter).toBe(5); + expect(controller.state.passkeyRecord?.credential.counter).toBe(5); mockVerifyAuthenticationResponse.mockResolvedValue({ verified: true, @@ -1129,7 +1134,7 @@ describe('PasskeyController', () => { }), }), ); - expect(controller.state.passkeyRecord?.counter).toBe(10); + expect(controller.state.passkeyRecord?.credential.counter).toBe(10); }); }); diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index 07f18ef0856..b6018a1d07c 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -228,8 +228,8 @@ export class PasskeyController extends BaseController< const challenge = bytesToBase64URL(randomBytes(32).slice()); const extensions: Record = {}; - if (record.derivationMethod === 'prf' && record.prfSalt) { - extensions.prf = { eval: { first: record.prfSalt } }; + if (record.keyDerivation.method === 'prf') { + extensions.prf = { eval: { first: record.keyDerivation.prfSalt } }; } const options: PasskeyAuthenticationOptions = { @@ -237,9 +237,9 @@ export class PasskeyController extends BaseController< rpId: this.#rpID, allowCredentials: [ { - id: record.credentialId, + id: record.credential.id, type: 'public-key', - transports: record.transports, + transports: record.credential.transports, }, ], userVerification: 'preferred', @@ -295,7 +295,7 @@ export class PasskeyController extends BaseController< } // derive key - const { encKey, derivationMethod } = deriveKeyFromRegistrationResponse( + const { encKey, keyDerivation } = deriveKeyFromRegistrationResponse( registrationResponse, registrationCeremony, ); @@ -305,15 +305,14 @@ export class PasskeyController extends BaseController< // persist passkey record this.#setPasskeyRecord({ - credentialId: registrationInfo.credentialId, - derivationMethod, - encryptedVaultKey: ciphertext, - iv, - prfSalt: - derivationMethod === 'prf' ? registrationCeremony.prfSalt : undefined, - publicKey: bytesToBase64URL(registrationInfo.publicKey), - counter: registrationInfo.counter, - transports: registrationInfo.transports, + credential: { + id: registrationInfo.credentialId, + publicKey: bytesToBase64URL(registrationInfo.publicKey), + counter: registrationInfo.counter, + transports: registrationInfo.transports, + }, + encryptedVaultKey: { ciphertext, iv }, + keyDerivation, }); // delete ceremony @@ -342,8 +341,8 @@ export class PasskeyController extends BaseController< // decrypt vault key const vaultKey = decryptWithKey( - passkeyRecord.encryptedVaultKey, - passkeyRecord.iv, + passkeyRecord.encryptedVaultKey.ciphertext, + passkeyRecord.encryptedVaultKey.iv, encKey, ); @@ -391,8 +390,8 @@ export class PasskeyController extends BaseController< // decrypt vault key const decryptedVaultKey = decryptWithKey( - passkeyRecord.encryptedVaultKey, - passkeyRecord.iv, + passkeyRecord.encryptedVaultKey.ciphertext, + passkeyRecord.encryptedVaultKey.iv, encKey, ); @@ -405,13 +404,12 @@ export class PasskeyController extends BaseController< } // encrypt new vault key - const { ciphertext, iv: newIv } = encryptWithKey(newVaultKey, encKey); + const { ciphertext, iv } = encryptWithKey(newVaultKey, encKey); // persist passkey record this.#setPasskeyRecord({ ...passkeyRecord, - encryptedVaultKey: ciphertext, - iv: newIv, + encryptedVaultKey: { ciphertext, iv }, }); } @@ -468,10 +466,10 @@ export class PasskeyController extends BaseController< expectedOrigin: this.#expectedOrigin, expectedRPID: this.#rpID, credential: { - id: record.credentialId, - publicKey: base64URLToBytes(record.publicKey), - counter: record.counter, - transports: record.transports, + id: record.credential.id, + publicKey: base64URLToBytes(record.credential.publicKey), + counter: record.credential.counter, + transports: record.credential.transports, }, // UV optional for device compatibility; vault key remains password-gated. requireUserVerification: false, @@ -484,7 +482,10 @@ export class PasskeyController extends BaseController< // persist passkey record with updated counter const updatedRecord: PasskeyRecord = { ...record, - counter: authenticationInfo.newCounter, + credential: { + ...record.credential, + counter: authenticationInfo.newCounter, + }, }; this.#setPasskeyRecord(updatedRecord); diff --git a/packages/passkey-controller/src/index.ts b/packages/passkey-controller/src/index.ts index 164e34e1f59..10f6aa6e8b6 100644 --- a/packages/passkey-controller/src/index.ts +++ b/packages/passkey-controller/src/index.ts @@ -18,7 +18,10 @@ export type { PasskeyControllerEvents, } from './PasskeyController'; export type { + EncryptedVaultKey, + PasskeyCredentialInfo, PasskeyDerivationMethod, + PasskeyKeyDerivation, PasskeyRecord, PrfEvalExtension, PrfClientExtensionResults, diff --git a/packages/passkey-controller/src/key-derivation.test.ts b/packages/passkey-controller/src/key-derivation.test.ts index d600d9e71fc..bf5653d88ad 100644 --- a/packages/passkey-controller/src/key-derivation.test.ts +++ b/packages/passkey-controller/src/key-derivation.test.ts @@ -64,13 +64,19 @@ function makeAuthenticationResponse( 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, + credential: { + id: CREDENTIAL_ID, + publicKey: 'pubkey', + counter: 0, + }, + encryptedVaultKey: { + ciphertext: 'ciphertext', + iv: 'iv', + }, + keyDerivation: + derivationMethod === 'prf' + ? { method: 'prf', prfSalt: PRF_SALT } + : { method: 'userHandle' }, }; } @@ -80,12 +86,12 @@ describe('deriveKeyFromRegistrationResponse', () => { prf: { results: { first: PRF_FIRST } }, }); - const { encKey, derivationMethod } = deriveKeyFromRegistrationResponse( + const { encKey, keyDerivation } = deriveKeyFromRegistrationResponse( response, makeRegistrationCeremony(), ); - expect(derivationMethod).toBe('prf'); + expect(keyDerivation).toStrictEqual({ method: 'prf', prfSalt: PRF_SALT }); expect(encKey).toBeInstanceOf(Uint8Array); expect(encKey).toHaveLength(32); }); @@ -95,12 +101,12 @@ describe('deriveKeyFromRegistrationResponse', () => { prf: { enabled: true, results: { first: PRF_FIRST } }, }); - const { derivationMethod } = deriveKeyFromRegistrationResponse( + const { keyDerivation } = deriveKeyFromRegistrationResponse( response, makeRegistrationCeremony(), ); - expect(derivationMethod).toBe('prf'); + expect(keyDerivation).toStrictEqual({ method: 'prf', prfSalt: PRF_SALT }); }); it('falls back to userHandle when prf.enabled is true but results.first is absent', () => { @@ -108,23 +114,23 @@ describe('deriveKeyFromRegistrationResponse', () => { prf: { enabled: true }, }); - const { derivationMethod } = deriveKeyFromRegistrationResponse( + const { keyDerivation } = deriveKeyFromRegistrationResponse( response, makeRegistrationCeremony(), ); - expect(derivationMethod).toBe('userHandle'); + expect(keyDerivation).toStrictEqual({ method: 'userHandle' }); }); it('falls back to userHandle when PRF is absent', () => { const response = makeRegistrationResponse({}); - const { encKey, derivationMethod } = deriveKeyFromRegistrationResponse( + const { encKey, keyDerivation } = deriveKeyFromRegistrationResponse( response, makeRegistrationCeremony(), ); - expect(derivationMethod).toBe('userHandle'); + expect(keyDerivation).toStrictEqual({ method: 'userHandle' }); expect(encKey).toBeInstanceOf(Uint8Array); expect(encKey).toHaveLength(32); }); @@ -134,12 +140,12 @@ describe('deriveKeyFromRegistrationResponse', () => { prf: { results: { first: '' } }, }); - const { derivationMethod } = deriveKeyFromRegistrationResponse( + const { keyDerivation } = deriveKeyFromRegistrationResponse( response, makeRegistrationCeremony(), ); - expect(derivationMethod).toBe('userHandle'); + expect(keyDerivation).toStrictEqual({ method: 'userHandle' }); }); it('produces different keys for different credential IDs', () => { @@ -181,7 +187,7 @@ describe('deriveKeyFromRegistrationResponse', () => { }); describe('deriveKeyFromAuthenticationResponse', () => { - it('uses PRF output when derivationMethod is prf', () => { + it('uses PRF output when keyDerivation.method is prf', () => { const response = makeAuthenticationResponse( { prf: { results: { first: PRF_FIRST } } }, USER_HANDLE, @@ -196,7 +202,7 @@ describe('deriveKeyFromAuthenticationResponse', () => { expect(encKey).toHaveLength(32); }); - it('uses userHandle when derivationMethod is userHandle', () => { + it('uses userHandle when keyDerivation.method is userHandle', () => { const response = makeAuthenticationResponse({}, USER_HANDLE); const encKey = deriveKeyFromAuthenticationResponse( diff --git a/packages/passkey-controller/src/key-derivation.ts b/packages/passkey-controller/src/key-derivation.ts index e4e45dde614..65d664420dc 100644 --- a/packages/passkey-controller/src/key-derivation.ts +++ b/packages/passkey-controller/src/key-derivation.ts @@ -1,4 +1,5 @@ import type { + PasskeyKeyDerivation, PasskeyRecord, PasskeyRegistrationCeremony, PrfClientExtensionResults, @@ -23,38 +24,39 @@ import type { * `navigator.credentials.create()`. * @param registrationCeremony - In-flight registration ceremony state from * when `generateRegistrationOptions()` was called. - * @returns The derived 32-byte AES wrapping key and which derivation - * method (PRF vs userHandle) was used. + * @returns The derived 32-byte AES wrapping key and the + * {@link PasskeyKeyDerivation} parameters needed to reproduce it. */ export function deriveKeyFromRegistrationResponse( registrationResponse: PasskeyRegistrationResponse, registrationCeremony: PasskeyRegistrationCeremony, ): { encKey: Uint8Array; - derivationMethod: 'prf' | 'userHandle'; + keyDerivation: PasskeyKeyDerivation; } { const credentialId = registrationResponse.id; - const prf = ( + const prfFirst = ( registrationResponse.clientExtensionResults as PrfClientExtensionResults - )?.prf; - const prfFirst = prf?.results?.first; + )?.prf?.results?.first; const hasPrfOutput = typeof prfFirst === 'string' && prfFirst.length > 0; - const derivationMethod: 'prf' | 'userHandle' = hasPrfOutput - ? 'prf' - : 'userHandle'; - const ikm: Uint8Array = - derivationMethod === 'prf' + + const keyDerivation: PasskeyKeyDerivation = hasPrfOutput + ? { method: 'prf', prfSalt: registrationCeremony.prfSalt } + : { method: 'userHandle' }; + const ikm = + keyDerivation.method === 'prf' ? base64URLToBytes(prfFirst as string) : base64URLToBytes(registrationCeremony.userHandle); const encKey = deriveEncryptionKey(ikm, base64URLToBytes(credentialId)); - return { encKey, derivationMethod }; + + return { encKey, keyDerivation }; } /** * Derives an AES-256 wrapping key from a WebAuthn authentication ceremony * response. * - * The derivation method is determined by the stored `PasskeyRecord`: + * The derivation method is determined by `record.keyDerivation`: * - `prf` -- uses the PRF evaluation result from `clientExtensionResults`. * - `userHandle` -- uses the `userHandle` returned in the assertion. * @@ -76,7 +78,7 @@ export function deriveKeyFromAuthenticationResponse( )?.prf?.results?.first; let ikm: Uint8Array; - if (record.derivationMethod === 'prf') { + if (record.keyDerivation.method === 'prf') { ikm = base64URLToBytes(prfFirst as string); } else if (userHandle) { ikm = base64URLToBytes(userHandle); @@ -84,5 +86,5 @@ export function deriveKeyFromAuthenticationResponse( throw new Error('Passkey assertion missing required key material'); } - return deriveEncryptionKey(ikm, base64URLToBytes(record.credentialId)); + return deriveEncryptionKey(ikm, base64URLToBytes(record.credential.id)); } diff --git a/packages/passkey-controller/src/types.ts b/packages/passkey-controller/src/types.ts index 3cd280baddc..013358d6606 100644 --- a/packages/passkey-controller/src/types.ts +++ b/packages/passkey-controller/src/types.ts @@ -1,5 +1,3 @@ -export type PasskeyDerivationMethod = 'prf' | 'userHandle'; - export type Base64String = string; export type Base64URLString = string; @@ -13,25 +11,61 @@ export type AuthenticatorTransportFuture = | '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) */ +/** + * WebAuthn credential metadata used to identify the passkey and verify + * subsequent assertions. + */ +export type PasskeyCredentialInfo = { + /** WebAuthn credential ID (base64url). */ + id: Base64URLString; + /** COSE-encoded credential public key (base64url) used to verify assertions. */ publicKey: Base64URLString; - /** Authenticator signature counter for replay detection */ + /** Authenticator signature counter for replay/clone detection. */ counter: number; - /** Authenticator transports for allowCredentials hints */ + /** Authenticator transports hint for `allowCredentials`. */ transports?: AuthenticatorTransportFuture[]; }; +/** + * Vault key wrapped under the passkey-derived AES-256-GCM key. + */ +export type EncryptedVaultKey = { + /** Base64-encoded AES-256-GCM ciphertext of the vault key. */ + ciphertext: Base64String; + /** Base64-encoded AES-GCM IV used during encryption. */ + iv: Base64String; +}; + +/** + * Parameters needed to reproduce the AES-256 wrapping key at unlock time. + * + * Encoded as a discriminated union so PRF-only fields (e.g. `prfSalt`) can + * only exist on the PRF branch, removing the "optional but actually + * required" footgun. + */ +export type PasskeyKeyDerivation = + | { + method: 'prf'; + /** + * PRF salt sent in `get()` extension options to reproduce the same PRF + * output that was generated at registration. + */ + prfSalt: Base64URLString; + } + | { method: 'userHandle' }; + +/** Discriminator value for {@link PasskeyKeyDerivation}. */ +export type PasskeyDerivationMethod = PasskeyKeyDerivation['method']; + +export type PasskeyRecord = { + /** WebAuthn credential metadata used for assertion verification & re-discovery. */ + credential: PasskeyCredentialInfo; + /** Vault key wrapped under the passkey-derived key. */ + encryptedVaultKey: EncryptedVaultKey; + /** How the wrapping key is reconstructed at unlock time. */ + keyDerivation: PasskeyKeyDerivation; +}; + /** * In-memory state for one **in-flight** WebAuthn **registration** ceremony * (from `create()` options until `protectVaultKeyWithPasskey` completes). This is From d46d8ebf4de0b0689795650dfe60c0fd001e0243 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Tue, 21 Apr 2026 10:22:54 +0700 Subject: [PATCH 19/44] fix: address review comments --- .../src/key-derivation.test.ts | 18 +++++++++++++ .../passkey-controller/src/key-derivation.ts | 6 ++++- .../src/utils/bytes.test.ts | 25 +++++++++++++++++++ .../passkey-controller/src/utils/bytes.ts | 21 ++++++++++++++++ .../verify-authentication-response.ts | 8 +----- .../webauthn/verify-registration-response.ts | 8 +----- .../src/webauthn/verify-signature.ts | 24 +++++------------- 7 files changed, 77 insertions(+), 33 deletions(-) create mode 100644 packages/passkey-controller/src/utils/bytes.test.ts create mode 100644 packages/passkey-controller/src/utils/bytes.ts diff --git a/packages/passkey-controller/src/key-derivation.test.ts b/packages/passkey-controller/src/key-derivation.test.ts index bf5653d88ad..d9c31b4a6a4 100644 --- a/packages/passkey-controller/src/key-derivation.test.ts +++ b/packages/passkey-controller/src/key-derivation.test.ts @@ -222,6 +222,24 @@ describe('deriveKeyFromAuthenticationResponse', () => { ).toThrow('Passkey assertion missing required key material'); }); + it('throws when PRF derivation is needed but PRF output is missing', () => { + const response = makeAuthenticationResponse({}); + + expect(() => + deriveKeyFromAuthenticationResponse(response, makeRecord('prf')), + ).toThrow('Passkey assertion missing required key material'); + }); + + it('throws when PRF derivation is needed but prf.results.first is empty', () => { + const response = makeAuthenticationResponse({ + prf: { results: { first: '' } }, + }); + + expect(() => + deriveKeyFromAuthenticationResponse(response, makeRecord('prf')), + ).toThrow('Passkey assertion missing required key material'); + }); + it('produces consistent keys across registration and authentication', () => { const regResponse = makeRegistrationResponse({}); const registrationCeremony = makeRegistrationCeremony(); diff --git a/packages/passkey-controller/src/key-derivation.ts b/packages/passkey-controller/src/key-derivation.ts index 65d664420dc..dd652884097 100644 --- a/packages/passkey-controller/src/key-derivation.ts +++ b/packages/passkey-controller/src/key-derivation.ts @@ -76,10 +76,14 @@ export function deriveKeyFromAuthenticationResponse( const prfFirst = ( authenticationResponse.clientExtensionResults as PrfClientExtensionResults )?.prf?.results?.first; + const hasPrfOutput = typeof prfFirst === 'string' && prfFirst.length > 0; let ikm: Uint8Array; if (record.keyDerivation.method === 'prf') { - ikm = base64URLToBytes(prfFirst as string); + if (!hasPrfOutput) { + throw new Error('Passkey assertion missing required key material'); + } + ikm = base64URLToBytes(prfFirst); } else if (userHandle) { ikm = base64URLToBytes(userHandle); } else { diff --git a/packages/passkey-controller/src/utils/bytes.test.ts b/packages/passkey-controller/src/utils/bytes.test.ts new file mode 100644 index 00000000000..f7bcca883fd --- /dev/null +++ b/packages/passkey-controller/src/utils/bytes.test.ts @@ -0,0 +1,25 @@ +import { concatUint8Arrays } from './bytes'; + +describe('concatUint8Arrays', () => { + it('concatenates two arrays', () => { + const a = new Uint8Array([1, 2]); + const b = new Uint8Array([3, 4, 5]); + expect(concatUint8Arrays(a, b)).toStrictEqual( + new Uint8Array([1, 2, 3, 4, 5]), + ); + }); + + it('concatenates three arrays', () => { + expect( + concatUint8Arrays( + new Uint8Array([0x04]), + new Uint8Array([1, 2]), + new Uint8Array([3]), + ), + ).toStrictEqual(new Uint8Array([0x04, 1, 2, 3])); + }); + + it('returns empty array when given no inputs', () => { + expect(concatUint8Arrays()).toStrictEqual(new Uint8Array()); + }); +}); diff --git a/packages/passkey-controller/src/utils/bytes.ts b/packages/passkey-controller/src/utils/bytes.ts new file mode 100644 index 00000000000..055e4f0199b --- /dev/null +++ b/packages/passkey-controller/src/utils/bytes.ts @@ -0,0 +1,21 @@ +/** + * Low-level `Uint8Array` helpers (concatenation, etc.). + * For base64url and hex conversions, see `encoding.ts`. + */ + +/** + * Concatenate one or more Uint8Arrays in order. + * + * @param arrays - Byte arrays to concatenate. + * @returns A new Uint8Array containing all inputs in order. + */ +export function concatUint8Arrays(...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; +} diff --git a/packages/passkey-controller/src/webauthn/verify-authentication-response.ts b/packages/passkey-controller/src/webauthn/verify-authentication-response.ts index 6e32f63d53e..636bbc925ca 100644 --- a/packages/passkey-controller/src/webauthn/verify-authentication-response.ts +++ b/packages/passkey-controller/src/webauthn/verify-authentication-response.ts @@ -8,6 +8,7 @@ import type { ParsedAuthenticatorData } from './types'; import type { PasskeyAuthenticationResponse } from './types'; import { verifySignature } from './verify-signature'; import type { AuthenticatorTransportFuture } from '../types'; +import { concatUint8Arrays } from '../utils/bytes'; import { base64URLToBytes } from '../utils/encoding'; export type VerifiedAuthenticationResponse = { @@ -174,10 +175,3 @@ export async function verifyAuthenticationResponse(opts: { }, }; } - -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/verify-registration-response.ts b/packages/passkey-controller/src/webauthn/verify-registration-response.ts index 8fa5026c6e2..7b9100bf80b 100644 --- a/packages/passkey-controller/src/webauthn/verify-registration-response.ts +++ b/packages/passkey-controller/src/webauthn/verify-registration-response.ts @@ -9,6 +9,7 @@ import { parseAuthenticatorData } from './parse-authenticator-data'; import type { PasskeyRegistrationResponse } from './types'; import { verifySignature } from './verify-signature'; import type { AuthenticatorTransportFuture } from '../types'; +import { concatUint8Arrays } from '../utils/bytes'; import { base64URLToBytes, bytesToBase64URL, @@ -280,10 +281,3 @@ async function verifyPackedAttestation( 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/verify-signature.ts b/packages/passkey-controller/src/webauthn/verify-signature.ts index 3166086ab0c..3bdba37a08c 100644 --- a/packages/passkey-controller/src/webauthn/verify-signature.ts +++ b/packages/passkey-controller/src/webauthn/verify-signature.ts @@ -3,27 +3,11 @@ import { p256, p384 } from '@noble/curves/nist'; import { sha256, sha384 } from '@noble/hashes/sha2'; import { COSEALG, COSECRV, COSEKEYS, COSEKTY } from './constants'; +import { concatUint8Arrays } from '../utils/bytes'; 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. * @@ -62,7 +46,11 @@ function verifyEC2( throw new Error('EC2 public key missing x or y coordinate'); } - const uncompressed = concatBytes(new Uint8Array([0x04]), xCoord, yCoord); + const uncompressed = concatUint8Arrays( + new Uint8Array([0x04]), + xCoord, + yCoord, + ); switch (crv) { case COSECRV.P256: From 11609cb37850ccc32a52ab7f13fda6840ad3a726 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Tue, 21 Apr 2026 10:40:43 +0700 Subject: [PATCH 20/44] chore: update deps --- packages/passkey-controller/package.json | 6 +++--- yarn.lock | 23 ++++++++--------------- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/packages/passkey-controller/package.json b/packages/passkey-controller/package.json index 7d131e53feb..03369ba11ee 100644 --- a/packages/passkey-controller/package.json +++ b/packages/passkey-controller/package.json @@ -48,12 +48,12 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@levischuck/tiny-cbor": "^0.2.2", + "@levischuck/tiny-cbor": "^0.3.3", "@metamask/base-controller": "^9.0.0", - "@metamask/messenger": "^0.3.0", + "@metamask/messenger": "^1.1.1", "@metamask/utils": "^11.9.0", "@noble/ciphers": "^1.3.0", - "@noble/curves": "^1.8.0", + "@noble/curves": "^1.9.2", "@noble/hashes": "^1.8.0" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index 8c63391df26..b81697d311d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2686,10 +2686,10 @@ __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 +"@levischuck/tiny-cbor@npm:^0.3.3": + version: 0.3.3 + resolution: "@levischuck/tiny-cbor@npm:0.3.3" + checksum: 10/737219a7bb28570043f77aa9d73af800a1dd41a1561c0f816d0ff5b7e95e08351377cd97afa19c4d8581e44951211a4b5f46084944ffdbca2779cddaa1c1fcbc languageName: node linkType: hard @@ -4644,13 +4644,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/messenger@npm:^0.3.0": - version: 0.3.0 - resolution: "@metamask/messenger@npm:0.3.0" - checksum: 10/84e9f4193646d749c7260a4958b13974b3c8738cc2e414116279ed31734e1edba687ff56ddbfdb75033bce30aaa9eeb7c391bccb87a66dbc99a902882271f673 - languageName: node - linkType: hard - "@metamask/messenger@npm:^1.1.0, @metamask/messenger@npm:^1.1.1, @metamask/messenger@workspace:packages/messenger": version: 0.0.0-use.local resolution: "@metamask/messenger@workspace:packages/messenger" @@ -5072,13 +5065,13 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/passkey-controller@workspace:packages/passkey-controller" dependencies: - "@levischuck/tiny-cbor": "npm:^0.2.2" + "@levischuck/tiny-cbor": "npm:^0.3.3" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" - "@metamask/messenger": "npm:^0.3.0" + "@metamask/messenger": "npm:^1.1.1" "@metamask/utils": "npm:^11.9.0" "@noble/ciphers": "npm:^1.3.0" - "@noble/curves": "npm:^1.8.0" + "@noble/curves": "npm:^1.9.2" "@noble/hashes": "npm:^1.8.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^27.5.2" @@ -6095,7 +6088,7 @@ __metadata: languageName: node linkType: hard -"@noble/curves@npm:^1.2.0, @noble/curves@npm:^1.8.0, @noble/curves@npm:^1.8.1, @noble/curves@npm:^1.9.2, @noble/curves@npm:~1.9.0": +"@noble/curves@npm:^1.2.0, @noble/curves@npm:^1.8.1, @noble/curves@npm:^1.9.2, @noble/curves@npm:~1.9.0": version: 1.9.7 resolution: "@noble/curves@npm:1.9.7" dependencies: From 373de11d070b8a7ee9411c63d395d93cd53ab1ff Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Tue, 21 Apr 2026 12:24:42 +0700 Subject: [PATCH 21/44] fix: address review comments --- packages/passkey-controller/CHANGELOG.md | 9 +- packages/passkey-controller/README.md | 21 ++- .../src/PasskeyController.test.ts | 72 ++++++-- .../src/PasskeyController.ts | 154 ++++++++++++------ .../src/ceremony-manager.test.ts | 10 +- .../src/ceremony-manager.ts | 9 +- packages/passkey-controller/src/index.ts | 8 +- .../verify-authentication-response.ts | 28 ++-- .../src/webauthn/webauthn.test.ts | 69 +++++++- 9 files changed, 282 insertions(+), 98 deletions(-) diff --git a/packages/passkey-controller/CHANGELOG.md b/packages/passkey-controller/CHANGELOG.md index ccf832b7631..ba3c02693bf 100644 --- a/packages/passkey-controller/CHANGELOG.md +++ b/packages/passkey-controller/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `PasskeyController` constructor accepts optional `userName` and `userDisplayName` so consumers can override the values shown in the OS passkey UI; both default to `rpName`. +- `PasskeyController.destroy()` clears in-flight ceremony state in addition to the standard `BaseController` teardown. +- `passkeyControllerSelectors.selectIsPasskeyEnrolled` selector for use in Redux selectors and other code paths that only have access to a state object. - `PasskeyController.verifyPasskeyAuthentication` — returns whether the passkey authentication response is valid without returning the vault key (delegates to the same path as `retrieveVaultKeyWithPasskey`). - Initial `@metamask/passkey-controller` package: passkey-based vault key protection using WebAuthn, orchestrating enrollment, authentication, and vault key wrap/unwrap with AES-256-GCM and HKDF-derived keys. - `PasskeyController` API: @@ -16,13 +19,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `generateAuthenticationOptions` / `retrieveVaultKeyWithPasskey` — unlock and vault key recovery - `renewVaultKeyProtection` — re-wrap vault key for password-change flows without re-enrolling the passkey - `removePasskey` — unenroll and clear key material - - `isPasskeyEnrolled` — enrollment check (exposed via messenger) + - `isPasskeyEnrolled` — enrollment check (also available as `passkeyControllerSelectors.selectIsPasskeyEnrolled`) - `clearState` — reset persisted state and clear in-flight WebAuthn ceremony state (for app lifecycle, e.g. wallet reset) - Adaptive key derivation during enrollment: **PRF** (WebAuthn PRF extension output as HKDF input) or **userHandle** fallback; PRF path used only when `prf.results.first` is a non-empty string. - Self-contained WebAuthn verification (no Node server): `clientDataJSON` and `authenticatorData` checks, signature verification (`@noble/curves` + Web Crypto RSA), attestation formats `none` and `packed` self-attestation. - In-flight **ceremony** coordination (distinct from user login sessions): challenge-keyed registration/authentication state in `src/ceremony-manager.ts` (`CeremonyManager` and timing/capacity constants), TTL aligned with WebAuthn `timeout`, and a cap on concurrent ceremonies per flow so multiple tabs/contexts do not overwrite a single in-memory entry. -- Exported timing and limits from package entry: `WEBAUTHN_TIMEOUT_MS`, `SESSION_TTL_SLACK_MS`, `SESSION_MAX_AGE_MS`, `MAX_CONCURRENT_PASSKEY_CEREMONIES` (`PasskeyController` does not re-export these; import from `@metamask/passkey-controller` or `./ceremony-manager` in the monorepo). +- Exported timing and limits from package entry: `WEBAUTHN_TIMEOUT_MS`, `CEREMONY_TTL_SLACK_MS`, `CEREMONY_MAX_AGE_MS`, `MAX_CONCURRENT_PASSKEY_CEREMONIES` (`PasskeyController` does not re-export these; import from `@metamask/passkey-controller` or `./ceremony-manager` in the monorepo). - Exported controller types (`PasskeyControllerState`, messenger types, actions, events) and WebAuthn option/response types; internal ceremony payload types `PasskeyRegistrationCeremony` / `PasskeyAuthenticationCeremony` in `types.ts`. - AES-256-GCM helpers and COSE enums (`COSEALG`, `COSEKEYS`, `COSEKTY`, `COSECRV`). +- `renewVaultKeyProtection` throws `Error('Passkey is not enrolled')` when called without an enrolled passkey instead of an opaque `TypeError`. +- `verifyAuthenticationResponse` returns a discriminated union (`{ verified: false }` or `{ verified: true; authenticationInfo }`) so consumers cannot accidentally read `authenticationInfo` on a failed verification; signature verification failure short-circuits before the counter-monotonicity check. [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/passkey-controller/README.md b/packages/passkey-controller/README.md index 99240fd10ec..36d0b93f73e 100644 --- a/packages/passkey-controller/README.md +++ b/packages/passkey-controller/README.md @@ -43,6 +43,9 @@ const controller = new PasskeyController({ rpID: 'example.com', rpName: 'My Wallet', expectedOrigin: 'chrome-extension://abcdef1234567890', + // Optional — both default to `rpName` when omitted. + userName: 'My Wallet', + userDisplayName: 'My Wallet', }); ``` @@ -98,6 +101,17 @@ controller.removePasskey(); // user-facing unenroll controller.clearState(); // same persisted reset + clears in-flight ceremony state; use for app lifecycle (e.g. wallet reset) ``` +### Selectors + +For Redux selectors and other code paths without access to the controller +instance, use the exported selector(s): + +```typescript +import { passkeyControllerSelectors } from '@metamask/passkey-controller'; + +passkeyControllerSelectors.selectIsPasskeyEnrolled(state); // boolean +``` + ## API ### State @@ -111,13 +125,16 @@ controller.clearState(); // same persisted reset + clears in-flight ceremony sta | Action | Handler | |---|---| | `PasskeyController:getState` | Returns the current controller state | -| `PasskeyController:isPasskeyEnrolled` | Returns whether a passkey is currently enrolled | + +For derived enrollment status outside of components that hold a controller +reference, use `passkeyControllerSelectors.selectIsPasskeyEnrolled` (see +[Selectors](#selectors)). ### Messenger events | Event | Payload | |---|---| -| `PasskeyController:stateChange` | Emitted when state changes (standard `BaseController` event) | +| `PasskeyController:stateChanged` | Emitted when state changes (standard `BaseController` event) | ## Contributing diff --git a/packages/passkey-controller/src/PasskeyController.test.ts b/packages/passkey-controller/src/PasskeyController.test.ts index 308b692e758..a5d088b519d 100644 --- a/packages/passkey-controller/src/PasskeyController.test.ts +++ b/packages/passkey-controller/src/PasskeyController.test.ts @@ -1,8 +1,9 @@ import { Messenger } from '@metamask/messenger'; -import { SESSION_MAX_AGE_MS, WEBAUTHN_TIMEOUT_MS } from './ceremony-manager'; +import { CEREMONY_MAX_AGE_MS, WEBAUTHN_TIMEOUT_MS } from './ceremony-manager'; import { getDefaultPasskeyControllerState, + passkeyControllerSelectors, PasskeyController, } from './PasskeyController'; import type { PasskeyControllerMessenger } from './PasskeyController'; @@ -204,18 +205,6 @@ describe('PasskeyController', () => { 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', () => { @@ -550,7 +539,6 @@ describe('PasskeyController', () => { mockVerifyAuthenticationResponse.mockResolvedValue({ verified: false, - authenticationInfo: {}, }); const authOpts = controller.generateAuthenticationOptions(); @@ -645,7 +633,6 @@ describe('PasskeyController', () => { mockVerifyAuthenticationResponse.mockResolvedValue({ verified: false, - authenticationInfo: {}, }); const authOpts = controller.generateAuthenticationOptions(); @@ -782,7 +769,7 @@ describe('PasskeyController', () => { oldVaultKey: 'old', newVaultKey: 'new', }), - ).rejects.toThrow(TypeError); + ).rejects.toThrow('Passkey is not enrolled'); }); it('updates the passkey wrap when before/after vault keys match', async () => { @@ -988,6 +975,56 @@ describe('PasskeyController', () => { }); }); + describe('destroy', () => { + it('clears in-flight ceremony state', async () => { + setupRegistrationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + + controller.destroy(); + + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'k', + }), + ).rejects.toThrow('No active passkey registration ceremony'); + }); + }); + + describe('passkeyControllerSelectors', () => { + describe('selectIsPasskeyEnrolled', () => { + it('returns false when no record is stored', () => { + expect( + passkeyControllerSelectors.selectIsPasskeyEnrolled({ + passkeyRecord: null, + }), + ).toBe(false); + }); + + it('returns true when a record is stored', () => { + const record: PasskeyRecord = { + credential: { + id: TEST_CREDENTIAL_ID, + publicKey: TEST_PUBLIC_KEY, + counter: 0, + transports: ['internal'], + }, + encryptedVaultKey: { ciphertext: 'YQ==', iv: 'YWFhYWFhYWFhYQ==' }, + keyDerivation: { method: 'userHandle' }, + }; + expect( + passkeyControllerSelectors.selectIsPasskeyEnrolled({ + passkeyRecord: record, + }), + ).toBe(true); + }); + }); + }); + describe('verifyRegistrationResponse parameters', () => { it('passes expectedOrigin and expectedRPID to verification', async () => { setupRegistrationMocks(); @@ -1228,7 +1265,7 @@ describe('PasskeyController', () => { const controller = createController(); const regOpts = controller.generateRegistrationOptions(); - jest.setSystemTime(1_000_000 + SESSION_MAX_AGE_MS + 1); + jest.setSystemTime(1_000_000 + CEREMONY_MAX_AGE_MS + 1); await expect( controller.protectVaultKeyWithPasskey({ @@ -1259,7 +1296,6 @@ describe('PasskeyController', () => { mockVerifyAuthenticationResponse.mockResolvedValue({ verified: false, - authenticationInfo: {}, }); const authOpts = controller.generateAuthenticationOptions(); diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index b6018a1d07c..dd0c268d4a5 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -1,6 +1,6 @@ import type { ControllerGetStateAction, - ControllerStateChangeEvent, + ControllerStateChangedEvent, StateMetadata, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; @@ -30,8 +30,6 @@ import type { const controllerName = 'PasskeyController'; -const MESSENGER_EXPOSED_METHODS = ['isPasskeyEnrolled'] as const; - export type PasskeyControllerState = { passkeyRecord: PasskeyRecord | null; }; @@ -41,21 +39,24 @@ export type PasskeyControllerGetStateAction = ControllerGetStateAction< PasskeyControllerState >; -export type PasskeyControllerIsPasskeyEnrolledAction = { - type: `${typeof controllerName}:isPasskeyEnrolled`; - handler: PasskeyController['isPasskeyEnrolled']; -}; - -export type PasskeyControllerActions = - | PasskeyControllerGetStateAction - | PasskeyControllerIsPasskeyEnrolledAction; +/** + * Actions exposed by {@link PasskeyController} on its messenger. + * + * Only `:getState` is exposed. Derived enrollment status is available via + * {@link passkeyControllerSelectors.selectIsPasskeyEnrolled}, and lifecycle + * methods ({@link PasskeyController.generateRegistrationOptions}, + * {@link PasskeyController.protectVaultKeyWithPasskey}, etc.) accept or + * return non-`Json` runtime values (WebAuthn `PublicKeyCredential` objects + * and the vault key string), so they require a direct controller reference. + */ +export type PasskeyControllerActions = PasskeyControllerGetStateAction; -export type PasskeyControllerStateChangeEvent = ControllerStateChangeEvent< +export type PasskeyControllerStateChangedEvent = ControllerStateChangedEvent< typeof controllerName, PasskeyControllerState >; -export type PasskeyControllerEvents = PasskeyControllerStateChangeEvent; +export type PasskeyControllerEvents = PasskeyControllerStateChangedEvent; export type PasskeyControllerMessenger = Messenger< typeof controllerName, @@ -81,6 +82,18 @@ const passkeyControllerMetadata = { }, } satisfies StateMetadata; +/** + * Selectors for {@link PasskeyControllerState}. + * + * Use these instead of dedicated getter methods on the controller, so that + * derived values can be consumed from Redux selectors and other places that + * only have access to a state object. + */ +export const passkeyControllerSelectors = { + selectIsPasskeyEnrolled: (state: PasskeyControllerState): boolean => + state.passkeyRecord !== null, +}; + /** * Passkey-based protection for the vault encryption key (WebAuthn). * @@ -100,18 +113,45 @@ export class PasskeyController extends BaseController< readonly #expectedOrigin: string | string[]; + readonly #userName: string; + + readonly #userDisplayName: string; + + /** + * Constructs a new {@link PasskeyController}. + * + * @param args - The constructor arguments. + * @param args.messenger - The messenger suited for this controller. + * @param args.state - Initial state. Missing properties are filled in with + * defaults from {@link getDefaultPasskeyControllerState}. + * @param args.rpID - WebAuthn Relying Party ID (typically the eTLD+1 of the + * client origin, or `localhost` in dev). + * @param args.rpName - Human-readable Relying Party name shown by the OS + * passkey UI. + * @param args.expectedOrigin - One or more acceptable origins for the + * `clientDataJSON.origin` check (e.g. `chrome-extension://...`). + * @param args.userName - Optional `user.name` shown by the OS passkey UI. + * Defaults to `rpName` so client builds (Stable, Flask, etc.) can + * differentiate without changes here. + * @param args.userDisplayName - Optional `user.displayName` shown by the OS + * passkey UI. Defaults to `rpName`. + */ constructor({ messenger, state, rpID, rpName, expectedOrigin, + userName, + userDisplayName, }: { messenger: PasskeyControllerMessenger; state?: Partial; rpID: string; rpName: string; expectedOrigin: string | string[]; + userName?: string; + userDisplayName?: string; }) { super({ messenger, @@ -123,11 +163,16 @@ export class PasskeyController extends BaseController< this.#rpID = rpID; this.#rpName = rpName; this.#expectedOrigin = expectedOrigin; + this.#userName = userName ?? rpName; + this.#userDisplayName = userDisplayName ?? rpName; + } - this.messenger.registerMethodActionHandlers( - this, - MESSENGER_EXPOSED_METHODS, - ); + /** + * Releases all in-flight ceremony state and tears down the messenger. + */ + destroy(): void { + this.#ceremonyManager.clear(); + super.destroy(); } #setPasskeyRecord(record: PasskeyRecord): void { @@ -140,6 +185,14 @@ export class PasskeyController extends BaseController< return this.state.passkeyRecord; } + #requireEnrolled(): PasskeyRecord { + const record = this.#getPasskeyRecord(); + if (!record) { + throw new Error('Passkey is not enrolled'); + } + return record; + } + #getChallengeFromClientData(clientDataJSON: string): string { return decodeClientDataJSON(clientDataJSON).challenge; } @@ -181,8 +234,8 @@ export class PasskeyController extends BaseController< rp: { name: this.#rpName, id: this.#rpID }, user: { id: userHandle, - name: 'MetaMask Wallet', - displayName: 'MetaMask Wallet', + name: this.#userName, + displayName: this.#userDisplayName, }, challenge, pubKeyCredParams: [ @@ -220,10 +273,7 @@ export class PasskeyController extends BaseController< * @returns Options for `navigator.credentials.get()`. */ generateAuthenticationOptions(): PasskeyAuthenticationOptions { - const record = this.#getPasskeyRecord(); - if (!record) { - throw new Error('Passkey is not enrolled'); - } + const record = this.#requireEnrolled(); const challenge = bytesToBase64URL(randomBytes(32).slice()); @@ -332,8 +382,8 @@ export class PasskeyController extends BaseController< // verify authentication response await this.#verifyAuthenticationResponse(authenticationResponse); - // derive key - const passkeyRecord = this.#getPasskeyRecord() as PasskeyRecord; + // derive key (#verifyAuthenticationResponse guarantees enrolled) + const passkeyRecord = this.#requireEnrolled(); const encKey = deriveKeyFromAuthenticationResponse( authenticationResponse, passkeyRecord, @@ -370,8 +420,15 @@ export class PasskeyController extends BaseController< /** * Updates the vault encryption key for the same passkey (e.g. after a password change). * + * Caller MUST first verify the assertion via {@link verifyPasskeyAuthentication} + * or {@link retrieveVaultKeyWithPasskey}. This method does not re-verify + * because the ceremony is single-use (deleted on verify) and the signature + * counter is advanced (replay would be rejected). Authentication here is + * enforced by the prior verification plus the `oldVaultKey` match below. + * * @param params - Renewal parameters. - * @param params.authenticationResponse - Credential from `navigator.credentials.get()`. + * @param params.authenticationResponse - Credential from `navigator.credentials.get()`, + * already verified by the caller. * @param params.oldVaultKey - Expected current vault key. * @param params.newVaultKey - New vault key to protect. */ @@ -380,9 +437,10 @@ export class PasskeyController extends BaseController< oldVaultKey: string; newVaultKey: string; }): Promise { - // derive key const { authenticationResponse } = params; - const passkeyRecord = this.#getPasskeyRecord() as PasskeyRecord; + const passkeyRecord = this.#requireEnrolled(); + + // derive key const encKey = deriveKeyFromAuthenticationResponse( authenticationResponse, passkeyRecord, @@ -446,10 +504,7 @@ export class PasskeyController extends BaseController< ); // get passkey record - const record = this.#getPasskeyRecord(); - if (!record) { - throw new Error('Passkey is not enrolled'); - } + const record = this.#requireEnrolled(); // get authentication ceremony const authenticationCeremony = @@ -459,23 +514,22 @@ export class PasskeyController extends BaseController< } // verify authentication response - const { verified, authenticationInfo } = - await verifyAuthenticationResponse({ - response: authenticationResponse, - expectedChallenge: authenticationCeremony.challenge, - expectedOrigin: this.#expectedOrigin, - expectedRPID: this.#rpID, - credential: { - id: record.credential.id, - publicKey: base64URLToBytes(record.credential.publicKey), - counter: record.credential.counter, - transports: record.credential.transports, - }, - // UV optional for device compatibility; vault key remains password-gated. - requireUserVerification: false, - }); - - if (!verified) { + const result = await verifyAuthenticationResponse({ + response: authenticationResponse, + expectedChallenge: authenticationCeremony.challenge, + expectedOrigin: this.#expectedOrigin, + expectedRPID: this.#rpID, + credential: { + id: record.credential.id, + publicKey: base64URLToBytes(record.credential.publicKey), + counter: record.credential.counter, + transports: record.credential.transports, + }, + // UV optional for device compatibility; vault key remains password-gated. + requireUserVerification: false, + }); + + if (!result.verified) { throw new Error('Passkey authentication verification failed'); } @@ -484,7 +538,7 @@ export class PasskeyController extends BaseController< ...record, credential: { ...record.credential, - counter: authenticationInfo.newCounter, + counter: result.authenticationInfo.newCounter, }, }; this.#setPasskeyRecord(updatedRecord); diff --git a/packages/passkey-controller/src/ceremony-manager.test.ts b/packages/passkey-controller/src/ceremony-manager.test.ts index 88bf3094ed6..7dedb828fec 100644 --- a/packages/passkey-controller/src/ceremony-manager.test.ts +++ b/packages/passkey-controller/src/ceremony-manager.test.ts @@ -1,7 +1,7 @@ import { + CEREMONY_MAX_AGE_MS, CeremonyManager, MAX_CONCURRENT_PASSKEY_CEREMONIES, - SESSION_MAX_AGE_MS, } from './ceremony-manager'; describe('CeremonyManager', () => { @@ -31,7 +31,7 @@ describe('CeremonyManager', () => { }); }); - it('getRegistrationCeremony prunes entries older than SESSION_MAX_AGE_MS before lookup', () => { + it('getRegistrationCeremony prunes entries older than CEREMONY_MAX_AGE_MS before lookup', () => { const manager = new CeremonyManager(); const tOld = 100_000; const tNew = 150_000; @@ -49,7 +49,7 @@ describe('CeremonyManager', () => { createdAt: tNew, }); jest.setSystemTime(pruneAt); - expect(pruneAt - tOld).toBeGreaterThan(SESSION_MAX_AGE_MS); + expect(pruneAt - tOld).toBeGreaterThan(CEREMONY_MAX_AGE_MS); expect(manager.getRegistrationCeremony('new')).toMatchObject({ challenge: 'new', createdAt: tNew, @@ -127,7 +127,7 @@ describe('CeremonyManager', () => { expect(manager.getRegistrationCeremony('reg-chal')).toBeDefined(); expect(manager.getAuthenticationCeremony('auth-chal')).toBeDefined(); - jest.setSystemTime(now + SESSION_MAX_AGE_MS + 1); + jest.setSystemTime(now + CEREMONY_MAX_AGE_MS + 1); expect(manager.getRegistrationCeremony('reg-chal')).toBeUndefined(); jest.setSystemTime(now); @@ -137,7 +137,7 @@ describe('CeremonyManager', () => { challenge: 'reg2', createdAt: now, }); - jest.setSystemTime(now + SESSION_MAX_AGE_MS + 1); + jest.setSystemTime(now + CEREMONY_MAX_AGE_MS + 1); expect(manager.getAuthenticationCeremony('auth-chal')).toBeUndefined(); jest.setSystemTime(now); diff --git a/packages/passkey-controller/src/ceremony-manager.ts b/packages/passkey-controller/src/ceremony-manager.ts index 31df15dc574..8b735ba5cb3 100644 --- a/packages/passkey-controller/src/ceremony-manager.ts +++ b/packages/passkey-controller/src/ceremony-manager.ts @@ -10,13 +10,14 @@ export const WEBAUTHN_TIMEOUT_MS = 60_000; * Extra allowance beyond {@link WEBAUTHN_TIMEOUT_MS} before in-memory * ceremony state is discarded (covers slow UX and clock skew). */ -export const SESSION_TTL_SLACK_MS = 15_000; +export const CEREMONY_TTL_SLACK_MS = 15_000; /** * Maximum age for in-flight registration or authentication ceremony state - * (between options and verified response), not a user login session. + * (between options and verified response). This bounds the lifetime of a + * single WebAuthn ceremony only; it is not a user login session timeout. */ -export const SESSION_MAX_AGE_MS = WEBAUTHN_TIMEOUT_MS + SESSION_TTL_SLACK_MS; +export const CEREMONY_MAX_AGE_MS = WEBAUTHN_TIMEOUT_MS + CEREMONY_TTL_SLACK_MS; /** * Upper bound on concurrent in-memory ceremonies per flow type (registration @@ -56,7 +57,7 @@ export class CeremonyManager { const now = Date.now(); const map = this.#getMap(ceremonyType); for (const [key, ceremony] of map) { - if (now - ceremony.createdAt > SESSION_MAX_AGE_MS) { + if (now - ceremony.createdAt > CEREMONY_MAX_AGE_MS) { map.delete(key); } } diff --git a/packages/passkey-controller/src/index.ts b/packages/passkey-controller/src/index.ts index 10f6aa6e8b6..a709d5f2692 100644 --- a/packages/passkey-controller/src/index.ts +++ b/packages/passkey-controller/src/index.ts @@ -1,20 +1,20 @@ export { PasskeyController, getDefaultPasskeyControllerState, + passkeyControllerSelectors, } from './PasskeyController'; export { WEBAUTHN_TIMEOUT_MS, - SESSION_TTL_SLACK_MS, - SESSION_MAX_AGE_MS, + CEREMONY_TTL_SLACK_MS, + CEREMONY_MAX_AGE_MS, MAX_CONCURRENT_PASSKEY_CEREMONIES, } from './ceremony-manager'; export type { PasskeyControllerState, PasskeyControllerMessenger, PasskeyControllerGetStateAction, - PasskeyControllerIsPasskeyEnrolledAction, PasskeyControllerActions, - PasskeyControllerStateChangeEvent, + PasskeyControllerStateChangedEvent, PasskeyControllerEvents, } from './PasskeyController'; export type { diff --git a/packages/passkey-controller/src/webauthn/verify-authentication-response.ts b/packages/passkey-controller/src/webauthn/verify-authentication-response.ts index 636bbc925ca..440f09e34fb 100644 --- a/packages/passkey-controller/src/webauthn/verify-authentication-response.ts +++ b/packages/passkey-controller/src/webauthn/verify-authentication-response.ts @@ -11,16 +11,18 @@ import type { AuthenticatorTransportFuture } from '../types'; import { concatUint8Arrays } from '../utils/bytes'; import { base64URLToBytes } from '../utils/encoding'; -export type VerifiedAuthenticationResponse = { - verified: boolean; - authenticationInfo: { - credentialId: string; - newCounter: number; - userVerified: boolean; - origin: string; - rpID: string; - }; -}; +export type VerifiedAuthenticationResponse = + | { verified: false; authenticationInfo?: never } + | { + verified: true; + authenticationInfo: { + credentialId: string; + newCounter: number; + userVerified: boolean; + origin: string; + rpID: string; + }; + }; /** * Verifies a WebAuthn authentication (assertion) response per @@ -155,6 +157,10 @@ export async function verifyAuthenticationResponse(opts: { data: signatureBase, }); + if (!verified) { + return { verified: false }; + } + if ( (counter > 0 || credential.counter > 0) && counter <= credential.counter @@ -165,7 +171,7 @@ export async function verifyAuthenticationResponse(opts: { } return { - verified, + verified: true, authenticationInfo: { credentialId: credential.id, newCounter: counter, diff --git a/packages/passkey-controller/src/webauthn/webauthn.test.ts b/packages/passkey-controller/src/webauthn/webauthn.test.ts index 8e128acb771..050463e1e49 100644 --- a/packages/passkey-controller/src/webauthn/webauthn.test.ts +++ b/packages/passkey-controller/src/webauthn/webauthn.test.ts @@ -495,11 +495,74 @@ describe('verifyAuthenticationResponse', () => { }, }); - expect(result.verified).toBe(true); + if (!result.verified) { + throw new Error('expected verified result'); + } expect(result.authenticationInfo.newCounter).toBe(1); expect(result.authenticationInfo.rpID).toBe(TEST_RP_ID); }); + it('returns { verified: false } when the signature does not verify', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + // Sign with a different key so the public key does not match the signature. + const { privateKey: otherPrivateKey } = 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, otherPrivateKey); + + const credentialIdB64 = bytesToBase64URL(new Uint8Array(16).fill(0x11)); + + 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(false); + expect(result.authenticationInfo).toBeUndefined(); + }); + it('rejects mismatched challenge', async () => { const { cosePublicKeyCBOR } = generateES256KeyPair(); const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); @@ -998,7 +1061,9 @@ describe('verifyAuthenticationResponse', () => { }, }); - expect(result.verified).toBe(true); + if (!result.verified) { + throw new Error('expected verified result'); + } expect(result.authenticationInfo.newCounter).toBe(0); }); }); From 5246c456815754a2ee5dcf45160d0d310c6527df Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Tue, 21 Apr 2026 12:35:00 +0700 Subject: [PATCH 22/44] fix: refactor to follow controller guidelines --- README.md | 4 ++ packages/passkey-controller/CHANGELOG.md | 6 ++- packages/passkey-controller/README.md | 12 ++++- .../src/PasskeyController.test.ts | 36 ++++++++++++++- .../src/PasskeyController.ts | 44 ++++++++++++++----- packages/passkey-controller/src/errors.ts | 8 ++++ packages/passkey-controller/src/index.ts | 1 + .../passkey-controller/src/key-derivation.ts | 9 +++- 8 files changed, 103 insertions(+), 17 deletions(-) create mode 100644 packages/passkey-controller/src/errors.ts diff --git a/README.md b/README.md index edf5cf71813..3053751f56f 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/network-controller`](packages/network-controller) - [`@metamask/network-enablement-controller`](packages/network-enablement-controller) - [`@metamask/notification-services-controller`](packages/notification-services-controller) +- [`@metamask/passkey-controller`](packages/passkey-controller) - [`@metamask/permission-controller`](packages/permission-controller) - [`@metamask/permission-log-controller`](packages/permission-log-controller) - [`@metamask/perps-controller`](packages/perps-controller) @@ -168,6 +169,7 @@ linkStyle default opacity:0.5 network_controller(["@metamask/network-controller"]); network_enablement_controller(["@metamask/network-enablement-controller"]); notification_services_controller(["@metamask/notification-services-controller"]); + passkey_controller(["@metamask/passkey-controller"]); permission_controller(["@metamask/permission-controller"]); permission_log_controller(["@metamask/permission-log-controller"]); perps_controller(["@metamask/perps-controller"]); @@ -422,6 +424,8 @@ linkStyle default opacity:0.5 notification_services_controller --> keyring_controller; notification_services_controller --> messenger; notification_services_controller --> profile_sync_controller; + passkey_controller --> base_controller; + passkey_controller --> messenger; permission_controller --> approval_controller; permission_controller --> base_controller; permission_controller --> controller_utils; diff --git a/packages/passkey-controller/CHANGELOG.md b/packages/passkey-controller/CHANGELOG.md index ba3c02693bf..2d47c3b5711 100644 --- a/packages/passkey-controller/CHANGELOG.md +++ b/packages/passkey-controller/CHANGELOG.md @@ -27,7 +27,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Exported timing and limits from package entry: `WEBAUTHN_TIMEOUT_MS`, `CEREMONY_TTL_SLACK_MS`, `CEREMONY_MAX_AGE_MS`, `MAX_CONCURRENT_PASSKEY_CEREMONIES` (`PasskeyController` does not re-export these; import from `@metamask/passkey-controller` or `./ceremony-manager` in the monorepo). - Exported controller types (`PasskeyControllerState`, messenger types, actions, events) and WebAuthn option/response types; internal ceremony payload types `PasskeyRegistrationCeremony` / `PasskeyAuthenticationCeremony` in `types.ts`. - AES-256-GCM helpers and COSE enums (`COSEALG`, `COSEKEYS`, `COSEKTY`, `COSECRV`). -- `renewVaultKeyProtection` throws `Error('Passkey is not enrolled')` when called without an enrolled passkey instead of an opaque `TypeError`. +- `PasskeyAuthenticationRejectedError` for expected authentication or enrollment failures (not enrolled, missing ceremony, failed verification, missing key material, vault decrypt failure). `PasskeyController.verifyPasskeyAuthentication` returns `false` only for this error and rethrows anything else. +- `renewVaultKeyProtection` throws `PasskeyAuthenticationRejectedError('Passkey is not enrolled')` when called without an enrolled passkey instead of an opaque `TypeError`. - `verifyAuthenticationResponse` returns a discriminated union (`{ verified: false }` or `{ verified: true; authenticationInfo }`) so consumers cannot accidentally read `authenticationInfo` on a failed verification; signature verification failure short-circuits before the counter-monotonicity check. +- `PasskeyController` constructor defaults `state` to `{}` per controller guidelines. +- `PasskeyController.isPasskeyEnrolled` delegates to `passkeyControllerSelectors.selectIsPasskeyEnrolled`. +- `PasskeyController.removePasskey` clears in-flight ceremony state (same as `clearState` for the ceremony manager). [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/passkey-controller/README.md b/packages/passkey-controller/README.md index 36d0b93f73e..989b997a3c6 100644 --- a/packages/passkey-controller/README.md +++ b/packages/passkey-controller/README.md @@ -96,7 +96,7 @@ await controller.renewVaultKeyProtection({ ```typescript controller.isPasskeyEnrolled(); // boolean -controller.removePasskey(); // user-facing unenroll +controller.removePasskey(); // user-facing unenroll; clears persisted passkey and in-flight ceremonies controller.clearState(); // same persisted reset + clears in-flight ceremony state; use for app lifecycle (e.g. wallet reset) ``` @@ -112,6 +112,16 @@ import { passkeyControllerSelectors } from '@metamask/passkey-controller'; passkeyControllerSelectors.selectIsPasskeyEnrolled(state); // boolean ``` +### Errors + +`PasskeyAuthenticationRejectedError` is thrown when authentication or vault-key +recovery fails in an expected operational way (for example: not enrolled, no +active ceremony, failed WebAuthn verification, missing PRF / `userHandle` +material, or AES-GCM decrypt failure). `retrieveVaultKeyWithPasskey` and +related paths surface this type so callers can distinguish it from bugs or +malformed input. `verifyPasskeyAuthentication` returns `false` only for this +error and rethrows any other thrown value. + ## API ### State diff --git a/packages/passkey-controller/src/PasskeyController.test.ts b/packages/passkey-controller/src/PasskeyController.test.ts index a5d088b519d..e51ac32e46c 100644 --- a/packages/passkey-controller/src/PasskeyController.test.ts +++ b/packages/passkey-controller/src/PasskeyController.test.ts @@ -644,6 +644,23 @@ describe('PasskeyController', () => { ).toBe(false); }); + it('rethrows non-operational errors (e.g. malformed clientDataJSON)', async () => { + const controller = createController(); + const badClientData = bytesToBase64URL( + new TextEncoder().encode('not-json'), + ); + await expect( + controller.verifyPasskeyAuthentication( + minimalAuthenticationResponse('uh', { + response: { + ...minimalAuthenticationResponse('uh').response, + clientDataJSON: badClientData, + }, + }), + ), + ).rejects.toThrow(SyntaxError); + }); + it('returns true on successful authentication (prf)', async () => { setupRegistrationMocks(); setupAuthenticationMocks(); @@ -709,7 +726,7 @@ describe('PasskeyController', () => { authOpts.challenge, ), ), - ).rejects.toThrow('aes/gcm'); + ).rejects.toThrow('Passkey vault key decryption failed'); authOpts = controller.generateAuthenticationOptions(); await expect( @@ -936,6 +953,23 @@ describe('PasskeyController', () => { }); describe('removePasskey', () => { + it('clears in-flight registration ceremonies', async () => { + setupRegistrationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + controller.removePasskey(); + + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'k', + }), + ).rejects.toThrow('No active passkey registration ceremony'); + }); + it('clears stored record and resets enrollment', async () => { setupRegistrationMocks(); const controller = createController(); diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index dd0c268d4a5..f8f42fbdc59 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -7,6 +7,7 @@ import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; import { randomBytes } from '@noble/ciphers/webcrypto'; +import { PasskeyAuthenticationRejectedError } from './errors'; import { WEBAUTHN_TIMEOUT_MS, CeremonyManager } from './ceremony-manager'; import { deriveKeyFromAuthenticationResponse, @@ -138,7 +139,7 @@ export class PasskeyController extends BaseController< */ constructor({ messenger, - state, + state = {}, rpID, rpName, expectedOrigin, @@ -188,7 +189,7 @@ export class PasskeyController extends BaseController< #requireEnrolled(): PasskeyRecord { const record = this.#getPasskeyRecord(); if (!record) { - throw new Error('Passkey is not enrolled'); + throw new PasskeyAuthenticationRejectedError('Passkey is not enrolled'); } return record; } @@ -203,7 +204,7 @@ export class PasskeyController extends BaseController< * @returns Whether the passkey is enrolled. */ isPasskeyEnrolled(): boolean { - return this.state.passkeyRecord !== null; + return passkeyControllerSelectors.selectIsPasskeyEnrolled(this.state); } /** @@ -390,11 +391,18 @@ export class PasskeyController extends BaseController< ); // decrypt vault key - const vaultKey = decryptWithKey( - passkeyRecord.encryptedVaultKey.ciphertext, - passkeyRecord.encryptedVaultKey.iv, - encKey, - ); + let vaultKey: string; + try { + vaultKey = decryptWithKey( + passkeyRecord.encryptedVaultKey.ciphertext, + passkeyRecord.encryptedVaultKey.iv, + encKey, + ); + } catch { + throw new PasskeyAuthenticationRejectedError( + 'Passkey vault key decryption failed', + ); + } return vaultKey; } @@ -403,6 +411,10 @@ export class PasskeyController extends BaseController< * Returns whether passkey authentication succeeds for this credential (same * work as {@link retrieveVaultKeyWithPasskey} without exposing the vault key). * + * Returns `false` only when the failure is a normal authentication outcome + * ({@link PasskeyAuthenticationRejectedError}). Unexpected errors (e.g. malformed + * `clientDataJSON`, internal bugs) are rethrown. + * * @param authenticationResponse - Credential from `navigator.credentials.get()`. * @returns `true` if authentication succeeds, otherwise `false`. */ @@ -412,8 +424,11 @@ export class PasskeyController extends BaseController< try { await this.retrieveVaultKeyWithPasskey(authenticationResponse); return true; - } catch { - return false; + } catch (error: unknown) { + if (error instanceof PasskeyAuthenticationRejectedError) { + return false; + } + throw error; } } @@ -486,6 +501,7 @@ export class PasskeyController extends BaseController< this.update((state) => { Object.assign(state, getDefaultPasskeyControllerState()); }); + this.#ceremonyManager.clear(); } /** @@ -510,7 +526,9 @@ export class PasskeyController extends BaseController< const authenticationCeremony = this.#ceremonyManager.getAuthenticationCeremony(challenge); if (!authenticationCeremony) { - throw new Error('No active passkey authentication ceremony'); + throw new PasskeyAuthenticationRejectedError( + 'No active passkey authentication ceremony', + ); } // verify authentication response @@ -530,7 +548,9 @@ export class PasskeyController extends BaseController< }); if (!result.verified) { - throw new Error('Passkey authentication verification failed'); + throw new PasskeyAuthenticationRejectedError( + 'Passkey authentication verification failed', + ); } // persist passkey record with updated counter diff --git a/packages/passkey-controller/src/errors.ts b/packages/passkey-controller/src/errors.ts new file mode 100644 index 00000000000..f40376e89b6 --- /dev/null +++ b/packages/passkey-controller/src/errors.ts @@ -0,0 +1,8 @@ +/** + * Thrown when passkey authentication or vault-key recovery fails in an + * expected operational way (not enrolled, no ceremony, bad assertion, missing + * key material, decrypt failure). + */ +export class PasskeyAuthenticationRejectedError extends Error { + override readonly name = 'PasskeyAuthenticationRejectedError'; +} diff --git a/packages/passkey-controller/src/index.ts b/packages/passkey-controller/src/index.ts index a709d5f2692..2b61f6d5812 100644 --- a/packages/passkey-controller/src/index.ts +++ b/packages/passkey-controller/src/index.ts @@ -1,3 +1,4 @@ +export { PasskeyAuthenticationRejectedError } from './errors'; export { PasskeyController, getDefaultPasskeyControllerState, diff --git a/packages/passkey-controller/src/key-derivation.ts b/packages/passkey-controller/src/key-derivation.ts index dd652884097..bcc354fd557 100644 --- a/packages/passkey-controller/src/key-derivation.ts +++ b/packages/passkey-controller/src/key-derivation.ts @@ -1,3 +1,4 @@ +import { PasskeyAuthenticationRejectedError } from './errors'; import type { PasskeyKeyDerivation, PasskeyRecord, @@ -81,13 +82,17 @@ export function deriveKeyFromAuthenticationResponse( let ikm: Uint8Array; if (record.keyDerivation.method === 'prf') { if (!hasPrfOutput) { - throw new Error('Passkey assertion missing required key material'); + throw new PasskeyAuthenticationRejectedError( + 'Passkey assertion missing required key material', + ); } ikm = base64URLToBytes(prfFirst); } else if (userHandle) { ikm = base64URLToBytes(userHandle); } else { - throw new Error('Passkey assertion missing required key material'); + throw new PasskeyAuthenticationRejectedError( + 'Passkey assertion missing required key material', + ); } return deriveEncryptionKey(ikm, base64URLToBytes(record.credential.id)); From df275aa0cc46fac764864ada54b885df113c3e1c Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Tue, 21 Apr 2026 12:43:57 +0700 Subject: [PATCH 23/44] fix: CI on README and deps --- .github/CODEOWNERS | 3 + packages/passkey-controller/CHANGELOG.md | 36 +- .../passkey-controller/jest.environment.js | 4 +- packages/passkey-controller/package.json | 18 +- .../CHANGELOG.md | 172 +- yarn.lock | 1396 +---------------- 6 files changed, 242 insertions(+), 1387 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8438a14f854..f2dd5c5b6fd 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -102,6 +102,7 @@ ## Web3Auth Team /packages/seedless-onboarding-controller @MetaMask/web3auth +/packages/passkey-controller @MetaMask/web3auth /packages/shield-controller @MetaMask/web3auth /packages/subscription-controller @MetaMask/web3auth /packages/claims-controller @MetaMask/web3auth @@ -168,6 +169,8 @@ /packages/geolocation-controller/CHANGELOG.md @MetaMask/core-platform /packages/keyring-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform /packages/keyring-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/passkey-controller/package.json @MetaMask/web3auth @MetaMask/core-platform +/packages/passkey-controller/CHANGELOG.md @MetaMask/web3auth @MetaMask/core-platform /packages/logging-controller/package.json @MetaMask/confirmations @MetaMask/core-platform /packages/logging-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform /packages/message-manager/package.json @MetaMask/confirmations @MetaMask/core-platform diff --git a/packages/passkey-controller/CHANGELOG.md b/packages/passkey-controller/CHANGELOG.md index 2d47c3b5711..7dafdb15110 100644 --- a/packages/passkey-controller/CHANGELOG.md +++ b/packages/passkey-controller/CHANGELOG.md @@ -9,29 +9,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `PasskeyController` constructor accepts optional `userName` and `userDisplayName` so consumers can override the values shown in the OS passkey UI; both default to `rpName`. -- `PasskeyController.destroy()` clears in-flight ceremony state in addition to the standard `BaseController` teardown. -- `passkeyControllerSelectors.selectIsPasskeyEnrolled` selector for use in Redux selectors and other code paths that only have access to a state object. -- `PasskeyController.verifyPasskeyAuthentication` — returns whether the passkey authentication response is valid without returning the vault key (delegates to the same path as `retrieveVaultKeyWithPasskey`). -- Initial `@metamask/passkey-controller` package: passkey-based vault key protection using WebAuthn, orchestrating enrollment, authentication, and vault key wrap/unwrap with AES-256-GCM and HKDF-derived keys. -- `PasskeyController` API: +- `PasskeyController` constructor accepts optional `userName` and `userDisplayName` so consumers can override the values shown in the OS passkey UI; both default to `rpName`. ([#8422](https://github.com/MetaMask/core/pull/8422)) +- `PasskeyController.destroy()` clears in-flight ceremony state in addition to the standard `BaseController` teardown. ([#8422](https://github.com/MetaMask/core/pull/8422)) +- `passkeyControllerSelectors.selectIsPasskeyEnrolled` selector for use in Redux selectors and other code paths that only have access to a state object. ([#8422](https://github.com/MetaMask/core/pull/8422)) +- `PasskeyController.verifyPasskeyAuthentication` — returns whether the passkey authentication response is valid without returning the vault key (delegates to the same path as `retrieveVaultKeyWithPasskey`). ([#8422](https://github.com/MetaMask/core/pull/8422)) +- Initial `@metamask/passkey-controller` package: passkey-based vault key protection using WebAuthn, orchestrating enrollment, authentication, and vault key wrap/unwrap with AES-256-GCM and HKDF-derived keys. ([#8422](https://github.com/MetaMask/core/pull/8422)) +- `PasskeyController` API: ([#8422](https://github.com/MetaMask/core/pull/8422)) - `generateRegistrationOptions` / `protectVaultKeyWithPasskey` — enrollment and vault key protection - `generateAuthenticationOptions` / `retrieveVaultKeyWithPasskey` — unlock and vault key recovery - `renewVaultKeyProtection` — re-wrap vault key for password-change flows without re-enrolling the passkey - `removePasskey` — unenroll and clear key material - `isPasskeyEnrolled` — enrollment check (also available as `passkeyControllerSelectors.selectIsPasskeyEnrolled`) - `clearState` — reset persisted state and clear in-flight WebAuthn ceremony state (for app lifecycle, e.g. wallet reset) -- Adaptive key derivation during enrollment: **PRF** (WebAuthn PRF extension output as HKDF input) or **userHandle** fallback; PRF path used only when `prf.results.first` is a non-empty string. -- Self-contained WebAuthn verification (no Node server): `clientDataJSON` and `authenticatorData` checks, signature verification (`@noble/curves` + Web Crypto RSA), attestation formats `none` and `packed` self-attestation. -- In-flight **ceremony** coordination (distinct from user login sessions): challenge-keyed registration/authentication state in `src/ceremony-manager.ts` (`CeremonyManager` and timing/capacity constants), TTL aligned with WebAuthn `timeout`, and a cap on concurrent ceremonies per flow so multiple tabs/contexts do not overwrite a single in-memory entry. -- Exported timing and limits from package entry: `WEBAUTHN_TIMEOUT_MS`, `CEREMONY_TTL_SLACK_MS`, `CEREMONY_MAX_AGE_MS`, `MAX_CONCURRENT_PASSKEY_CEREMONIES` (`PasskeyController` does not re-export these; import from `@metamask/passkey-controller` or `./ceremony-manager` in the monorepo). -- Exported controller types (`PasskeyControllerState`, messenger types, actions, events) and WebAuthn option/response types; internal ceremony payload types `PasskeyRegistrationCeremony` / `PasskeyAuthenticationCeremony` in `types.ts`. -- AES-256-GCM helpers and COSE enums (`COSEALG`, `COSEKEYS`, `COSEKTY`, `COSECRV`). -- `PasskeyAuthenticationRejectedError` for expected authentication or enrollment failures (not enrolled, missing ceremony, failed verification, missing key material, vault decrypt failure). `PasskeyController.verifyPasskeyAuthentication` returns `false` only for this error and rethrows anything else. -- `renewVaultKeyProtection` throws `PasskeyAuthenticationRejectedError('Passkey is not enrolled')` when called without an enrolled passkey instead of an opaque `TypeError`. -- `verifyAuthenticationResponse` returns a discriminated union (`{ verified: false }` or `{ verified: true; authenticationInfo }`) so consumers cannot accidentally read `authenticationInfo` on a failed verification; signature verification failure short-circuits before the counter-monotonicity check. -- `PasskeyController` constructor defaults `state` to `{}` per controller guidelines. -- `PasskeyController.isPasskeyEnrolled` delegates to `passkeyControllerSelectors.selectIsPasskeyEnrolled`. -- `PasskeyController.removePasskey` clears in-flight ceremony state (same as `clearState` for the ceremony manager). +- Adaptive key derivation during enrollment: **PRF** (WebAuthn PRF extension output as HKDF input) or **userHandle** fallback; PRF path used only when `prf.results.first` is a non-empty string. ([#8422](https://github.com/MetaMask/core/pull/8422)) +- Self-contained WebAuthn verification (no Node server): `clientDataJSON` and `authenticatorData` checks, signature verification (`@noble/curves` + Web Crypto RSA), attestation formats `none` and `packed` self-attestation. ([#8422](https://github.com/MetaMask/core/pull/8422)) +- In-flight **ceremony** coordination (distinct from user login sessions): challenge-keyed registration/authentication state in `src/ceremony-manager.ts` (`CeremonyManager` and timing/capacity constants), TTL aligned with WebAuthn `timeout`, and a cap on concurrent ceremonies per flow so multiple tabs/contexts do not overwrite a single in-memory entry. ([#8422](https://github.com/MetaMask/core/pull/8422)) +- Exported timing and limits from package entry: `WEBAUTHN_TIMEOUT_MS`, `CEREMONY_TTL_SLACK_MS`, `CEREMONY_MAX_AGE_MS`, `MAX_CONCURRENT_PASSKEY_CEREMONIES` (`PasskeyController` does not re-export these; import from `@metamask/passkey-controller` or `./ceremony-manager` in the monorepo). ([#8422](https://github.com/MetaMask/core/pull/8422)) +- Exported controller types (`PasskeyControllerState`, messenger types, actions, events) and WebAuthn option/response types; internal ceremony payload types `PasskeyRegistrationCeremony` / `PasskeyAuthenticationCeremony` in `types.ts`. ([#8422](https://github.com/MetaMask/core/pull/8422)) +- AES-256-GCM helpers and COSE enums (`COSEALG`, `COSEKEYS`, `COSEKTY`, `COSECRV`). ([#8422](https://github.com/MetaMask/core/pull/8422)) +- `PasskeyAuthenticationRejectedError` for expected authentication or enrollment failures (not enrolled, missing ceremony, failed verification, missing key material, vault decrypt failure). `PasskeyController.verifyPasskeyAuthentication` returns `false` only for this error and rethrows anything else. ([#8422](https://github.com/MetaMask/core/pull/8422)) +- `renewVaultKeyProtection` throws `PasskeyAuthenticationRejectedError('Passkey is not enrolled')` when called without an enrolled passkey instead of an opaque `TypeError`. ([#8422](https://github.com/MetaMask/core/pull/8422)) +- `verifyAuthenticationResponse` returns a discriminated union (`{ verified: false }` or `{ verified: true; authenticationInfo }`) so consumers cannot accidentally read `authenticationInfo` on a failed verification; signature verification failure short-circuits before the counter-monotonicity check. ([#8422](https://github.com/MetaMask/core/pull/8422)) +- `PasskeyController` constructor defaults `state` to `{}` per controller guidelines. ([#8422](https://github.com/MetaMask/core/pull/8422)) +- `PasskeyController.isPasskeyEnrolled` delegates to `passkeyControllerSelectors.selectIsPasskeyEnrolled`. ([#8422](https://github.com/MetaMask/core/pull/8422)) +- `PasskeyController.removePasskey` clears in-flight ceremony state (same as `clearState` for the ceremony manager). ([#8422](https://github.com/MetaMask/core/pull/8422)) [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/passkey-controller/jest.environment.js b/packages/passkey-controller/jest.environment.js index 08bc740e326..c3b47d5c246 100644 --- a/packages/passkey-controller/jest.environment.js +++ b/packages/passkey-controller/jest.environment.js @@ -1,9 +1,9 @@ -const NodeEnvironment = require('jest-environment-node'); +const { TestEnvironment } = require('jest-environment-node'); /** * Passkey orchestration uses the Web Crypto API (`crypto.getRandomValues`) in Node tests. */ -class CustomTestEnvironment extends NodeEnvironment { +class CustomTestEnvironment extends TestEnvironment { async setup() { await super.setup(); if (typeof this.global.crypto === 'undefined') { diff --git a/packages/passkey-controller/package.json b/packages/passkey-controller/package.json index 03369ba11ee..c7fcb697a5b 100644 --- a/packages/passkey-controller/package.json +++ b/packages/passkey-controller/package.json @@ -3,8 +3,8 @@ "version": "0.0.0", "description": "Controller and utilities for passkey-based wallet unlock", "keywords": [ - "MetaMask", - "Ethereum" + "Ethereum", + "MetaMask" ], "homepage": "https://github.com/MetaMask/core/tree/main/packages/passkey-controller#readme", "bugs": { @@ -49,7 +49,7 @@ }, "dependencies": { "@levischuck/tiny-cbor": "^0.3.3", - "@metamask/base-controller": "^9.0.0", + "@metamask/base-controller": "^9.1.0", "@metamask/messenger": "^1.1.1", "@metamask/utils": "^11.9.0", "@noble/ciphers": "^1.3.0", @@ -57,14 +57,14 @@ "@noble/hashes": "^1.8.0" }, "devDependencies": { - "@metamask/auto-changelog": "^3.4.4", + "@metamask/auto-changelog": "^6.1.0", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "jest-environment-node": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "jest-environment-node": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index 6454e07a420..09f9efb94b5 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -7,6 +7,173 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Uncategorized + +- fix: CI on README and deps +- fix: refactor to follow controller guidelines +- fix: address review comments +- chore: update deps +- Merge branch 'main' into feat/TO-540-passkey-controller +- fix: address review comments +- refactor: restructure passkey record state +- feat: added authenticated user storage ([#8260](https://github.com/MetaMask/core/pull/8260)) +- add money account upgrade controller ([#8426](https://github.com/MetaMask/core/pull/8426)) +- Release 927.0.0 ([#8525](https://github.com/MetaMask/core/pull/8525)) +- Release/926.0.0 ([#8523](https://github.com/MetaMask/core/pull/8523)) +- feat: normalize bridge hardware type ([#8503](https://github.com/MetaMask/core/pull/8503)) +- feat(social-controllers): source profileId from JWT on follow/unfollow/updateFollowing ([#8520](https://github.com/MetaMask/core/pull/8520)) +- feat(card): add cardDelegation transaction type ([#8508](https://github.com/MetaMask/core/pull/8508)) +- fix(transaction-pay-controller): proxy Across status polling via configured api base ([#8512](https://github.com/MetaMask/core/pull/8512)) +- feat: skip verification of passkey authentication response when renewing vault key protection +- feat: add hints to passkey registration and authentication options +- feat: allow verifying passkey authentication +- chore: refactor passkey controller +- refactor: use lowercase file names for webauthn module +- feat: allow multiple passkey ceremony at the same time +- Release/925.0.0 ([#8516](https://github.com/MetaMask/core/pull/8516)) +- feat(perps-controller): sync controller from mobile c2248a8a9e ([#8515](https://github.com/MetaMask/core/pull/8515)) +- Release/924.0.0 ([#8514](https://github.com/MetaMask/core/pull/8514)) +- Release/923.0.0 ([#8509](https://github.com/MetaMask/core/pull/8509)) +- feat: add chomp API service ([#8413](https://github.com/MetaMask/core/pull/8413)) +- Release 922.0.0 ([#8507](https://github.com/MetaMask/core/pull/8507)) +- fix(AccountTrackerController): prevent balance wipe during account sync ([#8505](https://github.com/MetaMask/core/pull/8505)) +- feat: Expose `createPermissionMiddleware` via messenger ([#8502](https://github.com/MetaMask/core/pull/8502)) +- fix(transaction-pay-controller): stop adding subsidized fee to target amount ([#8488](https://github.com/MetaMask/core/pull/8488)) +- Release/921.0.0 ([#8495](https://github.com/MetaMask/core/pull/8495)) +- chore: Enable Oxfmt for generating method action types files ([#8498](https://github.com/MetaMask/core/pull/8498)) +- ci: Run some jobs on Node.js 24 ([#8500](https://github.com/MetaMask/core/pull/8500)) +- feat!: Replace ESLint with direct call to Prettier or Oxfmt in messenger CLI ([#8486](https://github.com/MetaMask/core/pull/8486)) +- feat: allow clearing state in passkey controller +- fix(transaction-pay-controller): resolve correct networkClientId for source chain in relay execute ([#8492](https://github.com/MetaMask/core/pull/8492)) +- Release/920.0.0 ([#8494](https://github.com/MetaMask/core/pull/8494)) +- chore: bump `accounts` deps ([#8464](https://github.com/MetaMask/core/pull/8464)) +- feat: adds auth to social controllers ([#8485](https://github.com/MetaMask/core/pull/8485)) +- Release/919.0.0 ([#8482](https://github.com/MetaMask/core/pull/8482)) +- chore: refactor docs +- feat: use rp ID and name from platform +- feat: accept prf availability from browser when generating registration options +- Release/918.0.0 ([#8478](https://github.com/MetaMask/core/pull/8478)) +- chore: add periodic check for spl tokens ([#8400](https://github.com/MetaMask/core/pull/8400)) +- feat: add passkey verification and refactor code +- feat: added money-account-balance-service to root tsconfig files ([#8477](https://github.com/MetaMask/core/pull/8477)) +- Release 917.0.0 ([#8476](https://github.com/MetaMask/core/pull/8476)) +- Release/916.0.0 ([#8474](https://github.com/MetaMask/core/pull/8474)) +- Deprecate AbstractRpcService and RpcServiceRequestable ([#8475](https://github.com/MetaMask/core/pull/8475)) +- chore: Format changelogs with Oxfmt ([#8442](https://github.com/MetaMask/core/pull/8442)) +- fix(perps-controller): restore MYX webpackIgnore workaround ([#8473](https://github.com/MetaMask/core/pull/8473)) +- feat: implement no-retry policy for TransakService verify OTP ([#8468](https://github.com/MetaMask/core/pull/8468)) +- feat: port verification of authentication and registration responses from simplewebauthn +- fix: improve assets controller snap accounts subscription and websocket ([#8430](https://github.com/MetaMask/core/pull/8430)) +- Release/915.0.0 ([#8472](https://github.com/MetaMask/core/pull/8472)) +- Release/914.0.0 ([#8470](https://github.com/MetaMask/core/pull/8470)) +- fix: Fix `batch` `transaction_type` in metrics for pay transactions ([#8469](https://github.com/MetaMask/core/pull/8469)) +- chore: add imageUrl ([#8448](https://github.com/MetaMask/core/pull/8448)) +- feat(social-controllers): add followingProfileIds to controller state ([#8459](https://github.com/MetaMask/core/pull/8459)) +- Release/913.0.0 ([#8466](https://github.com/MetaMask/core/pull/8466)) +- feat(perps): sync controller from mobile ([#8460](https://github.com/MetaMask/core/pull/8460)) +- refactor: change method names and use crypto packages +- Add status to `PermissionInfoWithMetadata` ([#8445](https://github.com/MetaMask/core/pull/8445)) +- feat: MUSD-248 create money-account-balance-service ([#8428](https://github.com/MetaMask/core/pull/8428)) +- Revert "Release 912.0.0 (#8451)" ([#8451](https://github.com/MetaMask/core/pull/8451)) +- Release 912.0.0 ([#8451](https://github.com/MetaMask/core/pull/8451)) +- Release/911.0.0 ([#8452](https://github.com/MetaMask/core/pull/8452)) +- feat: skip simulation when enforced simulations container is active ([#8431](https://github.com/MetaMask/core/pull/8431)) +- feat(account-tree-controller): always fire `:selectedAccountGroupChange` on init ([#8427](https://github.com/MetaMask/core/pull/8427)) +- feat(account-tree-controller): persist `accountTree` ([#8437](https://github.com/MetaMask/core/pull/8437)) +- fix: link existing push token when notification accounts are added ([#8449](https://github.com/MetaMask/core/pull/8449)) +- feat: add mantle testnet gas fee ([#8386](https://github.com/MetaMask/core/pull/8386)) +- feat: allow update encryption key +- Release/910.0.0 ([#8447](https://github.com/MetaMask/core/pull/8447)) +- Release/909.0.0 ([#8444](https://github.com/MetaMask/core/pull/8444)) +- fix: set submittedTime at pay publish hook start for accurate time-to-complete metrics ([#8439](https://github.com/MetaMask/core/pull/8439)) +- chore: bump `@metamask/auto-changelog` to `^6.0.0` ([#8441](https://github.com/MetaMask/core/pull/8441)) +- chore: Use Oxfmt for import sorting instead of `import-x/order` ([#8438](https://github.com/MetaMask/core/pull/8438)) +- chore: Replace Prettier with Oxfmt ([#8434](https://github.com/MetaMask/core/pull/8434)) +- Release/908.0.0 ([#8440](https://github.com/MetaMask/core/pull/8440)) +- feat: MUSD-626 update earn controller to follow init pattern ([#8421](https://github.com/MetaMask/core/pull/8421)) +- feat(keyring-controller): add `withKeyringV2` support ([#8390](https://github.com/MetaMask/core/pull/8390)) +- feat(transaction-controller): add atomic option ([#8320](https://github.com/MetaMask/core/pull/8320)) +- fix: revert change on extension checking single NFT ownership ([#8435](https://github.com/MetaMask/core/pull/8435)) +- Release/907.0.0 ([#8432](https://github.com/MetaMask/core/pull/8432)) +- Release 906.0.0 ([#8425](https://github.com/MetaMask/core/pull/8425)) +- fix(perps-controller): restore webpackIgnore comment stripped by ts-bridge ([#8424](https://github.com/MetaMask/core/pull/8424)) +- fix: batch NFT ownership checks via Multicall3 ([#8281](https://github.com/MetaMask/core/pull/8281)) +- feat(keyring-controller): persist vault when keyring state changes during unlock ([#8415](https://github.com/MetaMask/core/pull/8415)) +- Fix: assets controller startup empty accounts ([#8412](https://github.com/MetaMask/core/pull/8412)) +- feat(social-controllers): add intent and category fields to Trade type ([#8410](https://github.com/MetaMask/core/pull/8410)) +- feat: update configuration of passkeys +- chore: Remove storage functionality from Delegation Controller ([#8330](https://github.com/MetaMask/core/pull/8330)) +- refactor: passkey controller to return registration and authentication options +- feat: init passkey controller +- feat: expose `getAccountAddressRelationship` and its request type ([#8402](https://github.com/MetaMask/core/pull/8402)) +- feat(STX-433): remove submission method from transaction meta ([#8409](https://github.com/MetaMask/core/pull/8409)) +- Release 905.0.0 ([#8407](https://github.com/MetaMask/core/pull/8407)) +- chore: Expose `KeyringController:signTransaction` through messenger ([#8408](https://github.com/MetaMask/core/pull/8408)) +- feat(perps-controller): make package safe for extension consumers (re-do of #8374) ([#8398](https://github.com/MetaMask/core/pull/8398)) +- feat(ramps-controller): add optional fiat buy limits to Provider type ([#8405](https://github.com/MetaMask/core/pull/8405)) +- Release/904.0.0 ([#8406](https://github.com/MetaMask/core/pull/8406)) +- Revert "Update Release 905.0.0 (#8399) and release 904.0.0 (#8397)" ([#8399](https://github.com/MetaMask/core/pull/8399)) +- Update Release 905.0.0 ([#8399](https://github.com/MetaMask/core/pull/8399)) +- Release/904.0.0 ([#8397](https://github.com/MetaMask/core/pull/8397)) +- feat: adopt messenger-cli pattern for 4 controller packages ([#8391](https://github.com/MetaMask/core/pull/8391)) +- chore: Update bridge controllers to expose all methods through messenger ([#8367](https://github.com/MetaMask/core/pull/8367)) +- fix(assets-controller): hardened error handling ([#8389](https://github.com/MetaMask/core/pull/8389)) +- feat(STX-433): add submissionMethod to transaction metadata ([#8375](https://github.com/MetaMask/core/pull/8375)) +- feat: improve rpc data source ([#8385](https://github.com/MetaMask/core/pull/8385)) +- fix(transaction-pay): allow perps withdraw to Arbitrum USDC by skipping same-token filter for HyperLiquid source ([#8387](https://github.com/MetaMask/core/pull/8387)) +- feat: Enable Across support for perps deposits ([#8334](https://github.com/MetaMask/core/pull/8334)) +- feat(assets-controller): batch API requests and split pipeline for faster asset loading ([#8383](https://github.com/MetaMask/core/pull/8383)) +- Release/903.0.0 ([#8382](https://github.com/MetaMask/core/pull/8382)) +- feat: extract generate-action-types CLI into @metamask/messenger-cli ([#8378](https://github.com/MetaMask/core/pull/8378)) +- chore: rename Ramp Team to Money Movement Team ([#8381](https://github.com/MetaMask/core/pull/8381)) +- Release/902.0.0 ([#8380](https://github.com/MetaMask/core/pull/8380)) +- fix(ramps): Remove dual-path fetching from RampsController, let client own data lifecycle ([#8354](https://github.com/MetaMask/core/pull/8354)) +- Ensure that README content is kept up to date automatically ([#8379](https://github.com/MetaMask/core/pull/8379)) +- Deprecate :stateChange in favor of :stateChanged ([#8187](https://github.com/MetaMask/core/pull/8187)) +- Drop eslint peer dep in messenger package ([#8371](https://github.com/MetaMask/core/pull/8371)) +- Release/900.0.0 ([#8370](https://github.com/MetaMask/core/pull/8370)) +- refactor(compliance): remove initial block wallets call ([#8365](https://github.com/MetaMask/core/pull/8365)) +- Release/899.0.0 ([#8369](https://github.com/MetaMask/core/pull/8369)) +- Feat/tsa 317 social service ([#8335](https://github.com/MetaMask/core/pull/8335)) +- Release/898.0.0 ([#8368](https://github.com/MetaMask/core/pull/8368)) +- feat: add `money-account-controller` ([#8361](https://github.com/MetaMask/core/pull/8361)) +- feat: Expose all PerpsController methods through messenger ([#8352](https://github.com/MetaMask/core/pull/8352)) +- Migrate SampleGasPriceService to BaseDataService ([#8343](https://github.com/MetaMask/core/pull/8343)) +- Prevent calling messenger actions in controller/service constructors ([#8353](https://github.com/MetaMask/core/pull/8353)) +- feat(accounts-controller): filter out `MoneyKeyring` accounts ([#8362](https://github.com/MetaMask/core/pull/8362)) +- Add selectors for `ConnectivityController` ([#7701](https://github.com/MetaMask/core/pull/7701)) +- feat(keyring-controller): add `KeyringTypes.money` ([#8360](https://github.com/MetaMask/core/pull/8360)) +- Release/895.0.0 ([#8359](https://github.com/MetaMask/core/pull/8359)) +- feat(keyring-controller): add `withKeyringUnsafe` action ([#8358](https://github.com/MetaMask/core/pull/8358)) +- feat(keyring-controller): add `isKeyringNotFoundError` ([#8351](https://github.com/MetaMask/core/pull/8351)) +- feat: require messenger and Blockaid bulk scan in TokenDataSource ([#8329](https://github.com/MetaMask/core/pull/8329)) +- feat(messenger): add `generate-action-types` CLI tool as subpath export ([#8264](https://github.com/MetaMask/core/pull/8264)) +- Release/894.0.0 ([#8355](https://github.com/MetaMask/core/pull/8355)) +- feat: inject new parameters in addTransaction for gas fee tokens (Tempo) ([#8052](https://github.com/MetaMask/core/pull/8052)) +- feat: Expose NetworkController methods through messenger ([#8350](https://github.com/MetaMask/core/pull/8350)) +- feat(keyring-controller): add `filter` selector callback to `withKeyring` ([#8348](https://github.com/MetaMask/core/pull/8348)) +- fix: hide native tokens on Tempo networks (testnet and mainnet) ([#7882](https://github.com/MetaMask/core/pull/7882)) +- fix(account-tree-controller): remove dynamic identifiers from Backup and sync thrown error messages ([#8349](https://github.com/MetaMask/core/pull/8349)) +- fix: add missing @metamask/messenger dependency ([#8318](https://github.com/MetaMask/core/pull/8318)) +- Release/893.0.0 ([#8344](https://github.com/MetaMask/core/pull/8344)) +- Release/892.0.0 ([#8340](https://github.com/MetaMask/core/pull/8340)) +- feat: ctrl utils add info chains no native token ([#8336](https://github.com/MetaMask/core/pull/8336)) +- feat: bridgeController consume complete event ([#8306](https://github.com/MetaMask/core/pull/8306)) +- feat: replaced metamask-earn team with earn in codeowners ([#8328](https://github.com/MetaMask/core/pull/8328)) +- feat(perps): latest controller sync ([#8333](https://github.com/MetaMask/core/pull/8333)) +- feat(transaction-pay-controller): add route-based strategy resolution ([#8282](https://github.com/MetaMask/core/pull/8282)) +- feat: introduces the new home for the social controllers ([#8321](https://github.com/MetaMask/core/pull/8321)) +- Release/891.0.0 ([#8327](https://github.com/MetaMask/core/pull/8327)) +- fix: market overview caip19 bug fix ([#8326](https://github.com/MetaMask/core/pull/8326)) +- chore: remove `@metamask/error-reporting-service` package ([#8323](https://github.com/MetaMask/core/pull/8323)) +- Release 890.0.0 ([#8325](https://github.com/MetaMask/core/pull/8325)) +- refactor(transaction-controller): encapsulate provider logic ([#8273](https://github.com/MetaMask/core/pull/8273)) +- ci: Separate lint steps to speed up CI ([#8322](https://github.com/MetaMask/core/pull/8322)) +- feat(transaction-pay-controller): add HyperLiquid withdrawal submission via Relay ([#8314](https://github.com/MetaMask/core/pull/8314)) +- test(base-data-service): Use `registerMethodActionHandlers` ([#8324](https://github.com/MetaMask/core/pull/8324)) +- chore!: Bump Snaps packages and handle breaking changes ([#8319](https://github.com/MetaMask/core/pull/8319)) +- feat: (TokenBalancesController) add batching around update balance requests ([#8246](https://github.com/MetaMask/core/pull/8246)) + ### Added - Add `runMigrations` method to run pending data migrations for legacy secrets ([#7284](https://github.com/MetaMask/core/pull/7284)) @@ -271,7 +438,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added an optional parameter, `passwordOutdatedCacheTTL` to the constructor params and exported `SecretMetadata` class from the controller.([#6169](https://github.com/MetaMask/core/pull/6169)) - - Added `revokeRefreshToken` function to revoke refresh token and update vault with the new revoke token.([#6187](https://github.com/MetaMask/core/pull/6187)) ## [2.4.0] @@ -320,7 +486,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added `PrivateKey sync` feature to the controller ([#5948](https://github.com/MetaMask/core/pull/5948)). +- Added `PrivateKey sync` feature to the controller. ([#5948](https://github.com/MetaMask/core/pull/5948)) - **BREAKING** Updated controller methods signatures. - removed `addNewSeedPhraseBackup` and replaced with `addNewSecretData` method. - added `addNewSecretData` method implementation to support adding different secret data types. @@ -343,7 +509,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `createToprfKeyAndBackupSeedPhrase`, `fetchAllSecretData` store revoke token in vault - check for token expired in toprf call, refresh token and retry if expired - `submitPassword` revoke refresh token and replace with new one after password submit to prevent malicious use if refresh token leak in persisted state -- Removed `recoveryRatelimitCache` from the controller state. ([#5976](https://github.com/MetaMask/core/pull/5976)). +- Removed `recoveryRatelimitCache` from the controller state.. ([#5976](https://github.com/MetaMask/core/pull/5976)) - **BREAKING:** Changed `syncLatestGlobalPassword`. ([#5995](https://github.com/MetaMask/core/pull/5995)) - removed parameter `oldPassword` - no longer verifying old password diff --git a/yarn.lock b/yarn.lock index b81697d311d..4ee23d8c373 100644 --- a/yarn.lock +++ b/yarn.lock @@ -37,7 +37,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.1.0, @babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.23.2, @babel/core@npm:^7.23.9, @babel/core@npm:^7.7.2, @babel/core@npm:^7.8.0": +"@babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.23.2, @babel/core@npm:^7.23.9": version: 7.29.0 resolution: "@babel/core@npm:7.29.0" dependencies: @@ -372,7 +372,7 @@ __metadata: languageName: node linkType: hard -"@babel/traverse@npm:^7.23.2, @babel/traverse@npm:^7.28.6, @babel/traverse@npm:^7.29.0, @babel/traverse@npm:^7.7.2": +"@babel/traverse@npm:^7.23.2, @babel/traverse@npm:^7.28.6, @babel/traverse@npm:^7.29.0": version: 7.29.0 resolution: "@babel/traverse@npm:7.29.0" dependencies: @@ -2114,20 +2114,6 @@ __metadata: languageName: node linkType: hard -"@jest/console@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/console@npm:27.5.1" - dependencies: - "@jest/types": "npm:^27.5.1" - "@types/node": "npm:*" - chalk: "npm:^4.0.0" - jest-message-util: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - slash: "npm:^3.0.0" - checksum: 10/f724ff9693b09711fded8b87145c3446091bde87f572e210667c2b8290b5364c776f3a99c7d1fd6d5642f7f9424d5acc312c12e9cc4da2ef0260d34547869fdd - languageName: node - linkType: hard - "@jest/console@npm:^29.7.0": version: 29.7.0 resolution: "@jest/console@npm:29.7.0" @@ -2142,47 +2128,6 @@ __metadata: languageName: node linkType: hard -"@jest/core@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/core@npm:27.5.1" - dependencies: - "@jest/console": "npm:^27.5.1" - "@jest/reporters": "npm:^27.5.1" - "@jest/test-result": "npm:^27.5.1" - "@jest/transform": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" - "@types/node": "npm:*" - ansi-escapes: "npm:^4.2.1" - chalk: "npm:^4.0.0" - emittery: "npm:^0.8.1" - exit: "npm:^0.1.2" - graceful-fs: "npm:^4.2.9" - jest-changed-files: "npm:^27.5.1" - jest-config: "npm:^27.5.1" - jest-haste-map: "npm:^27.5.1" - jest-message-util: "npm:^27.5.1" - jest-regex-util: "npm:^27.5.1" - jest-resolve: "npm:^27.5.1" - jest-resolve-dependencies: "npm:^27.5.1" - jest-runner: "npm:^27.5.1" - jest-runtime: "npm:^27.5.1" - jest-snapshot: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - jest-validate: "npm:^27.5.1" - jest-watcher: "npm:^27.5.1" - micromatch: "npm:^4.0.4" - rimraf: "npm:^3.0.0" - slash: "npm:^3.0.0" - strip-ansi: "npm:^6.0.0" - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - checksum: 10/79eb63c3197336c39de6a3341d3f5e7dbca7e20796bd4ee3d725e4ef2832f4d07242898a8af6c9de19ebd700983385a3df16c024b4497f8beb666c8ffe96ccb4 - languageName: node - linkType: hard - "@jest/core@npm:^29.7.0": version: 29.7.0 resolution: "@jest/core@npm:29.7.0" @@ -2224,18 +2169,6 @@ __metadata: languageName: node linkType: hard -"@jest/environment@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/environment@npm:27.5.1" - dependencies: - "@jest/fake-timers": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" - "@types/node": "npm:*" - jest-mock: "npm:^27.5.1" - checksum: 10/74a2a4427f82b096c4f7223c56a27f64487ee4639b017129f31e99ebb2e9a614eb365ec77c3701d6eedc1c8d711ad2dd4b31d6dfad72cbb6d73a4f1fdc4a86cb - languageName: node - linkType: hard - "@jest/environment@npm:^29.7.0": version: 29.7.0 resolution: "@jest/environment@npm:29.7.0" @@ -2267,20 +2200,6 @@ __metadata: languageName: node linkType: hard -"@jest/fake-timers@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/fake-timers@npm:27.5.1" - dependencies: - "@jest/types": "npm:^27.5.1" - "@sinonjs/fake-timers": "npm:^8.0.1" - "@types/node": "npm:*" - jest-message-util: "npm:^27.5.1" - jest-mock: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - checksum: 10/dd8b736edbc8da77af3ca14ffaa2f331168618db7b879a3a07a4667af11ae4ff840f64a61e3828e217ee94f06d5a9ba30bf19e5103bb74e193b8216ce4c0708d - languageName: node - linkType: hard - "@jest/fake-timers@npm:^29.7.0": version: 29.7.0 resolution: "@jest/fake-timers@npm:29.7.0" @@ -2295,17 +2214,6 @@ __metadata: languageName: node linkType: hard -"@jest/globals@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/globals@npm:27.5.1" - dependencies: - "@jest/environment": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" - expect: "npm:^27.5.1" - checksum: 10/f3b06e9b81686d7a5dd7bafb229cba73bdc90d3e16815deebf302d3a402ac29a1e9bafa274d908caefe7083938402619974c89420d247ab8739acd652c11b16d - languageName: node - linkType: hard - "@jest/globals@npm:^29.7.0": version: 29.7.0 resolution: "@jest/globals@npm:29.7.0" @@ -2318,44 +2226,6 @@ __metadata: languageName: node linkType: hard -"@jest/reporters@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/reporters@npm:27.5.1" - dependencies: - "@bcoe/v8-coverage": "npm:^0.2.3" - "@jest/console": "npm:^27.5.1" - "@jest/test-result": "npm:^27.5.1" - "@jest/transform": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" - "@types/node": "npm:*" - chalk: "npm:^4.0.0" - collect-v8-coverage: "npm:^1.0.0" - exit: "npm:^0.1.2" - glob: "npm:^7.1.2" - graceful-fs: "npm:^4.2.9" - istanbul-lib-coverage: "npm:^3.0.0" - istanbul-lib-instrument: "npm:^5.1.0" - istanbul-lib-report: "npm:^3.0.0" - istanbul-lib-source-maps: "npm:^4.0.0" - istanbul-reports: "npm:^3.1.3" - jest-haste-map: "npm:^27.5.1" - jest-resolve: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - jest-worker: "npm:^27.5.1" - slash: "npm:^3.0.0" - source-map: "npm:^0.6.0" - string-length: "npm:^4.0.1" - terminal-link: "npm:^2.0.0" - v8-to-istanbul: "npm:^8.1.0" - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - checksum: 10/d49aea4e5b09f9a316f0ff303d11f2db057cadaf370e3e706c024e4ea7f270899cccf7488711def4a930bc23e4f4676f406d1c646f8c6656de4c43dd40652877 - languageName: node - linkType: hard - "@jest/reporters@npm:^29.7.0": version: 29.7.0 resolution: "@jest/reporters@npm:29.7.0" @@ -2402,17 +2272,6 @@ __metadata: languageName: node linkType: hard -"@jest/source-map@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/source-map@npm:27.5.1" - dependencies: - callsites: "npm:^3.0.0" - graceful-fs: "npm:^4.2.9" - source-map: "npm:^0.6.0" - checksum: 10/90b1f4212b7191d594275c9b9aae18319b944e4ed018af74a1661fd9b783983074d00369a111274697b87193aa2b084f0f022a265d070f4a66d39d06d14a0336 - languageName: node - linkType: hard - "@jest/source-map@npm:^29.6.3": version: 29.6.3 resolution: "@jest/source-map@npm:29.6.3" @@ -2424,18 +2283,6 @@ __metadata: languageName: node linkType: hard -"@jest/test-result@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/test-result@npm:27.5.1" - dependencies: - "@jest/console": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" - "@types/istanbul-lib-coverage": "npm:^2.0.0" - collect-v8-coverage: "npm:^1.0.0" - checksum: 10/43cdc31b39857d4d6487345f1bfb9c97157ddfb7ff3e3b843f3999d4a3be5b1e7c1079302459ea627976fa9da7462426dfb26cf231ef2b6eb79bc80b67361c23 - languageName: node - linkType: hard - "@jest/test-result@npm:^29.7.0": version: 29.7.0 resolution: "@jest/test-result@npm:29.7.0" @@ -2448,18 +2295,6 @@ __metadata: languageName: node linkType: hard -"@jest/test-sequencer@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/test-sequencer@npm:27.5.1" - dependencies: - "@jest/test-result": "npm:^27.5.1" - graceful-fs: "npm:^4.2.9" - jest-haste-map: "npm:^27.5.1" - jest-runtime: "npm:^27.5.1" - checksum: 10/74c9c773eb0d8de581e17a7ea1d9173b835c0c91b40665caa42fd68931a2ee7429f9ed59c97a15855d3ad46024a17e7387ad4b900d4540890a7681d4a8a42bdd - languageName: node - linkType: hard - "@jest/test-sequencer@npm:^29.7.0": version: 29.7.0 resolution: "@jest/test-sequencer@npm:29.7.0" @@ -2472,29 +2307,6 @@ __metadata: languageName: node linkType: hard -"@jest/transform@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/transform@npm:27.5.1" - dependencies: - "@babel/core": "npm:^7.1.0" - "@jest/types": "npm:^27.5.1" - babel-plugin-istanbul: "npm:^6.1.1" - chalk: "npm:^4.0.0" - convert-source-map: "npm:^1.4.0" - fast-json-stable-stringify: "npm:^2.0.0" - graceful-fs: "npm:^4.2.9" - jest-haste-map: "npm:^27.5.1" - jest-regex-util: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - micromatch: "npm:^4.0.4" - pirates: "npm:^4.0.4" - slash: "npm:^3.0.0" - source-map: "npm:^0.6.1" - write-file-atomic: "npm:^3.0.0" - checksum: 10/9e0bec99971d28fc205e5e282be384a0269760b8452aa94e3d400465819b6c790c862ec5597d8c9439f2da97e68c0c4cec071340ff3e4c4414a34e5b2a19074a - languageName: node - linkType: hard - "@jest/transform@npm:^29.7.0": version: 29.7.0 resolution: "@jest/transform@npm:29.7.0" @@ -2531,19 +2343,6 @@ __metadata: languageName: node linkType: hard -"@jest/types@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/types@npm:27.5.1" - dependencies: - "@types/istanbul-lib-coverage": "npm:^2.0.0" - "@types/istanbul-reports": "npm:^3.0.0" - "@types/node": "npm:*" - "@types/yargs": "npm:^16.0.0" - chalk: "npm:^4.0.0" - checksum: 10/d3ca1655673539c54665f3e9135dc70887feb6b667b956e712c38f42e513ae007d3593b8075aecea8f2db7119f911773010f17f93be070b1725fbc6225539b6e - languageName: node - linkType: hard - "@jest/types@npm:^29.6.3": version: 29.6.3 resolution: "@jest/types@npm:29.6.3" @@ -3151,21 +2950,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/auto-changelog@npm:^3.4.4": - version: 3.4.4 - resolution: "@metamask/auto-changelog@npm:3.4.4" - dependencies: - diff: "npm:^5.0.0" - execa: "npm:^5.1.1" - prettier: "npm:^2.8.8" - semver: "npm:^7.3.5" - yargs: "npm:^17.0.1" - bin: - auto-changelog: dist/cli.js - checksum: 10/70e98529a153ebeab10410dbc3f567014999f77ed82f2b52f1b36501b28a4e3614c809a90c89600a739d7710595bfecc30e2260410e6afac7539f8db65a48f2c - languageName: node - linkType: hard - "@metamask/auto-changelog@npm:^6.1.0": version: 6.1.0 resolution: "@metamask/auto-changelog@npm:6.1.0" @@ -3189,7 +2973,7 @@ __metadata: languageName: node linkType: hard -"@metamask/base-controller@npm:^9.0.0, @metamask/base-controller@npm:^9.0.1, @metamask/base-controller@npm:^9.1.0, @metamask/base-controller@workspace:packages/base-controller": +"@metamask/base-controller@npm:^9.0.1, @metamask/base-controller@npm:^9.1.0, @metamask/base-controller@workspace:packages/base-controller": version: 0.0.0-use.local resolution: "@metamask/base-controller@workspace:packages/base-controller" dependencies: @@ -5066,20 +4850,20 @@ __metadata: resolution: "@metamask/passkey-controller@workspace:packages/passkey-controller" dependencies: "@levischuck/tiny-cbor": "npm:^0.3.3" - "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^9.0.0" + "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/base-controller": "npm:^9.1.0" "@metamask/messenger": "npm:^1.1.1" "@metamask/utils": "npm:^11.9.0" "@noble/ciphers": "npm:^1.3.0" "@noble/curves": "npm:^1.9.2" "@noble/hashes": "npm:^1.8.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" 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" + jest: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -6706,15 +6490,6 @@ __metadata: languageName: node linkType: hard -"@sinonjs/commons@npm:^1.7.0": - version: 1.8.6 - resolution: "@sinonjs/commons@npm:1.8.6" - dependencies: - type-detect: "npm:4.0.8" - checksum: 10/51987338fd8b4d1e135822ad593dd23a3288764aa41d83c695124d512bc38b87eece859078008651ecc7f1df89a7e558a515dc6f02d21a93be4ba50b39a28914 - languageName: node - linkType: hard - "@sinonjs/commons@npm:^3.0.0": version: 3.0.1 resolution: "@sinonjs/commons@npm:3.0.1" @@ -6733,15 +6508,6 @@ __metadata: languageName: node linkType: hard -"@sinonjs/fake-timers@npm:^8.0.1": - version: 8.1.0 - resolution: "@sinonjs/fake-timers@npm:8.1.0" - dependencies: - "@sinonjs/commons": "npm:^1.7.0" - checksum: 10/da50ddd68411617fcf72d9fb70b621aa2a6d17faa93a2769c7af390c88b40e045f84544db022dd1ac30a6db115d2a0f96473854d4a106b0174351f22d42910ce - languageName: node - linkType: hard - "@solana/addresses@npm:^2.0.0": version: 2.0.0 resolution: "@solana/addresses@npm:2.0.0" @@ -6908,13 +6674,6 @@ __metadata: languageName: node linkType: hard -"@tootallnate/once@npm:1": - version: 1.1.2 - resolution: "@tootallnate/once@npm:1.1.2" - checksum: 10/e1fb1bbbc12089a0cb9433dc290f97bddd062deadb6178ce9bcb93bb7c1aecde5e60184bc7065aec42fe1663622a213493c48bbd4972d931aae48315f18e1be9 - languageName: node - linkType: hard - "@tootallnate/once@npm:2": version: 2.0.0 resolution: "@tootallnate/once@npm:2.0.0" @@ -7021,7 +6780,7 @@ __metadata: languageName: node linkType: hard -"@types/babel__core@npm:^7.0.0, @types/babel__core@npm:^7.1.14": +"@types/babel__core@npm:^7.1.14": version: 7.20.5 resolution: "@types/babel__core@npm:7.20.5" dependencies: @@ -7054,15 +6813,6 @@ __metadata: linkType: hard "@types/babel__traverse@npm:*, @types/babel__traverse@npm:^7.0.6": - version: 7.20.6 - resolution: "@types/babel__traverse@npm:7.20.6" - dependencies: - "@babel/types": "npm:^7.20.7" - checksum: 10/63d13a3789aa1e783b87a8b03d9fb2c2c90078de7782422feff1631b8c2a25db626e63a63ac5a1465d47359201c73069dacb4b52149d17c568187625da3064ae - languageName: node - linkType: hard - -"@types/babel__traverse@npm:^7.0.4": version: 7.28.0 resolution: "@types/babel__traverse@npm:7.28.0" dependencies: @@ -7139,7 +6889,7 @@ __metadata: languageName: node linkType: hard -"@types/graceful-fs@npm:^4.1.2, @types/graceful-fs@npm:^4.1.3": +"@types/graceful-fs@npm:^4.1.3": version: 4.1.9 resolution: "@types/graceful-fs@npm:4.1.9" dependencies: @@ -7192,16 +6942,6 @@ __metadata: languageName: node linkType: hard -"@types/jest@npm:^27.5.2": - version: 27.5.2 - resolution: "@types/jest@npm:27.5.2" - dependencies: - jest-matcher-utils: "npm:^27.0.0" - pretty-format: "npm:^27.0.0" - checksum: 10/8608696fbdea81bc9a600d1c5aeb290063357eaa55c0174e7db15087c4f483113b35f8b4c4ae364d2632cfed15a4dd674786254826b946c896de5612c8cb1a26 - languageName: node - linkType: hard - "@types/jsdom@npm:^20.0.0": version: 20.0.1 resolution: "@types/jsdom@npm:20.0.1" @@ -7318,13 +7058,6 @@ __metadata: languageName: node linkType: hard -"@types/prettier@npm:^2.1.5": - version: 2.7.3 - resolution: "@types/prettier@npm:2.7.3" - checksum: 10/cda84c19acc3bf327545b1ce71114a7d08efbd67b5030b9e8277b347fa57b05178045f70debe1d363ff7efdae62f237260713aafc2d7217e06fc99b048a88497 - languageName: node - linkType: hard - "@types/punycode@npm:^2.1.0": version: 2.1.4 resolution: "@types/punycode@npm:2.1.4" @@ -7420,15 +7153,6 @@ __metadata: languageName: node linkType: hard -"@types/yargs@npm:^16.0.0": - version: 16.0.11 - resolution: "@types/yargs@npm:16.0.11" - dependencies: - "@types/yargs-parser": "npm:*" - checksum: 10/b083eb4e377a9488b67d5767053a3ef531c142478d04b227529db29f5f3ccc98bc555dbe842b47edadd9901cdae03ce0b75828abfd7e70f8db614b5eabf9db9f - languageName: node - linkType: hard - "@types/yargs@npm:^17.0.32, @types/yargs@npm:^17.0.8": version: 17.0.33 resolution: "@types/yargs@npm:17.0.33" @@ -7653,7 +7377,7 @@ __metadata: languageName: node linkType: hard -"abab@npm:^2.0.3, abab@npm:^2.0.5, abab@npm:^2.0.6": +"abab@npm:^2.0.6": version: 2.0.6 resolution: "abab@npm:2.0.6" checksum: 10/ebe95d7278999e605823fc515a3b05d689bc72e7f825536e73c95ebf621636874c6de1b749b3c4bf866b96ccd4b3a2802efa313d0e45ad51a413c8c73247db20 @@ -7701,16 +7425,6 @@ __metadata: languageName: node linkType: hard -"acorn-globals@npm:^6.0.0": - version: 6.0.0 - resolution: "acorn-globals@npm:6.0.0" - dependencies: - acorn: "npm:^7.1.1" - acorn-walk: "npm:^7.1.1" - checksum: 10/72d95e5b5e585f9acd019b993ab8bbba68bb3cbc9d9b5c1ebb3c2f1fe5981f11deababfb4949f48e6262f9c57878837f5958c0cca396f81023814680ca878042 - languageName: node - linkType: hard - "acorn-globals@npm:^7.0.0": version: 7.0.1 resolution: "acorn-globals@npm:7.0.1" @@ -7730,13 +7444,6 @@ __metadata: languageName: node linkType: hard -"acorn-walk@npm:^7.1.1": - version: 7.2.0 - resolution: "acorn-walk@npm:7.2.0" - checksum: 10/4d3e186f729474aed3bc3d0df44692f2010c726582655b20a23347bef650867655521c48ada444cb4fda241ee713dcb792da363ec74c6282fa884fb7144171bb - languageName: node - linkType: hard - "acorn-walk@npm:^8.0.2": version: 8.3.4 resolution: "acorn-walk@npm:8.3.4" @@ -7746,25 +7453,7 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^7.1.1": - version: 7.4.1 - resolution: "acorn@npm:7.4.1" - bin: - acorn: bin/acorn - checksum: 10/8be2a40714756d713dfb62544128adce3b7102c6eb94bc312af196c2cc4af76e5b93079bd66b05e9ca31b35a9b0ce12171d16bc55f366cafdb794fdab9d753ec - languageName: node - linkType: hard - "acorn@npm:^8.1.0, acorn@npm:^8.11.0, acorn@npm:^8.15.0, acorn@npm:^8.8.1": - version: 8.15.0 - resolution: "acorn@npm:8.15.0" - bin: - acorn: bin/acorn - checksum: 10/77f2de5051a631cf1729c090e5759148459cdb76b5f5c70f890503d629cf5052357b0ce783c0f976dd8a93c5150f59f6d18df1def3f502396a20f81282482fa4 - languageName: node - linkType: hard - -"acorn@npm:^8.2.4": version: 8.16.0 resolution: "acorn@npm:8.16.0" bin: @@ -8033,24 +7722,6 @@ __metadata: languageName: node linkType: hard -"babel-jest@npm:^27.5.1": - version: 27.5.1 - resolution: "babel-jest@npm:27.5.1" - dependencies: - "@jest/transform": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" - "@types/babel__core": "npm:^7.1.14" - babel-plugin-istanbul: "npm:^6.1.1" - babel-preset-jest: "npm:^27.5.1" - chalk: "npm:^4.0.0" - graceful-fs: "npm:^4.2.9" - slash: "npm:^3.0.0" - peerDependencies: - "@babel/core": ^7.8.0 - checksum: 10/d032823796072b3c269edaa623dd7fe6ecf2f72aff5b003066e7b16ad0ec4068ed04f3f569237183161d28b638936121975014bcb26ae539e669f2bdad5babe6 - languageName: node - linkType: hard - "babel-jest@npm:^29.7.0": version: 29.7.0 resolution: "babel-jest@npm:29.7.0" @@ -8081,18 +7752,6 @@ __metadata: languageName: node linkType: hard -"babel-plugin-jest-hoist@npm:^27.5.1": - version: 27.5.1 - resolution: "babel-plugin-jest-hoist@npm:27.5.1" - dependencies: - "@babel/template": "npm:^7.3.3" - "@babel/types": "npm:^7.3.3" - "@types/babel__core": "npm:^7.0.0" - "@types/babel__traverse": "npm:^7.0.6" - checksum: 10/9e334903433fd92ef9a65ea5c61f7d786238704b1327d9ca227ef40ef7142fba2bb8219bcb9b2d56eaf36ecfbcc50aa1e177db64508438569e98cfd67cce5043 - languageName: node - linkType: hard - "babel-plugin-jest-hoist@npm:^29.6.3": version: 29.6.3 resolution: "babel-plugin-jest-hoist@npm:29.6.3" @@ -8130,18 +7789,6 @@ __metadata: languageName: node linkType: hard -"babel-preset-jest@npm:^27.5.1": - version: 27.5.1 - resolution: "babel-preset-jest@npm:27.5.1" - dependencies: - babel-plugin-jest-hoist: "npm:^27.5.1" - babel-preset-current-node-syntax: "npm:^1.0.0" - peerDependencies: - "@babel/core": ^7.0.0 - checksum: 10/251bcea11c18fd9672fec104eadb45b43f117ceeb326fa7345ced778d4c1feab29343cd7a87a1dcfae4997d6c851a8b386d7f7213792da6e23b74f4443a8976d - languageName: node - linkType: hard - "babel-preset-jest@npm:^29.6.3": version: 29.6.3 resolution: "babel-preset-jest@npm:29.6.3" @@ -8324,16 +7971,7 @@ __metadata: languageName: node linkType: hard -"brace-expansion@npm:^2.0.1": - version: 2.0.1 - resolution: "brace-expansion@npm:2.0.1" - dependencies: - balanced-match: "npm:^1.0.0" - checksum: 10/a61e7cd2e8a8505e9f0036b3b6108ba5e926b4b55089eeb5550cd04a471fe216c96d4fe7e4c7f995c728c554ae20ddfc4244cad10aef255e72b62930afd233d1 - languageName: node - linkType: hard - -"brace-expansion@npm:^2.0.2": +"brace-expansion@npm:^2.0.1, brace-expansion@npm:^2.0.2": version: 2.1.0 resolution: "brace-expansion@npm:2.1.0" dependencies: @@ -8358,13 +7996,6 @@ __metadata: languageName: node linkType: hard -"browser-process-hrtime@npm:^1.0.0": - version: 1.0.0 - resolution: "browser-process-hrtime@npm:1.0.0" - checksum: 10/e30f868cdb770b1201afb714ad1575dd86366b6e861900884665fb627109b3cc757c40067d3bfee1ff2a29c835257ea30725a8018a9afd02ac1c24b408b1e45f - languageName: node - linkType: hard - "browserify-aes@npm:^1.2.0": version: 1.2.0 resolution: "browserify-aes@npm:1.2.0" @@ -8393,7 +8024,7 @@ __metadata: languageName: node linkType: hard -"bs-logger@npm:0.x, bs-logger@npm:^0.2.6": +"bs-logger@npm:^0.2.6": version: 0.2.6 resolution: "bs-logger@npm:0.2.6" dependencies: @@ -8842,13 +8473,6 @@ __metadata: languageName: node linkType: hard -"convert-source-map@npm:^1.4.0, convert-source-map@npm:^1.6.0": - version: 1.9.0 - resolution: "convert-source-map@npm:1.9.0" - checksum: 10/dc55a1f28ddd0e9485ef13565f8f756b342f9a46c4ae18b843fe3c30c675d058d6a4823eff86d472f187b176f0adf51ea7b69ea38be34be4a63cbbf91b0593c8 - languageName: node - linkType: hard - "convert-source-map@npm:^2.0.0": version: 2.0.0 resolution: "convert-source-map@npm:2.0.0" @@ -8977,13 +8601,6 @@ __metadata: languageName: node linkType: hard -"cssom@npm:^0.4.4": - version: 0.4.4 - resolution: "cssom@npm:0.4.4" - checksum: 10/6302c5f9b33a15f5430349f91553dd370f60707b1f2bb2c21954abe307b701d6095da134679fd0891a7814bc98061e1639bd0562d8f70c2dc529918111be8d2b - languageName: node - linkType: hard - "cssom@npm:^0.5.0": version: 0.5.0 resolution: "cssom@npm:0.5.0" @@ -9007,17 +8624,6 @@ __metadata: languageName: node linkType: hard -"data-urls@npm:^2.0.0": - version: 2.0.0 - resolution: "data-urls@npm:2.0.0" - dependencies: - abab: "npm:^2.0.3" - whatwg-mimetype: "npm:^2.3.0" - whatwg-url: "npm:^8.0.0" - checksum: 10/97caf828aac25e25e04ba6869db0f99c75e6859bb5b424ada28d3e7841941ebf08ddff3c1b1bb4585986bd507a5d54c2a716853ea6cb98af877400e637393e71 - languageName: node - linkType: hard - "data-urls@npm:^3.0.2": version: 3.0.2 resolution: "data-urls@npm:3.0.2" @@ -9083,20 +8689,13 @@ __metadata: languageName: node linkType: hard -"decimal.js@npm:^10.2.1, decimal.js@npm:^10.4.2": +"decimal.js@npm:^10.4.2": version: 10.6.0 resolution: "decimal.js@npm:10.6.0" checksum: 10/c0d45842d47c311d11b38ce7ccc911121953d4df3ebb1465d92b31970eb4f6738a065426a06094af59bee4b0d64e42e7c8984abd57b6767c64ea90cf90bb4a69 languageName: node linkType: hard -"dedent@npm:^0.7.0": - version: 0.7.0 - resolution: "dedent@npm:0.7.0" - checksum: 10/87de191050d9a40dd70cad01159a0bcf05ecb59750951242070b6abf9569088684880d00ba92a955b4058804f16eeaf91d604f283929b4f614d181cd7ae633d2 - languageName: node - linkType: hard - "dedent@npm:^1.0.0": version: 1.7.1 resolution: "dedent@npm:1.7.1" @@ -9261,13 +8860,6 @@ __metadata: languageName: node linkType: hard -"diff-sequences@npm:^27.5.1": - version: 27.5.1 - resolution: "diff-sequences@npm:27.5.1" - checksum: 10/34d852a13eb82735c39944a050613f952038614ce324256e1c3544948fa090f1ca7f329a4f1f57c31fe7ac982c17068d8915b633e300f040b97708c81ceb26cd - languageName: node - linkType: hard - "diff-sequences@npm:^29.6.3": version: 29.6.3 resolution: "diff-sequences@npm:29.6.3" @@ -9300,15 +8892,6 @@ __metadata: languageName: node linkType: hard -"domexception@npm:^2.0.1": - version: 2.0.1 - resolution: "domexception@npm:2.0.1" - dependencies: - webidl-conversions: "npm:^5.0.0" - checksum: 10/d638e9cb05c52999f1b2eb87c374b03311ea5b1d69c2f875bc92da73e17db60c12142b45c950228642ff7f845c536b65305483350d080df59003a653da80b691 - languageName: node - linkType: hard - "domexception@npm:^4.0.0": version: 4.0.0 resolution: "domexception@npm:4.0.0" @@ -9381,13 +8964,6 @@ __metadata: languageName: node linkType: hard -"emittery@npm:^0.8.1": - version: 0.8.1 - resolution: "emittery@npm:0.8.1" - checksum: 10/3b882c0bdc3121b4e92b85315f87da0db8e965766d6c7ff70a8f45e0c38ed49d561936650afa32759d8fb320a458bc9e12631799a0a276e9e8a960ae16c1f6f1 - languageName: node - linkType: hard - "emoji-regex@npm:^8.0.0": version: 8.0.0 resolution: "emoji-regex@npm:8.0.0" @@ -9502,18 +9078,6 @@ __metadata: languageName: node linkType: hard -"es-set-tostringtag@npm:^2.1.0": - version: 2.1.0 - resolution: "es-set-tostringtag@npm:2.1.0" - dependencies: - es-errors: "npm:^1.3.0" - get-intrinsic: "npm:^1.2.6" - has-tostringtag: "npm:^1.0.2" - hasown: "npm:^2.0.2" - checksum: 10/86814bf8afbcd8966653f731415888019d4bc4aca6b6c354132a7a75bb87566751e320369654a101d23a91c87a85c79b178bcf40332839bd347aff437c4fb65f - languageName: node - linkType: hard - "esbuild@npm:~0.25.0": version: 0.25.9 resolution: "esbuild@npm:0.25.9" @@ -10204,18 +9768,6 @@ __metadata: languageName: node linkType: hard -"expect@npm:^27.5.1": - version: 27.5.1 - resolution: "expect@npm:27.5.1" - dependencies: - "@jest/types": "npm:^27.5.1" - jest-get-type: "npm:^27.5.1" - jest-matcher-utils: "npm:^27.5.1" - jest-message-util: "npm:^27.5.1" - checksum: 10/65152be11e791361bb8f74b2516b6ba83021ac4a280b16575340a7dbb72be7fb51b021119a3f40f309a36b375cfb05d4854d5d7af3c53a293a342afc7f86bdaa - languageName: node - linkType: hard - "expect@npm:^29.0.0, expect@npm:^29.7.0": version: 29.7.0 resolution: "expect@npm:29.7.0" @@ -10603,19 +10155,6 @@ __metadata: languageName: node linkType: hard -"form-data@npm:^3.0.0": - version: 3.0.4 - resolution: "form-data@npm:3.0.4" - dependencies: - asynckit: "npm:^0.4.0" - combined-stream: "npm:^1.0.8" - es-set-tostringtag: "npm:^2.1.0" - hasown: "npm:^2.0.2" - mime-types: "npm:^2.1.35" - checksum: 10/68e4598e55cb193ef80304bff4d7513bf81ed4116d57b29c6c9a4c28c6f7ce57d46ddd60ba1a80aadf26703a722551e660bca2acaf9212d8b6e1f2e180d9e668 - languageName: node - linkType: hard - "form-data@npm:^4.0.0": version: 4.0.0 resolution: "form-data@npm:4.0.0" @@ -10736,41 +10275,23 @@ __metadata: linkType: hard "get-intrinsic@npm:^1.2.4": - version: 1.3.0 - resolution: "get-intrinsic@npm:1.3.0" + version: 1.3.1 + resolution: "get-intrinsic@npm:1.3.1" dependencies: + async-function: "npm:^1.0.0" + async-generator-function: "npm:^1.0.0" call-bind-apply-helpers: "npm:^1.0.2" es-define-property: "npm:^1.0.1" es-errors: "npm:^1.3.0" es-object-atoms: "npm:^1.1.1" function-bind: "npm:^1.1.2" + generator-function: "npm:^2.0.0" get-proto: "npm:^1.0.1" gopd: "npm:^1.2.0" has-symbols: "npm:^1.1.0" hasown: "npm:^2.0.2" math-intrinsics: "npm:^1.1.0" - checksum: 10/6e9dd920ff054147b6f44cb98104330e87caafae051b6d37b13384a45ba15e71af33c3baeac7cb630a0aaa23142718dcf25b45cfdd86c184c5dcb4e56d953a10 - languageName: node - linkType: hard - -"get-intrinsic@npm:^1.2.6": - version: 1.3.1 - resolution: "get-intrinsic@npm:1.3.1" - dependencies: - async-function: "npm:^1.0.0" - async-generator-function: "npm:^1.0.0" - call-bind-apply-helpers: "npm:^1.0.2" - es-define-property: "npm:^1.0.1" - es-errors: "npm:^1.3.0" - es-object-atoms: "npm:^1.1.1" - function-bind: "npm:^1.1.2" - generator-function: "npm:^2.0.0" - get-proto: "npm:^1.0.1" - gopd: "npm:^1.2.0" - has-symbols: "npm:^1.1.0" - hasown: "npm:^2.0.2" - math-intrinsics: "npm:^1.1.0" - checksum: 10/bb579dda84caa4a3a41611bdd483dade7f00f246f2a7992eb143c5861155290df3fdb48a8406efa3dfb0b434e2c8fafa4eebd469e409d0439247f85fc3fa2cc1 + checksum: 10/bb579dda84caa4a3a41611bdd483dade7f00f246f2a7992eb143c5861155290df3fdb48a8406efa3dfb0b434e2c8fafa4eebd469e409d0439247f85fc3fa2cc1 languageName: node linkType: hard @@ -10855,7 +10376,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^7.1.1, glob@npm:^7.1.2, glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.7": +"glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.7": version: 7.2.3 resolution: "glob@npm:7.2.3" dependencies: @@ -10976,22 +10497,13 @@ __metadata: languageName: node linkType: hard -"has-symbols@npm:^1.0.3, has-symbols@npm:^1.1.0": +"has-symbols@npm:^1.1.0": version: 1.1.0 resolution: "has-symbols@npm:1.1.0" checksum: 10/959385c98696ebbca51e7534e0dc723ada325efa3475350951363cce216d27373e0259b63edb599f72eb94d6cde8577b4b2375f080b303947e560f85692834fa languageName: node linkType: hard -"has-tostringtag@npm:^1.0.2": - version: 1.0.2 - resolution: "has-tostringtag@npm:1.0.2" - dependencies: - has-symbols: "npm:^1.0.3" - checksum: 10/c74c5f5ceee3c8a5b8bc37719840dc3749f5b0306d818974141dda2471a1a2ca6c8e46b9d6ac222c5345df7a901c9b6f350b1e6d62763fec877e26609a401bfe - languageName: node - linkType: hard - "hash-base@npm:^3.0.0": version: 3.1.0 resolution: "hash-base@npm:3.1.0" @@ -11067,15 +10579,6 @@ __metadata: languageName: node linkType: hard -"html-encoding-sniffer@npm:^2.0.1": - version: 2.0.1 - resolution: "html-encoding-sniffer@npm:2.0.1" - dependencies: - whatwg-encoding: "npm:^1.0.5" - checksum: 10/70365109cad69ee60376715fe0a56dd9ebb081327bf155cda93b2c276976c79cbedee2b988de6b0aefd0671a5d70597a35796e6e7d91feeb2c0aba46df059630 - languageName: node - linkType: hard - "html-encoding-sniffer@npm:^3.0.0": version: 3.0.0 resolution: "html-encoding-sniffer@npm:3.0.0" @@ -11119,17 +10622,6 @@ __metadata: languageName: node linkType: hard -"http-proxy-agent@npm:^4.0.1": - version: 4.0.1 - resolution: "http-proxy-agent@npm:4.0.1" - dependencies: - "@tootallnate/once": "npm:1" - agent-base: "npm:6" - debug: "npm:4" - checksum: 10/2e17f5519f2f2740b236d1d14911ea4be170c67419dc15b05ea9a860a22c5d9c6ff4da270972117067cc2cefeba9df5f7cd5e7818fdc6ae52b6acf2a533e5fdd - languageName: node - linkType: hard - "http-proxy-agent@npm:^5.0.0": version: 5.0.0 resolution: "http-proxy-agent@npm:5.0.0" @@ -11151,7 +10643,7 @@ __metadata: languageName: node linkType: hard -"https-proxy-agent@npm:^5.0.0, https-proxy-agent@npm:^5.0.1": +"https-proxy-agent@npm:^5.0.1": version: 5.0.1 resolution: "https-proxy-agent@npm:5.0.1" dependencies: @@ -11509,13 +11001,6 @@ __metadata: languageName: node linkType: hard -"is-typedarray@npm:^1.0.0": - version: 1.0.0 - resolution: "is-typedarray@npm:1.0.0" - checksum: 10/4b433bfb0f9026f079f4eb3fbaa4ed2de17c9995c3a0b5c800bec40799b4b2a8b4e051b1ada77749deb9ded4ae52fe2096973f3a93ff83df1a5a7184a669478c - languageName: node - linkType: hard - "is-unicode-supported@npm:^0.1.0": version: 0.1.0 resolution: "is-unicode-supported@npm:0.1.0" @@ -11593,7 +11078,7 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-instrument@npm:^5.0.4, istanbul-lib-instrument@npm:^5.1.0": +"istanbul-lib-instrument@npm:^5.0.4": version: 5.2.1 resolution: "istanbul-lib-instrument@npm:5.2.1" dependencies: @@ -11664,17 +11149,6 @@ __metadata: languageName: node linkType: hard -"jest-changed-files@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-changed-files@npm:27.5.1" - dependencies: - "@jest/types": "npm:^27.5.1" - execa: "npm:^5.0.0" - throat: "npm:^6.0.1" - checksum: 10/fad21687f899e527bc23b3cabda1b1fa74acb8e17e81bca4d6ca10ab83ebf1d7555f38ba66dda148f97c45b816f941aa4694a09ed0d16a4d7fe3216abf1a222f - languageName: node - linkType: hard - "jest-changed-files@npm:^29.7.0": version: 29.7.0 resolution: "jest-changed-files@npm:29.7.0" @@ -11686,33 +11160,6 @@ __metadata: languageName: node linkType: hard -"jest-circus@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-circus@npm:27.5.1" - dependencies: - "@jest/environment": "npm:^27.5.1" - "@jest/test-result": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" - "@types/node": "npm:*" - chalk: "npm:^4.0.0" - co: "npm:^4.6.0" - dedent: "npm:^0.7.0" - expect: "npm:^27.5.1" - is-generator-fn: "npm:^2.0.0" - jest-each: "npm:^27.5.1" - jest-matcher-utils: "npm:^27.5.1" - jest-message-util: "npm:^27.5.1" - jest-runtime: "npm:^27.5.1" - jest-snapshot: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - pretty-format: "npm:^27.5.1" - slash: "npm:^3.0.0" - stack-utils: "npm:^2.0.3" - throat: "npm:^6.0.1" - checksum: 10/cf8502d2c7669a89d6d9c309842a6bae1b336335f9a108b0ba3d555dcc635c6cc119d28627a5df455215a8bb04bdcdf18b1fee3441aca39c78c8b10053cd33f7 - languageName: node - linkType: hard - "jest-circus@npm:^29.7.0": version: 29.7.0 resolution: "jest-circus@npm:29.7.0" @@ -11741,33 +11188,6 @@ __metadata: languageName: node linkType: hard -"jest-cli@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-cli@npm:27.5.1" - dependencies: - "@jest/core": "npm:^27.5.1" - "@jest/test-result": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" - chalk: "npm:^4.0.0" - exit: "npm:^0.1.2" - graceful-fs: "npm:^4.2.9" - import-local: "npm:^3.0.2" - jest-config: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - jest-validate: "npm:^27.5.1" - prompts: "npm:^2.0.1" - yargs: "npm:^16.2.0" - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - bin: - jest: bin/jest.js - checksum: 10/527be160786a14f541b3f75e6241da1bd9ba51894fc9f2ba6466dba7f6ffd3a03de02b40d172ad1d29edc725847f7dd4f6dbf71d304d2364b075ec81c9a53224 - languageName: node - linkType: hard - "jest-cli@npm:^29.7.0": version: 29.7.0 resolution: "jest-cli@npm:29.7.0" @@ -11794,43 +11214,6 @@ __metadata: languageName: node linkType: hard -"jest-config@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-config@npm:27.5.1" - dependencies: - "@babel/core": "npm:^7.8.0" - "@jest/test-sequencer": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" - babel-jest: "npm:^27.5.1" - chalk: "npm:^4.0.0" - ci-info: "npm:^3.2.0" - deepmerge: "npm:^4.2.2" - glob: "npm:^7.1.1" - graceful-fs: "npm:^4.2.9" - jest-circus: "npm:^27.5.1" - jest-environment-jsdom: "npm:^27.5.1" - jest-environment-node: "npm:^27.5.1" - jest-get-type: "npm:^27.5.1" - jest-jasmine2: "npm:^27.5.1" - jest-regex-util: "npm:^27.5.1" - jest-resolve: "npm:^27.5.1" - jest-runner: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - jest-validate: "npm:^27.5.1" - micromatch: "npm:^4.0.4" - parse-json: "npm:^5.2.0" - pretty-format: "npm:^27.5.1" - slash: "npm:^3.0.0" - strip-json-comments: "npm:^3.1.1" - peerDependencies: - ts-node: ">=9.0.0" - peerDependenciesMeta: - ts-node: - optional: true - checksum: 10/63bc2dce50289ff921debedab766daa5122129671c77a9f4137d153a27b29ef77725db15d4809553b687c83495cd7ffefc8eadfd8dfa940d7ea878de57f428c2 - languageName: node - linkType: hard - "jest-config@npm:^29.7.0": version: 29.7.0 resolution: "jest-config@npm:29.7.0" @@ -11869,18 +11252,6 @@ __metadata: languageName: node linkType: hard -"jest-diff@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-diff@npm:27.5.1" - dependencies: - chalk: "npm:^4.0.0" - diff-sequences: "npm:^27.5.1" - jest-get-type: "npm:^27.5.1" - pretty-format: "npm:^27.5.1" - checksum: 10/af454f30f33af625832bdb02614e188a41e33ce79086b43f95dbcc515274dd36bf8443b8d0299e22c2416e7591da4321e6bc7f2b0aef56471d1133c6b6833221 - languageName: node - linkType: hard - "jest-diff@npm:^29.0.3, jest-diff@npm:^29.7.0": version: 29.7.0 resolution: "jest-diff@npm:29.7.0" @@ -11893,15 +11264,6 @@ __metadata: languageName: node linkType: hard -"jest-docblock@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-docblock@npm:27.5.1" - dependencies: - detect-newline: "npm:^3.0.0" - checksum: 10/65c765c5418986313685b7c49dcd844cd3bc281807a35f778d6ba479246b6ea070cdd98384582a9aed1a0d3ebf94b7fb14a33df5975aaae2eb20dc00281731f4 - languageName: node - linkType: hard - "jest-docblock@npm:^29.7.0": version: 29.7.0 resolution: "jest-docblock@npm:29.7.0" @@ -11911,19 +11273,6 @@ __metadata: languageName: node linkType: hard -"jest-each@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-each@npm:27.5.1" - dependencies: - "@jest/types": "npm:^27.5.1" - chalk: "npm:^4.0.0" - jest-get-type: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - pretty-format: "npm:^27.5.1" - checksum: 10/d73e3c7bbcd3a073e9fa29bd1f200bb9757cbcc568460c1d0971fc21924800f2d3e421219a85e20c54ea2a0129d2da9e2dfc266b6014244c5901f3ca2de7a99e - languageName: node - linkType: hard - "jest-each@npm:^29.7.0": version: 29.7.0 resolution: "jest-each@npm:29.7.0" @@ -11937,21 +11286,6 @@ __metadata: languageName: node linkType: hard -"jest-environment-jsdom@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-environment-jsdom@npm:27.5.1" - dependencies: - "@jest/environment": "npm:^27.5.1" - "@jest/fake-timers": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" - "@types/node": "npm:*" - jest-mock: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - jsdom: "npm:^16.6.0" - checksum: 10/bc104aef7d7530d0740402aa84ac812138b6d1e51fe58adecce679f82b99340ddab73e5ec68fa079f33f50c9ddec9728fc9f0ddcca2ad6f0b351eed2762cc555 - languageName: node - linkType: hard - "jest-environment-jsdom@npm:^29.7.0": version: 29.7.0 resolution: "jest-environment-jsdom@npm:29.7.0" @@ -11973,20 +11307,6 @@ __metadata: languageName: node linkType: hard -"jest-environment-node@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-environment-node@npm:27.5.1" - dependencies: - "@jest/environment": "npm:^27.5.1" - "@jest/fake-timers": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" - "@types/node": "npm:*" - jest-mock: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - checksum: 10/0f988330c4f3eec092e3fb37ea753b0c6f702e83cd8f4d770af9c2bf964a70bc45fbd34ec6fdb6d71ce98a778d9f54afd673e63f222e4667fff289e8069dba39 - languageName: node - linkType: hard - "jest-environment-node@npm:^29.7.0": version: 29.7.0 resolution: "jest-environment-node@npm:29.7.0" @@ -12001,13 +11321,6 @@ __metadata: languageName: node linkType: hard -"jest-get-type@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-get-type@npm:27.5.1" - checksum: 10/63064ab70195c21007d897c1157bf88ff94a790824a10f8c890392e7d17eda9c3900513cb291ca1c8d5722cad79169764e9a1279f7c8a9c4cd6e9109ff04bbc0 - languageName: node - linkType: hard - "jest-get-type@npm:^29.6.3": version: 29.6.3 resolution: "jest-get-type@npm:29.6.3" @@ -12015,30 +11328,6 @@ __metadata: languageName: node linkType: hard -"jest-haste-map@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-haste-map@npm:27.5.1" - dependencies: - "@jest/types": "npm:^27.5.1" - "@types/graceful-fs": "npm:^4.1.2" - "@types/node": "npm:*" - anymatch: "npm:^3.0.3" - fb-watchman: "npm:^2.0.0" - fsevents: "npm:^2.3.2" - graceful-fs: "npm:^4.2.9" - jest-regex-util: "npm:^27.5.1" - jest-serializer: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - jest-worker: "npm:^27.5.1" - micromatch: "npm:^4.0.4" - walker: "npm:^1.0.7" - dependenciesMeta: - fsevents: - optional: true - checksum: 10/cbf42e4a3d2b6fc8ad64d732c1bb8a230fe25ad3df7f9f93e8af2950691ef9a5241a9d48c5c88e365744a7467b8cb00ab21c01baee4ee0c2b62acc657782545f - languageName: node - linkType: hard - "jest-haste-map@npm:^29.7.0": version: 29.7.0 resolution: "jest-haste-map@npm:29.7.0" @@ -12075,41 +11364,6 @@ __metadata: languageName: node linkType: hard -"jest-jasmine2@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-jasmine2@npm:27.5.1" - dependencies: - "@jest/environment": "npm:^27.5.1" - "@jest/source-map": "npm:^27.5.1" - "@jest/test-result": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" - "@types/node": "npm:*" - chalk: "npm:^4.0.0" - co: "npm:^4.6.0" - expect: "npm:^27.5.1" - is-generator-fn: "npm:^2.0.0" - jest-each: "npm:^27.5.1" - jest-matcher-utils: "npm:^27.5.1" - jest-message-util: "npm:^27.5.1" - jest-runtime: "npm:^27.5.1" - jest-snapshot: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - pretty-format: "npm:^27.5.1" - throat: "npm:^6.0.1" - checksum: 10/052d3c99c36295564a6688ae7e66cfd59997ca9589ccaaa2551d344d84699816a6b8c7bebf3a5f7bcdf691a07f7065c61f4a0770b810e5d887acd21f80a06304 - languageName: node - linkType: hard - -"jest-leak-detector@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-leak-detector@npm:27.5.1" - dependencies: - jest-get-type: "npm:^27.5.1" - pretty-format: "npm:^27.5.1" - checksum: 10/5c9689060960567ddaf16c570d87afa760a461885765d2c71ef4f4857bbc3af1482c34e3cce88e50beefde1bf35e33530b020480752057a7e3dbb1ca0bae359f - languageName: node - linkType: hard - "jest-leak-detector@npm:^29.7.0": version: 29.7.0 resolution: "jest-leak-detector@npm:29.7.0" @@ -12120,18 +11374,6 @@ __metadata: languageName: node linkType: hard -"jest-matcher-utils@npm:^27.0.0, jest-matcher-utils@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-matcher-utils@npm:27.5.1" - dependencies: - chalk: "npm:^4.0.0" - jest-diff: "npm:^27.5.1" - jest-get-type: "npm:^27.5.1" - pretty-format: "npm:^27.5.1" - checksum: 10/037f99878a0515581d7728ed3aed03707810f4da5a1c7ffb9d68a2c6c3180851a6ec40b559af37fbe891dde3ba12552b19e47b8188a27b6c5a53376be6907f32 - languageName: node - linkType: hard - "jest-matcher-utils@npm:^29.7.0": version: 29.7.0 resolution: "jest-matcher-utils@npm:29.7.0" @@ -12144,23 +11386,6 @@ __metadata: languageName: node linkType: hard -"jest-message-util@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-message-util@npm:27.5.1" - dependencies: - "@babel/code-frame": "npm:^7.12.13" - "@jest/types": "npm:^27.5.1" - "@types/stack-utils": "npm:^2.0.0" - chalk: "npm:^4.0.0" - graceful-fs: "npm:^4.2.9" - micromatch: "npm:^4.0.4" - pretty-format: "npm:^27.5.1" - slash: "npm:^3.0.0" - stack-utils: "npm:^2.0.3" - checksum: 10/8fbf39dc25a7ef328dab22efcb3b198cbc788e309bc93e39fdb42b5541dba201c76acf47df476a4ee3d3fc6a6898e77bfc02677c198a98af91db1af0a435ade6 - languageName: node - linkType: hard - "jest-message-util@npm:^29.7.0": version: 29.7.0 resolution: "jest-message-util@npm:29.7.0" @@ -12178,16 +11403,6 @@ __metadata: languageName: node linkType: hard -"jest-mock@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-mock@npm:27.5.1" - dependencies: - "@jest/types": "npm:^27.5.1" - "@types/node": "npm:*" - checksum: 10/be9a8777801659227d3bb85317a3aca617542779a290a6a45c9addec8bda29f494a524cb4af96c82b825ecb02171e320dfbfde3e3d9218672f9e38c9fac118f4 - languageName: node - linkType: hard - "jest-mock@npm:^29.7.0": version: 29.7.0 resolution: "jest-mock@npm:29.7.0" @@ -12211,13 +11426,6 @@ __metadata: languageName: node linkType: hard -"jest-regex-util@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-regex-util@npm:27.5.1" - checksum: 10/d45ca7a9543616a34f7f3079337439cf07566e677a096472baa2810e274b9808b76767c97b0a4029b8a5b82b9d256dee28ef9ad4138b2b9e5933f6fac106c418 - languageName: node - linkType: hard - "jest-regex-util@npm:^29.6.3": version: 29.6.3 resolution: "jest-regex-util@npm:29.6.3" @@ -12225,17 +11433,6 @@ __metadata: languageName: node linkType: hard -"jest-resolve-dependencies@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-resolve-dependencies@npm:27.5.1" - dependencies: - "@jest/types": "npm:^27.5.1" - jest-regex-util: "npm:^27.5.1" - jest-snapshot: "npm:^27.5.1" - checksum: 10/c67af97afad1da88f5530317c732bbd1262d1225f6cd7f4e4740a5db48f90ab0bd8564738ac70d1a43934894f9aef62205c1b8f8ee89e5c7a737e6a121ee4c25 - languageName: node - linkType: hard - "jest-resolve-dependencies@npm:^29.7.0": version: 29.7.0 resolution: "jest-resolve-dependencies@npm:29.7.0" @@ -12246,24 +11443,6 @@ __metadata: languageName: node linkType: hard -"jest-resolve@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-resolve@npm:27.5.1" - dependencies: - "@jest/types": "npm:^27.5.1" - chalk: "npm:^4.0.0" - graceful-fs: "npm:^4.2.9" - jest-haste-map: "npm:^27.5.1" - jest-pnp-resolver: "npm:^1.2.2" - jest-util: "npm:^27.5.1" - jest-validate: "npm:^27.5.1" - resolve: "npm:^1.20.0" - resolve.exports: "npm:^1.1.0" - slash: "npm:^3.0.0" - checksum: 10/93659a9d5ec365a9f2fd3fcaa8f799e3bd090318c48890951ca4325e863f4eb778bb7f7e8d1d8495eda4c157ee771d93fb31f37364ce1a36a09f77f1089e52a1 - languageName: node - linkType: hard - "jest-resolve@npm:^29.7.0": version: 29.7.0 resolution: "jest-resolve@npm:29.7.0" @@ -12281,35 +11460,6 @@ __metadata: languageName: node linkType: hard -"jest-runner@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-runner@npm:27.5.1" - dependencies: - "@jest/console": "npm:^27.5.1" - "@jest/environment": "npm:^27.5.1" - "@jest/test-result": "npm:^27.5.1" - "@jest/transform": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" - "@types/node": "npm:*" - chalk: "npm:^4.0.0" - emittery: "npm:^0.8.1" - graceful-fs: "npm:^4.2.9" - jest-docblock: "npm:^27.5.1" - jest-environment-jsdom: "npm:^27.5.1" - jest-environment-node: "npm:^27.5.1" - jest-haste-map: "npm:^27.5.1" - jest-leak-detector: "npm:^27.5.1" - jest-message-util: "npm:^27.5.1" - jest-resolve: "npm:^27.5.1" - jest-runtime: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - jest-worker: "npm:^27.5.1" - source-map-support: "npm:^0.5.6" - throat: "npm:^6.0.1" - checksum: 10/97bd741f442ebbcebfdb5e8389c0df645448d0b4b634e4128b3387d6fe432cf0f93feb0ecfc3842fed20a35c43c24460ed5dd89d7501ca9e2fdba65e5a4edf37 - languageName: node - linkType: hard - "jest-runner@npm:^29.7.0": version: 29.7.0 resolution: "jest-runner@npm:29.7.0" @@ -12339,36 +11489,6 @@ __metadata: languageName: node linkType: hard -"jest-runtime@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-runtime@npm:27.5.1" - dependencies: - "@jest/environment": "npm:^27.5.1" - "@jest/fake-timers": "npm:^27.5.1" - "@jest/globals": "npm:^27.5.1" - "@jest/source-map": "npm:^27.5.1" - "@jest/test-result": "npm:^27.5.1" - "@jest/transform": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" - chalk: "npm:^4.0.0" - cjs-module-lexer: "npm:^1.0.0" - collect-v8-coverage: "npm:^1.0.0" - execa: "npm:^5.0.0" - glob: "npm:^7.1.3" - graceful-fs: "npm:^4.2.9" - jest-haste-map: "npm:^27.5.1" - jest-message-util: "npm:^27.5.1" - jest-mock: "npm:^27.5.1" - jest-regex-util: "npm:^27.5.1" - jest-resolve: "npm:^27.5.1" - jest-snapshot: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - slash: "npm:^3.0.0" - strip-bom: "npm:^4.0.0" - checksum: 10/cc6cdce5bee4bc02935a4671394e19962f3469eeb6e823442ca99e5670fd87f60ed64b7c7156ac13d2799fc44fe9bb806454a3f17c8342bd35e564b1a40e3920 - languageName: node - linkType: hard - "jest-runtime@npm:^29.7.0": version: 29.7.0 resolution: "jest-runtime@npm:29.7.0" @@ -12399,16 +11519,6 @@ __metadata: languageName: node linkType: hard -"jest-serializer@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-serializer@npm:27.5.1" - dependencies: - "@types/node": "npm:*" - graceful-fs: "npm:^4.2.9" - checksum: 10/803e03a552278610edc6753c0dd9fa5bb5cd3ca47414a7b2918106efb62b79fd5e9ae785d0a21f12a299fa599fea8acc1fa6dd41283328cee43962cf7df9bb44 - languageName: node - linkType: hard - "jest-silent-reporter@npm:^0.5.0": version: 0.5.0 resolution: "jest-silent-reporter@npm:0.5.0" @@ -12419,36 +11529,6 @@ __metadata: languageName: node linkType: hard -"jest-snapshot@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-snapshot@npm:27.5.1" - dependencies: - "@babel/core": "npm:^7.7.2" - "@babel/generator": "npm:^7.7.2" - "@babel/plugin-syntax-typescript": "npm:^7.7.2" - "@babel/traverse": "npm:^7.7.2" - "@babel/types": "npm:^7.0.0" - "@jest/transform": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" - "@types/babel__traverse": "npm:^7.0.4" - "@types/prettier": "npm:^2.1.5" - babel-preset-current-node-syntax: "npm:^1.0.0" - chalk: "npm:^4.0.0" - expect: "npm:^27.5.1" - graceful-fs: "npm:^4.2.9" - jest-diff: "npm:^27.5.1" - jest-get-type: "npm:^27.5.1" - jest-haste-map: "npm:^27.5.1" - jest-matcher-utils: "npm:^27.5.1" - jest-message-util: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - natural-compare: "npm:^1.4.0" - pretty-format: "npm:^27.5.1" - semver: "npm:^7.3.2" - checksum: 10/01b2c70c56980f21fc299fa68a1d1e3a9612f06d2fcdd1cf60f636c3dd427b814efc5f15aacc567e0c3b28fd32129be4a10fca34555f358534fc88e5cee4ffbb - languageName: node - linkType: hard - "jest-snapshot@npm:^29.7.0": version: 29.7.0 resolution: "jest-snapshot@npm:29.7.0" @@ -12491,20 +11571,6 @@ __metadata: languageName: node linkType: hard -"jest-util@npm:^27.0.0, jest-util@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-util@npm:27.5.1" - dependencies: - "@jest/types": "npm:^27.5.1" - "@types/node": "npm:*" - chalk: "npm:^4.0.0" - ci-info: "npm:^3.2.0" - graceful-fs: "npm:^4.2.9" - picomatch: "npm:^2.2.3" - checksum: 10/ecc7da41769558e57dbde544141ffceb536ee53b663de1e002d4b86784cea500a10f9a7f02e8b804e517aa0e34d3145118734c7e8b5071f9f18a153ede5b062d - languageName: node - linkType: hard - "jest-util@npm:^29.7.0": version: 29.7.0 resolution: "jest-util@npm:29.7.0" @@ -12519,20 +11585,6 @@ __metadata: languageName: node linkType: hard -"jest-validate@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-validate@npm:27.5.1" - dependencies: - "@jest/types": "npm:^27.5.1" - camelcase: "npm:^6.2.0" - chalk: "npm:^4.0.0" - jest-get-type: "npm:^27.5.1" - leven: "npm:^3.1.0" - pretty-format: "npm:^27.5.1" - checksum: 10/1fc4d46ecead311a0362bb8ea7767718b682e3d73b65c2bf55cb33722c13bb340e52d20f35d7af38918f8655a78ebbedf3d8a9eaba4ac067883cef006fcf9197 - languageName: node - linkType: hard - "jest-validate@npm:^29.7.0": version: 29.7.0 resolution: "jest-validate@npm:29.7.0" @@ -12547,21 +11599,6 @@ __metadata: languageName: node linkType: hard -"jest-watcher@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-watcher@npm:27.5.1" - dependencies: - "@jest/test-result": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" - "@types/node": "npm:*" - ansi-escapes: "npm:^4.2.1" - chalk: "npm:^4.0.0" - jest-util: "npm:^27.5.1" - string-length: "npm:^4.0.1" - checksum: 10/2c2f6cb4256d5cf90c4ae2d8400d5a40399aea9152c85b8b04c3fe4cbecb65e188462de1267d134a42c69d2ddb13a6e50a8ea1aef809b1e4c8fff7a0019ca2c4 - languageName: node - linkType: hard - "jest-watcher@npm:^29.7.0": version: 29.7.0 resolution: "jest-watcher@npm:29.7.0" @@ -12587,17 +11624,6 @@ __metadata: languageName: node linkType: hard -"jest-worker@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-worker@npm:27.5.1" - dependencies: - "@types/node": "npm:*" - merge-stream: "npm:^2.0.0" - supports-color: "npm:^8.0.0" - checksum: 10/06c6e2a84591d9ede704d5022fc13791e8876e83397c89d481b0063332abbb64c0f01ef4ca7de520b35c7a1058556078d6bdc3631376f4e9ffb42316c1a8488e - languageName: node - linkType: hard - "jest-worker@npm:^29.7.0": version: 29.7.0 resolution: "jest-worker@npm:29.7.0" @@ -12610,24 +11636,6 @@ __metadata: languageName: node linkType: hard -"jest@npm:^27.5.1": - version: 27.5.1 - resolution: "jest@npm:27.5.1" - dependencies: - "@jest/core": "npm:^27.5.1" - import-local: "npm:^3.0.2" - jest-cli: "npm:^27.5.1" - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - bin: - jest: bin/jest.js - checksum: 10/a1435098e1885e48d2a46c660176cd34d69bc80fa72966a1ea8781ab6d5355ee514d45cf871d2da2b5a54509979e53d39fbb9b149c94e430127f44ed0d70639c - languageName: node - linkType: hard - "jest@npm:^29.7.0": version: 29.7.0 resolution: "jest@npm:29.7.0" @@ -12719,46 +11727,6 @@ __metadata: languageName: node linkType: hard -"jsdom@npm:^16.6.0": - version: 16.7.0 - resolution: "jsdom@npm:16.7.0" - dependencies: - abab: "npm:^2.0.5" - acorn: "npm:^8.2.4" - acorn-globals: "npm:^6.0.0" - cssom: "npm:^0.4.4" - cssstyle: "npm:^2.3.0" - data-urls: "npm:^2.0.0" - decimal.js: "npm:^10.2.1" - domexception: "npm:^2.0.1" - escodegen: "npm:^2.0.0" - form-data: "npm:^3.0.0" - html-encoding-sniffer: "npm:^2.0.1" - http-proxy-agent: "npm:^4.0.1" - https-proxy-agent: "npm:^5.0.0" - is-potential-custom-element-name: "npm:^1.0.1" - nwsapi: "npm:^2.2.0" - parse5: "npm:6.0.1" - saxes: "npm:^5.0.1" - symbol-tree: "npm:^3.2.4" - tough-cookie: "npm:^4.0.0" - w3c-hr-time: "npm:^1.0.2" - w3c-xmlserializer: "npm:^2.0.0" - webidl-conversions: "npm:^6.1.0" - whatwg-encoding: "npm:^1.0.5" - whatwg-mimetype: "npm:^2.3.0" - whatwg-url: "npm:^8.5.0" - ws: "npm:^7.4.6" - xml-name-validator: "npm:^3.0.0" - peerDependencies: - canvas: ^2.5.0 - peerDependenciesMeta: - canvas: - optional: true - checksum: 10/c530c04b0e3718769a66e19b0b5c762126658bce384d6743b807a28a9d89beba4ad932e474f570323efe6ce832b3d9a8f94816fd6c4d386416d5ea0b64e07ebc - languageName: node - linkType: hard - "jsdom@npm:^20.0.0": version: 20.0.3 resolution: "jsdom@npm:20.0.3" @@ -12865,7 +11833,7 @@ __metadata: languageName: node linkType: hard -"json5@npm:2.x, json5@npm:^2.2.3": +"json5@npm:^2.2.3": version: 2.2.3 resolution: "json5@npm:2.2.3" bin: @@ -13013,7 +11981,7 @@ __metadata: languageName: node linkType: hard -"lodash.memoize@npm:4.x, lodash.memoize@npm:^4.1.2": +"lodash.memoize@npm:^4.1.2": version: 4.1.2 resolution: "lodash.memoize@npm:4.1.2" checksum: 10/192b2168f310c86f303580b53acf81ab029761b9bd9caa9506a019ffea5f3363ea98d7e39e7e11e6b9917066c9d36a09a11f6fe16f812326390d8f3a54a1a6da @@ -13028,13 +11996,6 @@ __metadata: linkType: hard "lodash@npm:^4.17.21": - version: 4.17.21 - resolution: "lodash@npm:4.17.21" - checksum: 10/c08619c038846ea6ac754abd6dd29d2568aa705feb69339e836dfa8d8b09abbb2f859371e86863eda41848221f9af43714491467b5b0299122431e202bb0c532 - languageName: node - linkType: hard - -"lodash@npm:^4.7.0": version: 4.18.1 resolution: "lodash@npm:4.18.1" checksum: 10/306fea53dfd39dad1f03d45ba654a2405aebd35797b673077f401edb7df2543623dc44b9effbb98f69b32152295fff725a4cec99c684098947430600c6af0c3f @@ -13122,7 +12083,7 @@ __metadata: languageName: node linkType: hard -"make-error@npm:1.x, make-error@npm:^1.3.6": +"make-error@npm:^1.3.6": version: 1.3.6 resolution: "make-error@npm:1.3.6" checksum: 10/b86e5e0e25f7f777b77fabd8e2cbf15737972869d852a22b7e73c17623928fccb826d8e46b9951501d3f20e51ad74ba8c59ed584f610526a48f8ccf88aaec402 @@ -13307,7 +12268,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:^2.1.12, mime-types@npm:^2.1.35, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": +"mime-types@npm:^2.1.12, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: @@ -13378,7 +12339,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^9.0.0": +"minimatch@npm:^9.0.3, minimatch@npm:^9.0.4, minimatch@npm:^9.0.5": version: 9.0.9 resolution: "minimatch@npm:9.0.9" dependencies: @@ -13387,15 +12348,6 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^9.0.3, minimatch@npm:^9.0.4, minimatch@npm:^9.0.5": - version: 9.0.5 - resolution: "minimatch@npm:9.0.5" - dependencies: - brace-expansion: "npm:^2.0.1" - checksum: 10/dd6a8927b063aca6d910b119e1f2df6d2ce7d36eab91de83167dd136bb85e1ebff97b0d3de1cb08bd1f7e018ca170b4962479fefab5b2a69e2ae12cb2edc8348 - languageName: node - linkType: hard - "minimist-options@npm:4.1.0": version: 4.1.0 resolution: "minimist-options@npm:4.1.0" @@ -13819,7 +12771,7 @@ __metadata: languageName: node linkType: hard -"nwsapi@npm:^2.2.0, nwsapi@npm:^2.2.2": +"nwsapi@npm:^2.2.2": version: 2.2.23 resolution: "nwsapi@npm:2.2.23" checksum: 10/aa4a570039c33d70b51436d1bb533f3e2c33c488ccbe9b09285c46a6cee5ef266fd60103461085c6954ba52460786a8138f042958328c7c1b4763898eb3dadfa @@ -14089,13 +13041,6 @@ __metadata: languageName: node linkType: hard -"parse5@npm:6.0.1": - version: 6.0.1 - resolution: "parse5@npm:6.0.1" - checksum: 10/dfb110581f62bd1425725a7c784ae022a24669bd0efc24b58c71fc731c4d868193e2ebd85b74cde2dbb965e4dcf07059b1e651adbec1b3b5267531bd132fdb75 - languageName: node - linkType: hard - "parse5@npm:^7.0.0, parse5@npm:^7.1.1": version: 7.3.0 resolution: "parse5@npm:7.3.0" @@ -14278,7 +13223,7 @@ __metadata: languageName: node linkType: hard -"prettier-2@npm:prettier@^2.8.8, prettier@npm:^2.8.8": +"prettier-2@npm:prettier@^2.8.8": version: 2.8.8 resolution: "prettier@npm:2.8.8" bin: @@ -14305,17 +13250,6 @@ __metadata: languageName: node linkType: hard -"pretty-format@npm:^27.0.0, pretty-format@npm:^27.5.1": - version: 27.5.1 - resolution: "pretty-format@npm:27.5.1" - dependencies: - ansi-regex: "npm:^5.0.1" - ansi-styles: "npm:^5.0.0" - react-is: "npm:^17.0.1" - checksum: 10/248990cbef9e96fb36a3e1ae6b903c551ca4ddd733f8d0912b9cc5141d3d0b3f9f8dfb4d799fb1c6723382c9c2083ffbfa4ad43ff9a0e7535d32d41fd5f01da6 - languageName: node - linkType: hard - "pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0": version: 29.7.0 resolution: "pretty-format@npm:29.7.0" @@ -14522,13 +13456,6 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^17.0.1": - version: 17.0.2 - resolution: "react-is@npm:17.0.2" - checksum: 10/73b36281e58eeb27c9cc6031301b6ae19ecdc9f18ae2d518bdb39b0ac564e65c5779405d623f1df9abf378a13858b79442480244bd579968afc1faf9a2ce5e05 - languageName: node - linkType: hard - "react-is@npm:^18.0.0": version: 18.3.1 resolution: "react-is@npm:18.3.1" @@ -14722,13 +13649,6 @@ __metadata: languageName: node linkType: hard -"resolve.exports@npm:^1.1.0": - version: 1.1.1 - resolution: "resolve.exports@npm:1.1.1" - checksum: 10/de58c30aca30883f0e29910e4ad1b7b9986ec5f69434ef2e957ddbe52d3250e138ddd2688e8cd67909b4ee9bf3437424c718a5962d59edd610f035b861ef8441 - languageName: node - linkType: hard - "resolve.exports@npm:^2.0.0": version: 2.0.3 resolution: "resolve.exports@npm:2.0.3" @@ -14809,17 +13729,6 @@ __metadata: languageName: node linkType: hard -"rimraf@npm:^3.0.0": - version: 3.0.2 - resolution: "rimraf@npm:3.0.2" - dependencies: - glob: "npm:^7.1.3" - bin: - rimraf: bin.js - checksum: 10/063ffaccaaaca2cfd0ef3beafb12d6a03dd7ff1260d752d62a6077b5dfff6ae81bea571f655bb6b589d366930ec1bdd285d40d560c0dae9b12f125e54eb743d5 - languageName: node - linkType: hard - "rimraf@npm:^5.0.5": version: 5.0.10 resolution: "rimraf@npm:5.0.10" @@ -14919,15 +13828,6 @@ __metadata: languageName: node linkType: hard -"saxes@npm:^5.0.1": - version: 5.0.1 - resolution: "saxes@npm:5.0.1" - dependencies: - xmlchars: "npm:^2.2.0" - checksum: 10/148b5f98fdd45df25fa1abef35d72cdf6457ac5aef3b7d59d60f770af09d8cf6e7e3a074197071222441d68670fd3198590aba9985e37c4738af2df2f44d0686 - languageName: node - linkType: hard - "saxes@npm:^6.0.0": version: 6.0.0 resolution: "saxes@npm:6.0.0" @@ -14972,15 +13872,6 @@ __metadata: languageName: node linkType: hard -"semver@npm:7.x, semver@npm:^7.3.2": - version: 7.7.4 - resolution: "semver@npm:7.7.4" - bin: - semver: bin/semver.js - checksum: 10/26bdc6d58b29528f4142d29afb8526bc335f4fc04c4a10f2b98b217f277a031c66736bf82d3d3bb354a2f6a3ae50f18fd62b053c4ac3f294a3d10a61f5075b75 - languageName: node - linkType: hard - "semver@npm:^6.3.0, semver@npm:^6.3.1": version: 6.3.1 resolution: "semver@npm:6.3.1" @@ -14991,11 +13882,11 @@ __metadata: linkType: hard "semver@npm:^7.1.1, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.3, semver@npm:^7.7.3": - version: 7.7.3 - resolution: "semver@npm:7.7.3" + version: 7.7.4 + resolution: "semver@npm:7.7.4" bin: semver: bin/semver.js - checksum: 10/8dbc3168e057a38fc322af909c7f5617483c50caddba135439ff09a754b20bdd6482a5123ff543dad4affa488ecf46ec5fb56d61312ad20bb140199b88dfaea9 + checksum: 10/26bdc6d58b29528f4142d29afb8526bc335f4fc04c4a10f2b98b217f277a031c66736bf82d3d3bb354a2f6a3ae50f18fd62b053c4ac3f294a3d10a61f5075b75 languageName: node linkType: hard @@ -15113,7 +14004,7 @@ __metadata: languageName: node linkType: hard -"shiki@npm:^0.14.1, shiki@npm:^0.14.7": +"shiki@npm:^0.14.7": version: 0.14.7 resolution: "shiki@npm:0.14.7" dependencies: @@ -15137,7 +14028,7 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3, signal-exit@npm:^3.0.7": +"signal-exit@npm:^3.0.3, signal-exit@npm:^3.0.7": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" checksum: 10/a2f098f247adc367dffc27845853e9959b9e88b01cb301658cfe4194352d8d2bb32e18467c786a7fe15f1d44b233ea35633d076d5e737870b7139949d1ab6318 @@ -15247,16 +14138,6 @@ __metadata: languageName: node linkType: hard -"source-map-support@npm:^0.5.6": - version: 0.5.21 - resolution: "source-map-support@npm:0.5.21" - dependencies: - buffer-from: "npm:^1.0.0" - source-map: "npm:^0.6.0" - checksum: 10/8317e12d84019b31e34b86d483dd41d6f832f389f7417faf8fc5c75a66a12d9686e47f589a0554a868b8482f037e23df9d040d29387eb16fa14cb85f091ba207 - languageName: node - linkType: hard - "source-map@npm:^0.6.0, source-map@npm:^0.6.1, source-map@npm:~0.6.1": version: 0.6.1 resolution: "source-map@npm:0.6.1" @@ -15264,13 +14145,6 @@ __metadata: languageName: node linkType: hard -"source-map@npm:^0.7.3": - version: 0.7.6 - resolution: "source-map@npm:0.7.6" - checksum: 10/c8d2da7c57c14f3fd7568f764b39ad49bbf9dd7632b86df3542b31fed117d4af2fb74a4f886fc06baf7a510fee68e37998efc3080aacdac951c36211dc29a7a3 - languageName: node - linkType: hard - "spdx-correct@npm:^3.0.0": version: 3.2.0 resolution: "spdx-correct@npm:3.2.0" @@ -15595,16 +14469,6 @@ __metadata: languageName: node linkType: hard -"terminal-link@npm:^2.0.0": - version: 2.1.1 - resolution: "terminal-link@npm:2.1.1" - dependencies: - ansi-escapes: "npm:^4.2.1" - supports-hyperlinks: "npm:^2.0.0" - checksum: 10/ce3d2cd3a438c4a9453947aa664581519173ea40e77e2534d08c088ee6dda449eabdbe0a76d2a516b8b73c33262fedd10d5270ccf7576ae316e3db170ce6562f - languageName: node - linkType: hard - "test-exclude@npm:^6.0.0": version: 6.0.0 resolution: "test-exclude@npm:6.0.0" @@ -15625,13 +14489,6 @@ __metadata: languageName: node linkType: hard -"throat@npm:^6.0.1": - version: 6.0.2 - resolution: "throat@npm:6.0.2" - checksum: 10/acd99f4b7362bcf6dcc517b01517165a00f7270d0c4fe2ca06c73b6217f022f76fb20e8ca98283b25ccb85d97a5f96dbcac5577d60bb0bda1eff92fa8e79fbd7 - languageName: node - linkType: hard - "tinyglobby@npm:^0.2.15": version: 0.2.15 resolution: "tinyglobby@npm:0.2.15" @@ -15672,7 +14529,7 @@ __metadata: languageName: node linkType: hard -"tough-cookie@npm:^4.0.0, tough-cookie@npm:^4.1.2": +"tough-cookie@npm:^4.1.2": version: 4.1.4 resolution: "tough-cookie@npm:4.1.4" dependencies: @@ -15684,15 +14541,6 @@ __metadata: languageName: node linkType: hard -"tr46@npm:^2.1.0": - version: 2.1.0 - resolution: "tr46@npm:2.1.0" - dependencies: - punycode: "npm:^2.1.1" - checksum: 10/302b13f458da713b2a6ff779a0c1d27361d369fdca6c19330536d31db61789b06b246968fc879fdac818a92d02643dca1a0f4da5618df86aea4a79fb3243d3f3 - languageName: node - linkType: hard - "tr46@npm:^3.0.0": version: 3.0.0 resolution: "tr46@npm:3.0.0" @@ -15725,39 +14573,6 @@ __metadata: languageName: node linkType: hard -"ts-jest@npm:^27.1.5": - version: 27.1.5 - resolution: "ts-jest@npm:27.1.5" - dependencies: - bs-logger: "npm:0.x" - fast-json-stable-stringify: "npm:2.x" - jest-util: "npm:^27.0.0" - json5: "npm:2.x" - lodash.memoize: "npm:4.x" - make-error: "npm:1.x" - semver: "npm:7.x" - yargs-parser: "npm:20.x" - peerDependencies: - "@babel/core": ">=7.0.0-beta.0 <8" - "@types/jest": ^27.0.0 - babel-jest: ">=27.0.0 <28" - jest: ^27.0.0 - typescript: ">=3.8 <5.0" - peerDependenciesMeta: - "@babel/core": - optional: true - "@types/jest": - optional: true - babel-jest: - optional: true - esbuild: - optional: true - bin: - ts-jest: cli.js - checksum: 10/7675946cefc8c652ec35f2fd600ffb99c8e5db5ac355ceb8317707862c586ee46f7ddd589bd206fa8be2598bc4a87c5a53eb4198af78723f5661c90e32200ba3 - languageName: node - linkType: hard - "ts-jest@npm:^29.2.5": version: 29.4.6 resolution: "ts-jest@npm:29.4.6" @@ -15913,15 +14728,6 @@ __metadata: languageName: node linkType: hard -"typedarray-to-buffer@npm:^3.1.5": - version: 3.1.5 - resolution: "typedarray-to-buffer@npm:3.1.5" - dependencies: - is-typedarray: "npm:^1.0.0" - checksum: 10/7c850c3433fbdf4d04f04edfc751743b8f577828b8e1eb93b95a3bce782d156e267d83e20fb32b3b47813e69a69ab5e9b5342653332f7d21c7d1210661a7a72c - languageName: node - linkType: hard - "typedarray@npm:^0.0.6": version: 0.0.6 resolution: "typedarray@npm:0.0.6" @@ -15938,22 +14744,6 @@ __metadata: languageName: node linkType: hard -"typedoc@npm:^0.24.8": - version: 0.24.8 - resolution: "typedoc@npm:0.24.8" - dependencies: - lunr: "npm:^2.3.9" - marked: "npm:^4.3.0" - minimatch: "npm:^9.0.0" - shiki: "npm:^0.14.1" - peerDependencies: - typescript: 4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x - bin: - typedoc: bin/typedoc - checksum: 10/4f2f92ddde3f70a1a9666507f6bdf6620023599bd2c2a3ed3f8f909f9c28d92594c30ee6ee68f5a248ff70e09623acf1323bad633cb713b9f2e36bbd4fccf683 - languageName: node - linkType: hard - "typedoc@npm:^0.25.13": version: 0.25.13 resolution: "typedoc@npm:0.25.13" @@ -16170,17 +14960,6 @@ __metadata: languageName: node linkType: hard -"v8-to-istanbul@npm:^8.1.0": - version: 8.1.1 - resolution: "v8-to-istanbul@npm:8.1.1" - dependencies: - "@types/istanbul-lib-coverage": "npm:^2.0.1" - convert-source-map: "npm:^1.6.0" - source-map: "npm:^0.7.3" - checksum: 10/db5469f133a7cfb7680a28ddfb31aad2cc64f282fa7cf0c8e91f91bfd542bf61597260282be28c9648f0f2114963a24b273ed92af9a5cad6cb629c708ca72f8e - languageName: node - linkType: hard - "v8-to-istanbul@npm:^9.0.1": version: 9.3.0 resolution: "v8-to-istanbul@npm:9.3.0" @@ -16270,24 +15049,6 @@ __metadata: languageName: node linkType: hard -"w3c-hr-time@npm:^1.0.2": - version: 1.0.2 - resolution: "w3c-hr-time@npm:1.0.2" - dependencies: - browser-process-hrtime: "npm:^1.0.0" - checksum: 10/03851d90c236837c24c2983f5a8806a837c6515b21d52e5f29776b07cc08695779303d481454d768308489f00dd9d3232d595acaa5b2686d199465a4d9f7b283 - languageName: node - linkType: hard - -"w3c-xmlserializer@npm:^2.0.0": - version: 2.0.0 - resolution: "w3c-xmlserializer@npm:2.0.0" - dependencies: - xml-name-validator: "npm:^3.0.0" - checksum: 10/400c18b75ce6af269168f964e7d1eb196a7422e134032906540c69d83b802f38dc64e18fc259c02966a334687483f416398d2ad7ebe9d19ab434a7a0247c71c3 - languageName: node - linkType: hard - "w3c-xmlserializer@npm:^4.0.0": version: 4.0.0 resolution: "w3c-xmlserializer@npm:4.0.0" @@ -16297,7 +15058,7 @@ __metadata: languageName: node linkType: hard -"walker@npm:^1.0.7, walker@npm:^1.0.8": +"walker@npm:^1.0.8": version: 1.0.8 resolution: "walker@npm:1.0.8" dependencies: @@ -16336,20 +15097,6 @@ __metadata: languageName: node linkType: hard -"webidl-conversions@npm:^5.0.0": - version: 5.0.0 - resolution: "webidl-conversions@npm:5.0.0" - checksum: 10/cea864dd9cf1f2133d82169a446fb94427ba089e4676f5895273ea085f165649afe587ae3f19f2f0370751a724bba2d96e9956d652b3e41ac1feaaa4376e2d70 - languageName: node - linkType: hard - -"webidl-conversions@npm:^6.1.0": - version: 6.1.0 - resolution: "webidl-conversions@npm:6.1.0" - checksum: 10/4454b73060a6d83f7ec1f1db24c480b7ecda33880306dd32a3d62d85b36df4789a383489f1248387e5451737dca17054b8cbf2e792ba89e49d76247f0f4f6380 - languageName: node - linkType: hard - "webidl-conversions@npm:^7.0.0": version: 7.0.0 resolution: "webidl-conversions@npm:7.0.0" @@ -16375,15 +15122,6 @@ __metadata: languageName: node linkType: hard -"whatwg-encoding@npm:^1.0.5": - version: 1.0.5 - resolution: "whatwg-encoding@npm:1.0.5" - dependencies: - iconv-lite: "npm:0.4.24" - checksum: 10/5be4efe111dce29ddee3448d3915477fcc3b28f991d9cf1300b4e50d6d189010d47bca2f51140a844cf9b726e8f066f4aee72a04d687bfe4f2ee2767b2f5b1e6 - languageName: node - linkType: hard - "whatwg-encoding@npm:^2.0.0": version: 2.0.0 resolution: "whatwg-encoding@npm:2.0.0" @@ -16400,13 +15138,6 @@ __metadata: languageName: node linkType: hard -"whatwg-mimetype@npm:^2.3.0": - version: 2.3.0 - resolution: "whatwg-mimetype@npm:2.3.0" - checksum: 10/3582c1d74d708716013433bbab45cb9b31ef52d276adfbe2205d948be1ec9bb1a4ac05ce6d9045f3acc4104489e1344c857b14700002385a4b997a5673ff6416 - languageName: node - linkType: hard - "whatwg-mimetype@npm:^3.0.0": version: 3.0.0 resolution: "whatwg-mimetype@npm:3.0.0" @@ -16434,17 +15165,6 @@ __metadata: languageName: node linkType: hard -"whatwg-url@npm:^8.0.0, whatwg-url@npm:^8.5.0": - version: 8.7.0 - resolution: "whatwg-url@npm:8.7.0" - dependencies: - lodash: "npm:^4.7.0" - tr46: "npm:^2.1.0" - webidl-conversions: "npm:^6.1.0" - checksum: 10/512a8b2703dffbf13a9a247bf2fb27c3048a3ceb5ece09f88b737c8260afaba4b2f6775c2f1cfc29c2ba4859f2454a9de73fac08e239b00ae2b42cd6b8bb0d35 - languageName: node - linkType: hard - "which@npm:^1.2.14": version: 1.3.1 resolution: "which@npm:1.3.1" @@ -16539,18 +15259,6 @@ __metadata: languageName: node linkType: hard -"write-file-atomic@npm:^3.0.0": - version: 3.0.3 - resolution: "write-file-atomic@npm:3.0.3" - dependencies: - imurmurhash: "npm:^0.1.4" - is-typedarray: "npm:^1.0.0" - signal-exit: "npm:^3.0.2" - typedarray-to-buffer: "npm:^3.1.5" - checksum: 10/0955ab94308b74d32bc252afe69d8b42ba4b8a28b8d79f399f3f405969f82623f981e35d13129a52aa2973450f342107c06d86047572637584e85a1c0c246bf3 - languageName: node - linkType: hard - "write-file-atomic@npm:^4.0.2": version: 4.0.2 resolution: "write-file-atomic@npm:4.0.2" @@ -16616,21 +15324,6 @@ __metadata: languageName: node linkType: hard -"ws@npm:^7.4.6": - version: 7.5.10 - resolution: "ws@npm:7.5.10" - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ^5.0.2 - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - checksum: 10/9c796b84ba80ffc2c2adcdfc9c8e9a219ba99caa435c9a8d45f9ac593bba325563b3f83edc5eb067cc6d21b9a6bf2c930adf76dd40af5f58a5ca6859e81858f0 - languageName: node - linkType: hard - "ws@npm:^8.11.0, ws@npm:^8.18.3": version: 8.19.0 resolution: "ws@npm:8.19.0" @@ -16653,13 +15346,6 @@ __metadata: languageName: node linkType: hard -"xml-name-validator@npm:^3.0.0": - version: 3.0.0 - resolution: "xml-name-validator@npm:3.0.0" - checksum: 10/24f5d38c777ad9239dfe99c4ca3cd155415b65ac583785d1514e04b9f86d6d09eaff983ed373e7a779ceefd1fca0fd893f2fc264999e9aeaac36b6e1afc397ed - languageName: node - linkType: hard - "xml-name-validator@npm:^4.0.0": version: 4.0.0 resolution: "xml-name-validator@npm:4.0.0" @@ -16725,7 +15411,7 @@ __metadata: languageName: node linkType: hard -"yargs-parser@npm:20.x, yargs-parser@npm:^20.2.2, yargs-parser@npm:^20.2.3": +"yargs-parser@npm:^20.2.2, yargs-parser@npm:^20.2.3": version: 20.2.9 resolution: "yargs-parser@npm:20.2.9" checksum: 10/0188f430a0f496551d09df6719a9132a3469e47fe2747208b1dd0ab2bb0c512a95d0b081628bbca5400fb20dbf2fabe63d22badb346cecadffdd948b049f3fcc From c1086a02f5105c3d27b4eed4fb64e3509981f426 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Tue, 21 Apr 2026 13:35:52 +0700 Subject: [PATCH 24/44] fix: lint:msc --- packages/passkey-controller/README.md | 20 +++++++++---------- packages/passkey-controller/package.json | 20 +++++++++---------- .../src/PasskeyController.ts | 2 +- .../src/webauthn/decode-client-data-json.ts | 2 +- .../verify-authentication-response.ts | 6 +++--- .../webauthn/verify-registration-response.ts | 14 ++++++------- .../src/webauthn/verify-signature.ts | 2 +- .../src/webauthn/webauthn.test.ts | 2 +- .../passkey-controller/tsconfig.build.json | 5 +---- packages/passkey-controller/tsconfig.json | 5 +---- 10 files changed, 36 insertions(+), 42 deletions(-) diff --git a/packages/passkey-controller/README.md b/packages/passkey-controller/README.md index 989b997a3c6..11c95864500 100644 --- a/packages/passkey-controller/README.md +++ b/packages/passkey-controller/README.md @@ -21,10 +21,10 @@ The controller follows a two-phase ceremony pattern for both enrollment and auth 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 | +| 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. @@ -126,14 +126,14 @@ error and rethrows any other thrown value. ### State -| Property | Type | Description | -|---|---|---| +| Property | Type | Description | +| --------------- | ----------------------- | --------------------------------------------------------------------------------------------- | | `passkeyRecord` | `PasskeyRecord \| null` | Enrolled passkey credential data and encrypted vault key. `null` when no passkey is enrolled. | ### Messenger actions -| Action | Handler | -|---|---| +| Action | Handler | +| ---------------------------- | ------------------------------------ | | `PasskeyController:getState` | Returns the current controller state | For derived enrollment status outside of components that hold a controller @@ -142,8 +142,8 @@ reference, use `passkeyControllerSelectors.selectIsPasskeyEnrolled` (see ### Messenger events -| Event | Payload | -|---|---| +| Event | Payload | +| -------------------------------- | ------------------------------------------------------------ | | `PasskeyController:stateChanged` | Emitted when state changes (standard `BaseController` event) | ## Contributing diff --git a/packages/passkey-controller/package.json b/packages/passkey-controller/package.json index c7fcb697a5b..931a45cd83a 100644 --- a/packages/passkey-controller/package.json +++ b/packages/passkey-controller/package.json @@ -10,12 +10,17 @@ "bugs": { "url": "https://github.com/MetaMask/core/issues" }, + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/MetaMask/core.git" }, - "license": "MIT", + "files": [ + "dist/" + ], "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", "exports": { ".": { "import": { @@ -29,11 +34,10 @@ }, "./package.json": "./package.json" }, - "main": "./dist/index.cjs", - "types": "./dist/index.d.cts", - "files": [ - "dist/" - ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, "scripts": { "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", @@ -70,9 +74,5 @@ }, "engines": { "node": "^18.18 || >=20" - }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" } } diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index f8f42fbdc59..e80c6a73f99 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -7,8 +7,8 @@ import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; import { randomBytes } from '@noble/ciphers/webcrypto'; -import { PasskeyAuthenticationRejectedError } from './errors'; import { WEBAUTHN_TIMEOUT_MS, CeremonyManager } from './ceremony-manager'; +import { PasskeyAuthenticationRejectedError } from './errors'; import { deriveKeyFromAuthenticationResponse, deriveKeyFromRegistrationResponse, diff --git a/packages/passkey-controller/src/webauthn/decode-client-data-json.ts b/packages/passkey-controller/src/webauthn/decode-client-data-json.ts index a94504177aa..e3881b30331 100644 --- a/packages/passkey-controller/src/webauthn/decode-client-data-json.ts +++ b/packages/passkey-controller/src/webauthn/decode-client-data-json.ts @@ -1,5 +1,5 @@ -import type { ClientDataJSON } from './types'; import { base64URLToBytes } from '../utils/encoding'; +import type { ClientDataJSON } from './types'; /** * Decode an authenticator's base64url-encoded clientDataJSON to JSON. diff --git a/packages/passkey-controller/src/webauthn/verify-authentication-response.ts b/packages/passkey-controller/src/webauthn/verify-authentication-response.ts index 440f09e34fb..05d6e577d10 100644 --- a/packages/passkey-controller/src/webauthn/verify-authentication-response.ts +++ b/packages/passkey-controller/src/webauthn/verify-authentication-response.ts @@ -1,15 +1,15 @@ import { decodePartialCBOR } from '@levischuck/tiny-cbor'; import { sha256 } from '@noble/hashes/sha2'; +import type { AuthenticatorTransportFuture } from '../types'; +import { concatUint8Arrays } from '../utils/bytes'; +import { base64URLToBytes } from '../utils/encoding'; import { decodeClientDataJSON } from './decode-client-data-json'; import { matchExpectedRPID } from './match-expected-rp-id'; import { parseAuthenticatorData } from './parse-authenticator-data'; import type { ParsedAuthenticatorData } from './types'; import type { PasskeyAuthenticationResponse } from './types'; import { verifySignature } from './verify-signature'; -import type { AuthenticatorTransportFuture } from '../types'; -import { concatUint8Arrays } from '../utils/bytes'; -import { base64URLToBytes } from '../utils/encoding'; export type VerifiedAuthenticationResponse = | { verified: false; authenticationInfo?: never } diff --git a/packages/passkey-controller/src/webauthn/verify-registration-response.ts b/packages/passkey-controller/src/webauthn/verify-registration-response.ts index 7b9100bf80b..d070055f0f1 100644 --- a/packages/passkey-controller/src/webauthn/verify-registration-response.ts +++ b/packages/passkey-controller/src/webauthn/verify-registration-response.ts @@ -1,13 +1,6 @@ import { decodePartialCBOR } from '@levischuck/tiny-cbor'; import { sha256 } from '@noble/hashes/sha2'; -import { COSEALG, COSEKEYS } from './constants'; -import { decodeAttestationObject } from './decode-attestation-object'; -import { decodeClientDataJSON } from './decode-client-data-json'; -import { matchExpectedRPID } from './match-expected-rp-id'; -import { parseAuthenticatorData } from './parse-authenticator-data'; -import type { PasskeyRegistrationResponse } from './types'; -import { verifySignature } from './verify-signature'; import type { AuthenticatorTransportFuture } from '../types'; import { concatUint8Arrays } from '../utils/bytes'; import { @@ -15,6 +8,13 @@ import { bytesToBase64URL, bytesToHex, } from '../utils/encoding'; +import { COSEALG, COSEKEYS } from './constants'; +import { decodeAttestationObject } from './decode-attestation-object'; +import { decodeClientDataJSON } from './decode-client-data-json'; +import { matchExpectedRPID } from './match-expected-rp-id'; +import { parseAuthenticatorData } from './parse-authenticator-data'; +import type { PasskeyRegistrationResponse } from './types'; +import { verifySignature } from './verify-signature'; export type VerifiedRegistrationResponse = | { verified: false; registrationInfo?: never } diff --git a/packages/passkey-controller/src/webauthn/verify-signature.ts b/packages/passkey-controller/src/webauthn/verify-signature.ts index 3bdba37a08c..93154767ec4 100644 --- a/packages/passkey-controller/src/webauthn/verify-signature.ts +++ b/packages/passkey-controller/src/webauthn/verify-signature.ts @@ -2,9 +2,9 @@ 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 { concatUint8Arrays } from '../utils/bytes'; import { bytesToBase64URL } from '../utils/encoding'; +import { COSEALG, COSECRV, COSEKEYS, COSEKTY } from './constants'; type COSEPublicKey = Map; diff --git a/packages/passkey-controller/src/webauthn/webauthn.test.ts b/packages/passkey-controller/src/webauthn/webauthn.test.ts index 050463e1e49..b8202d80764 100644 --- a/packages/passkey-controller/src/webauthn/webauthn.test.ts +++ b/packages/passkey-controller/src/webauthn/webauthn.test.ts @@ -4,6 +4,7 @@ import { p256 } from '@noble/curves/p256'; import { p384 } from '@noble/curves/p384'; import { sha256, sha384 } from '@noble/hashes/sha2'; +import { bytesToBase64URL } from '../utils/encoding'; import { COSEALG, COSEKEYS, COSEKTY, COSECRV } from './constants'; import type { PasskeyRegistrationResponse, @@ -11,7 +12,6 @@ import type { } from './types'; import { verifyAuthenticationResponse } from './verify-authentication-response'; import { verifyRegistrationResponse } from './verify-registration-response'; -import { bytesToBase64URL } from '../utils/encoding'; // --------------------------------------------------------------------------- // Test Helpers diff --git a/packages/passkey-controller/tsconfig.build.json b/packages/passkey-controller/tsconfig.build.json index bf5cd863599..249f327913d 100644 --- a/packages/passkey-controller/tsconfig.build.json +++ b/packages/passkey-controller/tsconfig.build.json @@ -13,8 +13,5 @@ "path": "../messenger/tsconfig.build.json" } ], - "include": [ - "../../types", - "./src" - ] + "include": ["../../types", "./src"] } diff --git a/packages/passkey-controller/tsconfig.json b/packages/passkey-controller/tsconfig.json index 265a56cb300..cb296895b28 100644 --- a/packages/passkey-controller/tsconfig.json +++ b/packages/passkey-controller/tsconfig.json @@ -11,8 +11,5 @@ "path": "../messenger" } ], - "include": [ - "../../types", - "./src" - ] + "include": ["../../types", "./src"] } From c8db2c81cb2fcc9807e3b520cc2512a7547c5cee Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Tue, 21 Apr 2026 13:50:37 +0700 Subject: [PATCH 25/44] refactor: revert changes in seedless onboarding --- .../CHANGELOG.md | 172 +----------------- 1 file changed, 3 insertions(+), 169 deletions(-) diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index 09f9efb94b5..6454e07a420 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -7,173 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Uncategorized - -- fix: CI on README and deps -- fix: refactor to follow controller guidelines -- fix: address review comments -- chore: update deps -- Merge branch 'main' into feat/TO-540-passkey-controller -- fix: address review comments -- refactor: restructure passkey record state -- feat: added authenticated user storage ([#8260](https://github.com/MetaMask/core/pull/8260)) -- add money account upgrade controller ([#8426](https://github.com/MetaMask/core/pull/8426)) -- Release 927.0.0 ([#8525](https://github.com/MetaMask/core/pull/8525)) -- Release/926.0.0 ([#8523](https://github.com/MetaMask/core/pull/8523)) -- feat: normalize bridge hardware type ([#8503](https://github.com/MetaMask/core/pull/8503)) -- feat(social-controllers): source profileId from JWT on follow/unfollow/updateFollowing ([#8520](https://github.com/MetaMask/core/pull/8520)) -- feat(card): add cardDelegation transaction type ([#8508](https://github.com/MetaMask/core/pull/8508)) -- fix(transaction-pay-controller): proxy Across status polling via configured api base ([#8512](https://github.com/MetaMask/core/pull/8512)) -- feat: skip verification of passkey authentication response when renewing vault key protection -- feat: add hints to passkey registration and authentication options -- feat: allow verifying passkey authentication -- chore: refactor passkey controller -- refactor: use lowercase file names for webauthn module -- feat: allow multiple passkey ceremony at the same time -- Release/925.0.0 ([#8516](https://github.com/MetaMask/core/pull/8516)) -- feat(perps-controller): sync controller from mobile c2248a8a9e ([#8515](https://github.com/MetaMask/core/pull/8515)) -- Release/924.0.0 ([#8514](https://github.com/MetaMask/core/pull/8514)) -- Release/923.0.0 ([#8509](https://github.com/MetaMask/core/pull/8509)) -- feat: add chomp API service ([#8413](https://github.com/MetaMask/core/pull/8413)) -- Release 922.0.0 ([#8507](https://github.com/MetaMask/core/pull/8507)) -- fix(AccountTrackerController): prevent balance wipe during account sync ([#8505](https://github.com/MetaMask/core/pull/8505)) -- feat: Expose `createPermissionMiddleware` via messenger ([#8502](https://github.com/MetaMask/core/pull/8502)) -- fix(transaction-pay-controller): stop adding subsidized fee to target amount ([#8488](https://github.com/MetaMask/core/pull/8488)) -- Release/921.0.0 ([#8495](https://github.com/MetaMask/core/pull/8495)) -- chore: Enable Oxfmt for generating method action types files ([#8498](https://github.com/MetaMask/core/pull/8498)) -- ci: Run some jobs on Node.js 24 ([#8500](https://github.com/MetaMask/core/pull/8500)) -- feat!: Replace ESLint with direct call to Prettier or Oxfmt in messenger CLI ([#8486](https://github.com/MetaMask/core/pull/8486)) -- feat: allow clearing state in passkey controller -- fix(transaction-pay-controller): resolve correct networkClientId for source chain in relay execute ([#8492](https://github.com/MetaMask/core/pull/8492)) -- Release/920.0.0 ([#8494](https://github.com/MetaMask/core/pull/8494)) -- chore: bump `accounts` deps ([#8464](https://github.com/MetaMask/core/pull/8464)) -- feat: adds auth to social controllers ([#8485](https://github.com/MetaMask/core/pull/8485)) -- Release/919.0.0 ([#8482](https://github.com/MetaMask/core/pull/8482)) -- chore: refactor docs -- feat: use rp ID and name from platform -- feat: accept prf availability from browser when generating registration options -- Release/918.0.0 ([#8478](https://github.com/MetaMask/core/pull/8478)) -- chore: add periodic check for spl tokens ([#8400](https://github.com/MetaMask/core/pull/8400)) -- feat: add passkey verification and refactor code -- feat: added money-account-balance-service to root tsconfig files ([#8477](https://github.com/MetaMask/core/pull/8477)) -- Release 917.0.0 ([#8476](https://github.com/MetaMask/core/pull/8476)) -- Release/916.0.0 ([#8474](https://github.com/MetaMask/core/pull/8474)) -- Deprecate AbstractRpcService and RpcServiceRequestable ([#8475](https://github.com/MetaMask/core/pull/8475)) -- chore: Format changelogs with Oxfmt ([#8442](https://github.com/MetaMask/core/pull/8442)) -- fix(perps-controller): restore MYX webpackIgnore workaround ([#8473](https://github.com/MetaMask/core/pull/8473)) -- feat: implement no-retry policy for TransakService verify OTP ([#8468](https://github.com/MetaMask/core/pull/8468)) -- feat: port verification of authentication and registration responses from simplewebauthn -- fix: improve assets controller snap accounts subscription and websocket ([#8430](https://github.com/MetaMask/core/pull/8430)) -- Release/915.0.0 ([#8472](https://github.com/MetaMask/core/pull/8472)) -- Release/914.0.0 ([#8470](https://github.com/MetaMask/core/pull/8470)) -- fix: Fix `batch` `transaction_type` in metrics for pay transactions ([#8469](https://github.com/MetaMask/core/pull/8469)) -- chore: add imageUrl ([#8448](https://github.com/MetaMask/core/pull/8448)) -- feat(social-controllers): add followingProfileIds to controller state ([#8459](https://github.com/MetaMask/core/pull/8459)) -- Release/913.0.0 ([#8466](https://github.com/MetaMask/core/pull/8466)) -- feat(perps): sync controller from mobile ([#8460](https://github.com/MetaMask/core/pull/8460)) -- refactor: change method names and use crypto packages -- Add status to `PermissionInfoWithMetadata` ([#8445](https://github.com/MetaMask/core/pull/8445)) -- feat: MUSD-248 create money-account-balance-service ([#8428](https://github.com/MetaMask/core/pull/8428)) -- Revert "Release 912.0.0 (#8451)" ([#8451](https://github.com/MetaMask/core/pull/8451)) -- Release 912.0.0 ([#8451](https://github.com/MetaMask/core/pull/8451)) -- Release/911.0.0 ([#8452](https://github.com/MetaMask/core/pull/8452)) -- feat: skip simulation when enforced simulations container is active ([#8431](https://github.com/MetaMask/core/pull/8431)) -- feat(account-tree-controller): always fire `:selectedAccountGroupChange` on init ([#8427](https://github.com/MetaMask/core/pull/8427)) -- feat(account-tree-controller): persist `accountTree` ([#8437](https://github.com/MetaMask/core/pull/8437)) -- fix: link existing push token when notification accounts are added ([#8449](https://github.com/MetaMask/core/pull/8449)) -- feat: add mantle testnet gas fee ([#8386](https://github.com/MetaMask/core/pull/8386)) -- feat: allow update encryption key -- Release/910.0.0 ([#8447](https://github.com/MetaMask/core/pull/8447)) -- Release/909.0.0 ([#8444](https://github.com/MetaMask/core/pull/8444)) -- fix: set submittedTime at pay publish hook start for accurate time-to-complete metrics ([#8439](https://github.com/MetaMask/core/pull/8439)) -- chore: bump `@metamask/auto-changelog` to `^6.0.0` ([#8441](https://github.com/MetaMask/core/pull/8441)) -- chore: Use Oxfmt for import sorting instead of `import-x/order` ([#8438](https://github.com/MetaMask/core/pull/8438)) -- chore: Replace Prettier with Oxfmt ([#8434](https://github.com/MetaMask/core/pull/8434)) -- Release/908.0.0 ([#8440](https://github.com/MetaMask/core/pull/8440)) -- feat: MUSD-626 update earn controller to follow init pattern ([#8421](https://github.com/MetaMask/core/pull/8421)) -- feat(keyring-controller): add `withKeyringV2` support ([#8390](https://github.com/MetaMask/core/pull/8390)) -- feat(transaction-controller): add atomic option ([#8320](https://github.com/MetaMask/core/pull/8320)) -- fix: revert change on extension checking single NFT ownership ([#8435](https://github.com/MetaMask/core/pull/8435)) -- Release/907.0.0 ([#8432](https://github.com/MetaMask/core/pull/8432)) -- Release 906.0.0 ([#8425](https://github.com/MetaMask/core/pull/8425)) -- fix(perps-controller): restore webpackIgnore comment stripped by ts-bridge ([#8424](https://github.com/MetaMask/core/pull/8424)) -- fix: batch NFT ownership checks via Multicall3 ([#8281](https://github.com/MetaMask/core/pull/8281)) -- feat(keyring-controller): persist vault when keyring state changes during unlock ([#8415](https://github.com/MetaMask/core/pull/8415)) -- Fix: assets controller startup empty accounts ([#8412](https://github.com/MetaMask/core/pull/8412)) -- feat(social-controllers): add intent and category fields to Trade type ([#8410](https://github.com/MetaMask/core/pull/8410)) -- feat: update configuration of passkeys -- chore: Remove storage functionality from Delegation Controller ([#8330](https://github.com/MetaMask/core/pull/8330)) -- refactor: passkey controller to return registration and authentication options -- feat: init passkey controller -- feat: expose `getAccountAddressRelationship` and its request type ([#8402](https://github.com/MetaMask/core/pull/8402)) -- feat(STX-433): remove submission method from transaction meta ([#8409](https://github.com/MetaMask/core/pull/8409)) -- Release 905.0.0 ([#8407](https://github.com/MetaMask/core/pull/8407)) -- chore: Expose `KeyringController:signTransaction` through messenger ([#8408](https://github.com/MetaMask/core/pull/8408)) -- feat(perps-controller): make package safe for extension consumers (re-do of #8374) ([#8398](https://github.com/MetaMask/core/pull/8398)) -- feat(ramps-controller): add optional fiat buy limits to Provider type ([#8405](https://github.com/MetaMask/core/pull/8405)) -- Release/904.0.0 ([#8406](https://github.com/MetaMask/core/pull/8406)) -- Revert "Update Release 905.0.0 (#8399) and release 904.0.0 (#8397)" ([#8399](https://github.com/MetaMask/core/pull/8399)) -- Update Release 905.0.0 ([#8399](https://github.com/MetaMask/core/pull/8399)) -- Release/904.0.0 ([#8397](https://github.com/MetaMask/core/pull/8397)) -- feat: adopt messenger-cli pattern for 4 controller packages ([#8391](https://github.com/MetaMask/core/pull/8391)) -- chore: Update bridge controllers to expose all methods through messenger ([#8367](https://github.com/MetaMask/core/pull/8367)) -- fix(assets-controller): hardened error handling ([#8389](https://github.com/MetaMask/core/pull/8389)) -- feat(STX-433): add submissionMethod to transaction metadata ([#8375](https://github.com/MetaMask/core/pull/8375)) -- feat: improve rpc data source ([#8385](https://github.com/MetaMask/core/pull/8385)) -- fix(transaction-pay): allow perps withdraw to Arbitrum USDC by skipping same-token filter for HyperLiquid source ([#8387](https://github.com/MetaMask/core/pull/8387)) -- feat: Enable Across support for perps deposits ([#8334](https://github.com/MetaMask/core/pull/8334)) -- feat(assets-controller): batch API requests and split pipeline for faster asset loading ([#8383](https://github.com/MetaMask/core/pull/8383)) -- Release/903.0.0 ([#8382](https://github.com/MetaMask/core/pull/8382)) -- feat: extract generate-action-types CLI into @metamask/messenger-cli ([#8378](https://github.com/MetaMask/core/pull/8378)) -- chore: rename Ramp Team to Money Movement Team ([#8381](https://github.com/MetaMask/core/pull/8381)) -- Release/902.0.0 ([#8380](https://github.com/MetaMask/core/pull/8380)) -- fix(ramps): Remove dual-path fetching from RampsController, let client own data lifecycle ([#8354](https://github.com/MetaMask/core/pull/8354)) -- Ensure that README content is kept up to date automatically ([#8379](https://github.com/MetaMask/core/pull/8379)) -- Deprecate :stateChange in favor of :stateChanged ([#8187](https://github.com/MetaMask/core/pull/8187)) -- Drop eslint peer dep in messenger package ([#8371](https://github.com/MetaMask/core/pull/8371)) -- Release/900.0.0 ([#8370](https://github.com/MetaMask/core/pull/8370)) -- refactor(compliance): remove initial block wallets call ([#8365](https://github.com/MetaMask/core/pull/8365)) -- Release/899.0.0 ([#8369](https://github.com/MetaMask/core/pull/8369)) -- Feat/tsa 317 social service ([#8335](https://github.com/MetaMask/core/pull/8335)) -- Release/898.0.0 ([#8368](https://github.com/MetaMask/core/pull/8368)) -- feat: add `money-account-controller` ([#8361](https://github.com/MetaMask/core/pull/8361)) -- feat: Expose all PerpsController methods through messenger ([#8352](https://github.com/MetaMask/core/pull/8352)) -- Migrate SampleGasPriceService to BaseDataService ([#8343](https://github.com/MetaMask/core/pull/8343)) -- Prevent calling messenger actions in controller/service constructors ([#8353](https://github.com/MetaMask/core/pull/8353)) -- feat(accounts-controller): filter out `MoneyKeyring` accounts ([#8362](https://github.com/MetaMask/core/pull/8362)) -- Add selectors for `ConnectivityController` ([#7701](https://github.com/MetaMask/core/pull/7701)) -- feat(keyring-controller): add `KeyringTypes.money` ([#8360](https://github.com/MetaMask/core/pull/8360)) -- Release/895.0.0 ([#8359](https://github.com/MetaMask/core/pull/8359)) -- feat(keyring-controller): add `withKeyringUnsafe` action ([#8358](https://github.com/MetaMask/core/pull/8358)) -- feat(keyring-controller): add `isKeyringNotFoundError` ([#8351](https://github.com/MetaMask/core/pull/8351)) -- feat: require messenger and Blockaid bulk scan in TokenDataSource ([#8329](https://github.com/MetaMask/core/pull/8329)) -- feat(messenger): add `generate-action-types` CLI tool as subpath export ([#8264](https://github.com/MetaMask/core/pull/8264)) -- Release/894.0.0 ([#8355](https://github.com/MetaMask/core/pull/8355)) -- feat: inject new parameters in addTransaction for gas fee tokens (Tempo) ([#8052](https://github.com/MetaMask/core/pull/8052)) -- feat: Expose NetworkController methods through messenger ([#8350](https://github.com/MetaMask/core/pull/8350)) -- feat(keyring-controller): add `filter` selector callback to `withKeyring` ([#8348](https://github.com/MetaMask/core/pull/8348)) -- fix: hide native tokens on Tempo networks (testnet and mainnet) ([#7882](https://github.com/MetaMask/core/pull/7882)) -- fix(account-tree-controller): remove dynamic identifiers from Backup and sync thrown error messages ([#8349](https://github.com/MetaMask/core/pull/8349)) -- fix: add missing @metamask/messenger dependency ([#8318](https://github.com/MetaMask/core/pull/8318)) -- Release/893.0.0 ([#8344](https://github.com/MetaMask/core/pull/8344)) -- Release/892.0.0 ([#8340](https://github.com/MetaMask/core/pull/8340)) -- feat: ctrl utils add info chains no native token ([#8336](https://github.com/MetaMask/core/pull/8336)) -- feat: bridgeController consume complete event ([#8306](https://github.com/MetaMask/core/pull/8306)) -- feat: replaced metamask-earn team with earn in codeowners ([#8328](https://github.com/MetaMask/core/pull/8328)) -- feat(perps): latest controller sync ([#8333](https://github.com/MetaMask/core/pull/8333)) -- feat(transaction-pay-controller): add route-based strategy resolution ([#8282](https://github.com/MetaMask/core/pull/8282)) -- feat: introduces the new home for the social controllers ([#8321](https://github.com/MetaMask/core/pull/8321)) -- Release/891.0.0 ([#8327](https://github.com/MetaMask/core/pull/8327)) -- fix: market overview caip19 bug fix ([#8326](https://github.com/MetaMask/core/pull/8326)) -- chore: remove `@metamask/error-reporting-service` package ([#8323](https://github.com/MetaMask/core/pull/8323)) -- Release 890.0.0 ([#8325](https://github.com/MetaMask/core/pull/8325)) -- refactor(transaction-controller): encapsulate provider logic ([#8273](https://github.com/MetaMask/core/pull/8273)) -- ci: Separate lint steps to speed up CI ([#8322](https://github.com/MetaMask/core/pull/8322)) -- feat(transaction-pay-controller): add HyperLiquid withdrawal submission via Relay ([#8314](https://github.com/MetaMask/core/pull/8314)) -- test(base-data-service): Use `registerMethodActionHandlers` ([#8324](https://github.com/MetaMask/core/pull/8324)) -- chore!: Bump Snaps packages and handle breaking changes ([#8319](https://github.com/MetaMask/core/pull/8319)) -- feat: (TokenBalancesController) add batching around update balance requests ([#8246](https://github.com/MetaMask/core/pull/8246)) - ### Added - Add `runMigrations` method to run pending data migrations for legacy secrets ([#7284](https://github.com/MetaMask/core/pull/7284)) @@ -438,6 +271,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added an optional parameter, `passwordOutdatedCacheTTL` to the constructor params and exported `SecretMetadata` class from the controller.([#6169](https://github.com/MetaMask/core/pull/6169)) + - Added `revokeRefreshToken` function to revoke refresh token and update vault with the new revoke token.([#6187](https://github.com/MetaMask/core/pull/6187)) ## [2.4.0] @@ -486,7 +320,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added `PrivateKey sync` feature to the controller. ([#5948](https://github.com/MetaMask/core/pull/5948)) +- Added `PrivateKey sync` feature to the controller ([#5948](https://github.com/MetaMask/core/pull/5948)). - **BREAKING** Updated controller methods signatures. - removed `addNewSeedPhraseBackup` and replaced with `addNewSecretData` method. - added `addNewSecretData` method implementation to support adding different secret data types. @@ -509,7 +343,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `createToprfKeyAndBackupSeedPhrase`, `fetchAllSecretData` store revoke token in vault - check for token expired in toprf call, refresh token and retry if expired - `submitPassword` revoke refresh token and replace with new one after password submit to prevent malicious use if refresh token leak in persisted state -- Removed `recoveryRatelimitCache` from the controller state.. ([#5976](https://github.com/MetaMask/core/pull/5976)) +- Removed `recoveryRatelimitCache` from the controller state. ([#5976](https://github.com/MetaMask/core/pull/5976)). - **BREAKING:** Changed `syncLatestGlobalPassword`. ([#5995](https://github.com/MetaMask/core/pull/5995)) - removed parameter `oldPassword` - no longer verifying old password From 75c5c1b97567f412f8de8716d31c3beda35a3534 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Wed, 22 Apr 2026 14:53:16 +0700 Subject: [PATCH 26/44] refactor: add comments to webauthn package --- .../verify-authentication-response.ts | 47 ++++++++++++++++--- .../webauthn/verify-registration-response.ts | 39 ++++++++++++--- 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/packages/passkey-controller/src/webauthn/verify-authentication-response.ts b/packages/passkey-controller/src/webauthn/verify-authentication-response.ts index 05d6e577d10..cea736b8e3d 100644 --- a/packages/passkey-controller/src/webauthn/verify-authentication-response.ts +++ b/packages/passkey-controller/src/webauthn/verify-authentication-response.ts @@ -87,52 +87,87 @@ export async function verifyAuthenticationResponse(opts: { response: assertionResponse, } = response; + // Ensure credential specified an ID if (!id) { throw new Error('Missing credential ID'); } + + // Ensure ID is base64url-encoded if (id !== rawId) { throw new Error('Credential ID was not base64url-encoded'); } + + // Make sure credential type is public-key if (credentialType !== 'public-key') { throw new Error( `Unexpected credential type ${String(credentialType)}, expected "public-key"`, ); } + if (typeof assertionResponse?.clientDataJSON !== 'string') { + throw new Error('Credential response clientDataJSON was not a string'); + } + const clientDataJSON = decodeClientDataJSON(assertionResponse.clientDataJSON); + const { type, challenge, origin, tokenBinding } = clientDataJSON; - if (clientDataJSON.type !== 'webauthn.get') { + // Make sure we're handling an authentication + if (type !== 'webauthn.get') { throw new Error( - `Unexpected authentication response type: ${clientDataJSON.type}`, + `Unexpected authentication response type: ${type}`, ); } - if (clientDataJSON.challenge !== expectedChallenge) { + // Ensure the device provided the challenge we gave it + if (challenge !== expectedChallenge) { throw new Error( - `Unexpected authentication response challenge "${clientDataJSON.challenge}", expected "${expectedChallenge}"`, + `Unexpected authentication response challenge "${challenge}", expected "${expectedChallenge}"`, ); } + // Check that the origin is our site const expectedOrigins = Array.isArray(expectedOrigin) ? expectedOrigin : [expectedOrigin]; - if (!expectedOrigins.includes(clientDataJSON.origin)) { + if (!expectedOrigins.includes(origin)) { throw new Error( - `Unexpected authentication response origin "${clientDataJSON.origin}", expected one of: ${expectedOrigins.join(', ')}`, + `Unexpected authentication response origin "${origin}", expected one of: ${expectedOrigins.join(', ')}`, ); } + if ( + assertionResponse.userHandle && + typeof assertionResponse.userHandle !== 'string' + ) { + throw new Error('Credential response userHandle was not a string'); + } + + if (tokenBinding) { + if (typeof tokenBinding !== 'object') { + throw new Error('ClientDataJSON tokenBinding was not an object'); + } + + if ( + !['present', 'supported', 'notSupported'].includes(tokenBinding.status) + ) { + throw new Error(`Unexpected tokenBinding status ${tokenBinding.status}`); + } + } + const authDataBuffer = base64URLToBytes(assertionResponse.authenticatorData); const parsedAuthData: ParsedAuthenticatorData = parseAuthenticatorData(authDataBuffer); const { rpIdHash, flags, counter } = parsedAuthData; + // Make sure the response's RP ID is ours const matchedRPID = matchExpectedRPID(rpIdHash, [expectedRPID]); + // WebAuthn only requires the user presence flag be true if (!flags.up) { throw new Error('User not present during authentication'); } + // Enforce user verification if required if (requireUserVerification && !flags.uv) { throw new Error( 'User verification required, but user could not be verified', diff --git a/packages/passkey-controller/src/webauthn/verify-registration-response.ts b/packages/passkey-controller/src/webauthn/verify-registration-response.ts index d070055f0f1..43fe61cedc0 100644 --- a/packages/passkey-controller/src/webauthn/verify-registration-response.ts +++ b/packages/passkey-controller/src/webauthn/verify-registration-response.ts @@ -88,12 +88,17 @@ export async function verifyRegistrationResponse(opts: { response: attestationResponse, } = response; + // Ensure credential specified an ID if (!id) { throw new Error('Missing credential ID'); } + + // Ensure ID is base64url-encoded if (id !== rawId) { throw new Error('Credential ID was not base64url-encoded'); } + + // Make sure credential type is public-key if (credentialType !== 'public-key') { throw new Error( `Unexpected credential type ${String(credentialType)}, expected "public-key"`, @@ -103,28 +108,46 @@ export async function verifyRegistrationResponse(opts: { const clientDataJSON = decodeClientDataJSON( attestationResponse.clientDataJSON, ); + const { type, challenge, origin, tokenBinding } = clientDataJSON; - if (clientDataJSON.type !== 'webauthn.create') { + // Make sure we're handling an registration + if (type !== 'webauthn.create') { throw new Error( - `Unexpected registration response type: ${clientDataJSON.type}`, + `Unexpected registration response type: ${type}`, ); } - if (clientDataJSON.challenge !== expectedChallenge) { + // Ensure the device provided the challenge we gave it + if (challenge !== expectedChallenge) { throw new Error( - `Unexpected registration response challenge "${clientDataJSON.challenge}", expected "${expectedChallenge}"`, + `Unexpected registration response challenge "${challenge}", expected "${expectedChallenge}"`, ); } + // Check that the origin is our site const expectedOrigins = Array.isArray(expectedOrigin) ? expectedOrigin : [expectedOrigin]; - if (!expectedOrigins.includes(clientDataJSON.origin)) { + if (!expectedOrigins.includes(origin)) { throw new Error( - `Unexpected registration response origin "${clientDataJSON.origin}", expected one of: ${expectedOrigins.join(', ')}`, + `Unexpected registration response origin "${origin}", expected one of: ${expectedOrigins.join(', ')}`, ); } + if (tokenBinding) { + if (typeof tokenBinding !== 'object') { + throw new Error('ClientDataJSON tokenBinding was not an object'); + } + + if ( + !['present', 'supported', 'not-supported'].includes(tokenBinding.status) + ) { + throw new Error( + `Unexpected tokenBinding.status value of "${tokenBinding.status}"`, + ); + } + } + const attestationObjectBytes = base64URLToBytes( attestationResponse.attestationObject, ); @@ -145,10 +168,12 @@ export async function verifyRegistrationResponse(opts: { matchExpectedRPID(rpIdHash, [expectedRPID]); + // Make sure someone was physically present if (!flags.up) { throw new Error('User presence was required, but user was not present'); } + // Enforce user verification if specified if (requireUserVerification && !flags.uv) { throw new Error( 'User verification was required, but user could not be verified', @@ -174,6 +199,8 @@ export async function verifyRegistrationResponse(opts: { if (typeof alg !== 'number') { throw new Error('Credential public key was missing numeric alg'); } + + // Make sure the key algorithm is one we specified within the registration options if (!supportedAlgorithmIDs.includes(alg)) { throw new Error( `Unexpected public key alg "${alg}", expected one of "${supportedAlgorithmIDs.join(', ')}"`, From 7375cba094659ddebacbd1c1aaa98074e2ffa93d Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Wed, 22 Apr 2026 15:16:23 +0700 Subject: [PATCH 27/44] refactor: use concatBytes from metamask/utils package --- .../src/utils/bytes.test.ts | 25 ------------------- .../passkey-controller/src/utils/bytes.ts | 21 ---------------- .../verify-authentication-response.ts | 4 +-- .../webauthn/verify-registration-response.ts | 4 +-- .../src/webauthn/verify-signature.ts | 6 ++--- 5 files changed, 7 insertions(+), 53 deletions(-) delete mode 100644 packages/passkey-controller/src/utils/bytes.test.ts delete mode 100644 packages/passkey-controller/src/utils/bytes.ts diff --git a/packages/passkey-controller/src/utils/bytes.test.ts b/packages/passkey-controller/src/utils/bytes.test.ts deleted file mode 100644 index f7bcca883fd..00000000000 --- a/packages/passkey-controller/src/utils/bytes.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { concatUint8Arrays } from './bytes'; - -describe('concatUint8Arrays', () => { - it('concatenates two arrays', () => { - const a = new Uint8Array([1, 2]); - const b = new Uint8Array([3, 4, 5]); - expect(concatUint8Arrays(a, b)).toStrictEqual( - new Uint8Array([1, 2, 3, 4, 5]), - ); - }); - - it('concatenates three arrays', () => { - expect( - concatUint8Arrays( - new Uint8Array([0x04]), - new Uint8Array([1, 2]), - new Uint8Array([3]), - ), - ).toStrictEqual(new Uint8Array([0x04, 1, 2, 3])); - }); - - it('returns empty array when given no inputs', () => { - expect(concatUint8Arrays()).toStrictEqual(new Uint8Array()); - }); -}); diff --git a/packages/passkey-controller/src/utils/bytes.ts b/packages/passkey-controller/src/utils/bytes.ts deleted file mode 100644 index 055e4f0199b..00000000000 --- a/packages/passkey-controller/src/utils/bytes.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Low-level `Uint8Array` helpers (concatenation, etc.). - * For base64url and hex conversions, see `encoding.ts`. - */ - -/** - * Concatenate one or more Uint8Arrays in order. - * - * @param arrays - Byte arrays to concatenate. - * @returns A new Uint8Array containing all inputs in order. - */ -export function concatUint8Arrays(...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; -} diff --git a/packages/passkey-controller/src/webauthn/verify-authentication-response.ts b/packages/passkey-controller/src/webauthn/verify-authentication-response.ts index cea736b8e3d..0437d408226 100644 --- a/packages/passkey-controller/src/webauthn/verify-authentication-response.ts +++ b/packages/passkey-controller/src/webauthn/verify-authentication-response.ts @@ -1,8 +1,8 @@ import { decodePartialCBOR } from '@levischuck/tiny-cbor'; +import { concatBytes } from '@metamask/utils'; import { sha256 } from '@noble/hashes/sha2'; import type { AuthenticatorTransportFuture } from '../types'; -import { concatUint8Arrays } from '../utils/bytes'; import { base64URLToBytes } from '../utils/encoding'; import { decodeClientDataJSON } from './decode-client-data-json'; import { matchExpectedRPID } from './match-expected-rp-id'; @@ -177,7 +177,7 @@ export async function verifyAuthenticationResponse(opts: { const clientDataHash = sha256( base64URLToBytes(assertionResponse.clientDataJSON), ); - const signatureBase = concatUint8Arrays(authDataBuffer, clientDataHash); + const signatureBase = concatBytes([authDataBuffer, clientDataHash]); const signature = base64URLToBytes(assertionResponse.signature); diff --git a/packages/passkey-controller/src/webauthn/verify-registration-response.ts b/packages/passkey-controller/src/webauthn/verify-registration-response.ts index 43fe61cedc0..39d35da62c5 100644 --- a/packages/passkey-controller/src/webauthn/verify-registration-response.ts +++ b/packages/passkey-controller/src/webauthn/verify-registration-response.ts @@ -1,8 +1,8 @@ import { decodePartialCBOR } from '@levischuck/tiny-cbor'; +import { concatBytes } from '@metamask/utils'; import { sha256 } from '@noble/hashes/sha2'; import type { AuthenticatorTransportFuture } from '../types'; -import { concatUint8Arrays } from '../utils/bytes'; import { base64URLToBytes, bytesToBase64URL, @@ -300,7 +300,7 @@ async function verifyPackedAttestation( } const clientDataHash = sha256(base64URLToBytes(clientDataJSONB64url)); - const signatureBase = concatUint8Arrays(authData, clientDataHash); + const signatureBase = concatBytes([authData, clientDataHash]); return verifySignature({ cosePublicKey, diff --git a/packages/passkey-controller/src/webauthn/verify-signature.ts b/packages/passkey-controller/src/webauthn/verify-signature.ts index 93154767ec4..e6dbe9f7a9a 100644 --- a/packages/passkey-controller/src/webauthn/verify-signature.ts +++ b/packages/passkey-controller/src/webauthn/verify-signature.ts @@ -1,8 +1,8 @@ +import { concatBytes } from '@metamask/utils'; import { ed25519 } from '@noble/curves/ed25519'; import { p256, p384 } from '@noble/curves/nist'; import { sha256, sha384 } from '@noble/hashes/sha2'; -import { concatUint8Arrays } from '../utils/bytes'; import { bytesToBase64URL } from '../utils/encoding'; import { COSEALG, COSECRV, COSEKEYS, COSEKTY } from './constants'; @@ -46,11 +46,11 @@ function verifyEC2( throw new Error('EC2 public key missing x or y coordinate'); } - const uncompressed = concatUint8Arrays( + const uncompressed = concatBytes([ new Uint8Array([0x04]), xCoord, yCoord, - ); + ]); switch (crv) { case COSECRV.P256: From 5390ca66786439a900ea0674cd3d510296aabc84 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Wed, 22 Apr 2026 16:40:25 +0700 Subject: [PATCH 28/44] refactor: tests --- .../decode-attestation-object.test.ts | 46 + .../webauthn/decode-client-data-json.test.ts | 16 + .../src/webauthn/match-expected-rp-id.test.ts | 33 + .../webauthn/parse-authenticator-data.test.ts | 144 ++ .../verify-authentication-response.test.ts | 308 +++ .../verify-registration-response.test.ts | 745 +++++++ .../src/webauthn/verify-signature.test.ts | 369 ++++ .../src/webauthn/webauthn.test.ts | 1952 ----------------- 8 files changed, 1661 insertions(+), 1952 deletions(-) create mode 100644 packages/passkey-controller/src/webauthn/decode-attestation-object.test.ts create mode 100644 packages/passkey-controller/src/webauthn/decode-client-data-json.test.ts create mode 100644 packages/passkey-controller/src/webauthn/match-expected-rp-id.test.ts create mode 100644 packages/passkey-controller/src/webauthn/parse-authenticator-data.test.ts create mode 100644 packages/passkey-controller/src/webauthn/verify-authentication-response.test.ts create mode 100644 packages/passkey-controller/src/webauthn/verify-registration-response.test.ts create mode 100644 packages/passkey-controller/src/webauthn/verify-signature.test.ts delete mode 100644 packages/passkey-controller/src/webauthn/webauthn.test.ts diff --git a/packages/passkey-controller/src/webauthn/decode-attestation-object.test.ts b/packages/passkey-controller/src/webauthn/decode-attestation-object.test.ts new file mode 100644 index 00000000000..f4532c505a4 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/decode-attestation-object.test.ts @@ -0,0 +1,46 @@ +import { base64URLToBytes } from '../utils/encoding'; +import { decodeAttestationObject } from './decode-attestation-object'; + +describe('decodeAttestationObject', () => { + it('decodes base64url-encoded indirect attestationObject', () => { + const decoded = decodeAttestationObject( + base64URLToBytes( + 'o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjEAbElFazplpnc037DORGDZNjDq86cN9vm6' + + '+APoAM20wtBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQKmPuEwByQJ3e89TccUSrCGDkNWquhevjLLn/' + + 'KNZZaxQQ0steueoG2g12dvnUNbiso8kVJDyLa+6UiA34eniujWlAQIDJiABIVggiUk8wN2j' + + '+3fkKI7KSiLBkKzs3FfhPZxHgHPnGLvOY/YiWCBv7+XyTqArnMVtQ947/8Xk8fnVCdLMRWJGM1VbNevVcQ==', + ), + ); + + expect(decoded.get('fmt')).toBe('none'); + expect(decoded.get('attStmt')).toStrictEqual(new Map()); + expect(Boolean(decoded.get('authData'))).toBe(true); + }); + + it('decodes base64url-encoded direct attestationObject', () => { + const decoded = decodeAttestationObject( + base64URLToBytes( + 'o2NmbXRoZmlkby11MmZnYXR0U3RtdKJjc2lnWEgwRgIhAK40WxA0t7py7AjEXvwGwTlmqlvrOk' + + 's5g9lf+9zXzRiVAiEA3bv60xyXveKDOusYzniD7CDSostCet9PYK7FLdnTdZNjeDVjgVkCwTCCAr0wggGloAMCAQICBCrn' + + 'YmMwDQYJKoZIhvcNAQELBQAwLjEsMCoGA1UEAxMjWXViaWNvIFUyRiBSb290IENBIFNlcmlhbCA0NTcyMDA2MzEwIBcNMT' + + 'QwODAxMDAwMDAwWhgPMjA1MDA5MDQwMDAwMDBaMG4xCzAJBgNVBAYTAlNFMRIwEAYDVQQKDAlZdWJpY28gQUIxIjAgBgNV' + + 'BAsMGUF1dGhlbnRpY2F0b3IgQXR0ZXN0YXRpb24xJzAlBgNVBAMMHll1YmljbyBVMkYgRUUgU2VyaWFsIDcxOTgwNzA3NT' + + 'BZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABCoDhl5gQ9meEf8QqiVUV4S/Ca+Oax47MhcpIW9VEhqM2RDTmd3HaL3+SnvH' + + '49q8YubSRp/1Z1uP+okMynSGnj+jbDBqMCIGCSsGAQQBgsQKAgQVMS4zLjYuMS40LjEuNDE0ODIuMS4xMBMGCysGAQQBgu' + + 'UcAgEBBAQDAgQwMCEGCysGAQQBguUcAQEEBBIEEG1Eupv27C5JuTAMj+kgy3MwDAYDVR0TAQH/BAIwADANBgkqhkiG9w0B' + + 'AQsFAAOCAQEAclfQPNzD4RVphJDW+A75W1MHI3PZ5kcyYysR3Nx3iuxr1ZJtB+F7nFQweI3jL05HtFh2/4xVIgKb6Th4eV' + + 'cjMecncBaCinEbOcdP1sEli9Hk2eVm1XB5A0faUjXAPw/+QLFCjgXG6ReZ5HVUcWkB7riLsFeJNYitiKrTDXFPLy+sNtVN' + + 'utcQnFsCerDKuM81TvEAigkIbKCGlq8M/NvBg5j83wIxbCYiyV7mIr3RwApHieShzLdJo1S6XydgQjC+/64G5r8C+8AVvN' + + 'FR3zXXCpio5C3KRIj88HEEIYjf6h1fdLfqeIsq+cUUqbq5T+c4nNoZUZCysTB9v5EY4akp+GhhdXRoRGF0YVjEAbElFazp' + + 'lpnc037DORGDZNjDq86cN9vm6+APoAM20wtBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQGFYevaR71ptU5YtXOSnVzPQTsGgK+' + + 'gLiBKnqPWBmZXNRvjISqlLxiwApzlrfkTc3lEMYMatjeACCnsijOkNEGOlAQIDJiABIVggdWLG6UvGyHFw/k/bv6/k6z/L' + + 'LgSO5KXzXw2EcUxkEX8iWCBeaVLz/cbyoKvRIg/q+q7tan0VN+i3WR0BOBCcuNP7yw==', + ), + ); + + expect(decoded.get('fmt')).toBe('fido-u2f'); + expect(Boolean(decoded.get('attStmt').get('sig'))).toBe(true); + expect(Boolean(decoded.get('attStmt').get('x5c'))).toBe(true); + expect(Boolean(decoded.get('authData'))).toBe(true); + }); +}); diff --git a/packages/passkey-controller/src/webauthn/decode-client-data-json.test.ts b/packages/passkey-controller/src/webauthn/decode-client-data-json.test.ts new file mode 100644 index 00000000000..7c6e72404f4 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/decode-client-data-json.test.ts @@ -0,0 +1,16 @@ +import { decodeClientDataJSON } from './decode-client-data-json'; + +describe('decodeClientDataJSON', () => { + it('converts base64url-encoded attestation clientDataJSON to JSON', () => { + expect( + decodeClientDataJSON( + 'eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiWko0YW12QnpOUGVMb3lLVE04bDlqamFmMDhXc0V0TG5OSENGZnhacGEybjlfU21NUnR5VjZlYlNPSUFfUGNsOHBaUjl5Y1ZhaW5SdV9rUDhRaTZiemciLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIn0', + ), + ).toStrictEqual({ + type: 'webauthn.create', + challenge: + 'ZJ4amvBzNPeLoyKTM8l9jjaf08WsEtLnNHCFfxZpa2n9_SmMRtyV6ebSOIA_Pcl8pZR9ycVainRu_kP8Qi6bzg', + origin: 'https://webauthn.io', + }); + }); +}); diff --git a/packages/passkey-controller/src/webauthn/match-expected-rp-id.test.ts b/packages/passkey-controller/src/webauthn/match-expected-rp-id.test.ts new file mode 100644 index 00000000000..c746504d2c6 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/match-expected-rp-id.test.ts @@ -0,0 +1,33 @@ +import { sha256 } from '@noble/hashes/sha2'; + +import { matchExpectedRPID } from './match-expected-rp-id'; + +describe('matchExpectedRPID', () => { + it('throws when no RP ID matches', () => { + const rpIdHash = sha256(new TextEncoder().encode('example.com')); + expect(() => matchExpectedRPID(rpIdHash, ['wrong.com'])).toThrow( + 'Unexpected RP ID hash', + ); + }); + + it('returns matching RP ID', () => { + const rpIdHash = sha256(new TextEncoder().encode('example.com')); + expect(matchExpectedRPID(rpIdHash, ['example.com'])).toBe('example.com'); + }); + + it('constant-time compare rejects different lengths', () => { + // Pass a 16-byte rpIdHash to trigger the areEqual length-mismatch branch + // (sha256 always produces 32 bytes, so the comparison short-circuits) + const shortHash = new Uint8Array(16).fill(0xaa); + expect(() => matchExpectedRPID(shortHash, ['example.com'])).toThrow( + 'Unexpected RP ID hash', + ); + }); + + it('matches second candidate in array', () => { + const rpIdHash = sha256(new TextEncoder().encode('example.com')); + expect(matchExpectedRPID(rpIdHash, ['wrong.com', 'example.com'])).toBe( + 'example.com', + ); + }); +}); diff --git a/packages/passkey-controller/src/webauthn/parse-authenticator-data.test.ts b/packages/passkey-controller/src/webauthn/parse-authenticator-data.test.ts new file mode 100644 index 00000000000..05fd73cd1a8 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/parse-authenticator-data.test.ts @@ -0,0 +1,144 @@ +import { encodeCBOR } from '@levischuck/tiny-cbor'; +import { sha256 } from '@noble/hashes/sha2'; +import { base64ToBytes, bytesToBase64 } from '@metamask/utils'; + +import { bytesToBase64URL } from '../utils/encoding'; +import { parseAuthenticatorData } from './parse-authenticator-data'; + +/** + * Conformance vectors from SimpleWebAuthn `parseAuthenticatorData.test.ts` + * (base64-decoded the same way: `isoBase64URL.toBuffer(..., 'base64')`). + * + * The Firefox 117 malformed COSE case from upstream is omitted here: that + * parser patches bad CBOR and re-encodes the public key; this implementation + * does not, and throws "Leftover bytes detected..." on that buffer. + */ + +// Includes attested credential data (AT) +const authDataWithAT = Uint8Array.from( + base64ToBytes( + 'SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2NBAAAAJch83ZdWwUm4niTLNjZU81AAIHa7Ksm5br3hAh3UjxP9+4rqu8BEsD+7SZ2xWe1/yHv6pAEDAzkBACBZAQDcxA7Ehs9goWB2Hbl6e9v+aUub9rvy2M7Hkvf+iCzMGE63e3sCEW5Ru33KNy4um46s9jalcBHtZgtEnyeRoQvszis+ws5o4Da0vQfuzlpBmjWT1dV6LuP+vs9wrfObW4jlA5bKEIhv63+jAxOtdXGVzo75PxBlqxrmrr5IR9n8Fw7clwRsDkjgRHaNcQVbwq/qdNwU5H3hZKu9szTwBS5NGRq01EaDF2014YSTFjwtAmZ3PU1tcO/QD2U2zg6eB5grfWDeAJtRE8cbndDWc8aLL0aeC37Q36+TVsGe6AhBgHEw6eO3I3NW5r9v/26CqMPBDwmEundeq1iGyKfMloobIUMBAAE=', + ), +); + +// Includes extension data (ED) +const authDataWithED = Uint8Array.from( + base64ToBytes( + 'SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2OBAAAAjaFxZXhhbXBsZS5leHRlbnNpb254dlRoaXMgaXMgYW4gZXhhbXBsZSBleHRlbnNpb24hIElmIHlvdSByZWFkIHRoaXMgbWVzc2FnZSwgeW91IHByb2JhYmx5IHN1Y2Nlc3NmdWxseSBwYXNzaW5nIGNvbmZvcm1hbmNlIHRlc3RzLiBHb29kIGpvYiE=', + ), +); + +const TEST_RP_ID = 'example.com'; + +describe('parseAuthenticatorData', () => { + it('parses flags', () => { + const parsed = parseAuthenticatorData(authDataWithED); + const { flags } = parsed; + + expect(flags.up).toBe(true); + expect(flags.uv).toBe(false); + expect(flags.be).toBe(false); + expect(flags.bs).toBe(false); + expect(flags.at).toBe(false); + expect(flags.ed).toBe(true); + }); + + it('parses attestation data', () => { + const parsed = parseAuthenticatorData(authDataWithAT); + const { credentialID, credentialPublicKey, aaguid, counter } = parsed; + + if ( + credentialID === undefined || + credentialPublicKey === undefined || + aaguid === undefined + ) { + throw new Error('expected credentialID, credentialPublicKey, and aaguid'); + } + + expect(bytesToBase64URL(credentialID)).toBe( + 'drsqybluveECHdSPE_37iuq7wESwP7tJnbFZ7X_Ie_o', + ); + expect(bytesToBase64(credentialPublicKey)).toBe( + 'pAEDAzkBACBZAQDcxA7Ehs9goWB2Hbl6e9v+aUub9rvy2M7Hkvf+iCzMGE63e3sCEW5Ru33KNy4um46s9jalcBHtZgtEnyeRoQvszis+ws5o4Da0vQfuzlpBmjWT1dV6LuP+vs9wrfObW4jlA5bKEIhv63+jAxOtdXGVzo75PxBlqxrmrr5IR9n8Fw7clwRsDkjgRHaNcQVbwq/qdNwU5H3hZKu9szTwBS5NGRq01EaDF2014YSTFjwtAmZ3PU1tcO/QD2U2zg6eB5grfWDeAJtRE8cbndDWc8aLL0aeC37Q36+TVsGe6AhBgHEw6eO3I3NW5r9v/26CqMPBDwmEundeq1iGyKfMloobIUMBAAE=', + ); + expect(bytesToBase64(aaguid)).toBe('yHzdl1bBSbieJMs2NlTzUA=='); + expect(counter).toBe(37); + }); + + it('parses extension data', () => { + const parsed = parseAuthenticatorData(authDataWithED); + const { extensionsData } = parsed; + + expect(extensionsData).toStrictEqual( + new Map([ + [ + 'example.extension', + 'This is an example extension! If you read this message, you probably successfully passing conformance tests. Good job!', + ], + ]), + ); + }); +}); + +describe('parseAuthenticatorData edge cases', () => { + it('throws for authenticator data shorter than 37 bytes', () => { + expect(() => parseAuthenticatorData(new Uint8Array(36))).toThrow( + 'authenticatorData is 36 bytes, expected at least 37', + ); + }); + + it('parses extension data when ED flag is set', () => { + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + const flags = 0x81; + const counter = new Uint8Array(4); + + const extMap = new Map(); + extMap.set('credProtect', 2); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const extCBOR = encodeCBOR(extMap as any); + + const authData = new Uint8Array(37 + extCBOR.length); + authData.set(rpIdHash, 0); + authData[32] = flags; + authData.set(counter, 33); + authData.set(extCBOR, 37); + + const result = parseAuthenticatorData(authData); + expect(result.flags.ed).toBe(true); + expect(result.extensionsData).toBeDefined(); + expect(result.extensionsData?.get('credProtect')).toBe(2); + }); + + it('throws on leftover bytes after parsing', () => { + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + const authData = new Uint8Array(42); + authData.set(rpIdHash, 0); + authData[32] = 0x01; + authData.set(new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0x00]), 37); + + expect(() => parseAuthenticatorData(authData)).toThrow( + 'Leftover bytes detected while parsing authenticator data', + ); + }); + + it('parses authenticator data without attested credential or extensions', () => { + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + const authData = new Uint8Array(37); + authData.set(rpIdHash, 0); + authData[32] = 0x05; + + const counterView = new DataView(authData.buffer, 33, 4); + counterView.setUint32(0, 42, false); + + const result = parseAuthenticatorData(authData); + expect(result.flags.up).toBe(true); + expect(result.flags.uv).toBe(true); + expect(result.flags.at).toBe(false); + expect(result.flags.ed).toBe(false); + expect(result.counter).toBe(42); + expect(result.aaguid).toBeUndefined(); + expect(result.credentialID).toBeUndefined(); + expect(result.credentialPublicKey).toBeUndefined(); + expect(result.extensionsData).toBeUndefined(); + }); +}); diff --git a/packages/passkey-controller/src/webauthn/verify-authentication-response.test.ts b/packages/passkey-controller/src/webauthn/verify-authentication-response.test.ts new file mode 100644 index 00000000000..15a89957926 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/verify-authentication-response.test.ts @@ -0,0 +1,308 @@ +import { bytesToBase64URL, base64URLToBytes } from '../utils/encoding'; +import { decodeClientDataJSON } from './decode-client-data-json'; +import type { PasskeyAuthenticationResponse } from './types'; +import { verifyAuthenticationResponse } from './verify-authentication-response'; + +const EXPECTED_ORIGIN = 'https://dev.dontneeda.pw'; +const EXPECTED_RP_ID = 'dev.dontneeda.pw'; + +const assertionResponse: PasskeyAuthenticationResponse = { + id: 'KEbWNCc7NgaYnUyrNeFGX9_3Y-8oJ3KwzjnaiD1d1LVTxR7v3CaKfCz2Vy_g_MHSh7yJ8yL0Pxg6jo_o0hYiew', + rawId: + 'KEbWNCc7NgaYnUyrNeFGX9_3Y-8oJ3KwzjnaiD1d1LVTxR7v3CaKfCz2Vy_g_MHSh7yJ8yL0Pxg6jo_o0hYiew', + response: { + authenticatorData: 'PdxHEOnAiLIp26idVjIguzn3Ipr_RlsKZWsa-5qK-KABAAAAkA==', + clientDataJSON: + 'eyJjaGFsbGVuZ2UiOiJkRzkwWVd4c2VWVnVhWEYxWlZaaGJIVmxSWFpsY25sVWFXMWwiLCJj' + + 'bGllbnRFeHRlbnNpb25zIjp7fSwiaGFzaEFsZ29yaXRobSI6IlNIQS0yNTYiLCJvcmlnaW4iOiJodHRwczovL2Rldi5k' + + 'b250bmVlZGEucHciLCJ0eXBlIjoid2ViYXV0aG4uZ2V0In0=', + signature: + 'MEUCIQDYXBOpCWSWq2Ll4558GJKD2RoWg958lvJSB_GdeokxogIgWuEVQ7ee6AswQY0OsuQ6y8Ks6' + + 'jhd45bDx92wjXKs900=', + }, + clientExtensionResults: {}, + type: 'public-key', +}; + +const credential = { + publicKey: base64URLToBytes( + 'pQECAyYgASFYIIheFp-u6GvFT2LNGovf3ZrT0iFVBsA_76rRysxRG9A1Ilgg8WGeA6hPmnab0HAViUYVRkwTNcN77QBf_RR0dv3lIvQ', + ), + id: 'KEbWNCc7NgaYnUyrNeFGX9_3Y-8oJ3KwzjnaiD1d1LVTxR7v3CaKfCz2Vy_g_MHSh7yJ8yL0Pxg6jo_o0hYiew', + counter: 143, +}; + +const assertionFirstTimeUsedResponse: PasskeyAuthenticationResponse = { + id: 'wSisR0_4hlzw3Y1tj4uNwwifIhRa-ZxWJwWbnfror0pVK9qPdBPO5pW3gasPqn6wXHb0LNhXB_IrA1nFoSQJ9A', + rawId: + 'wSisR0_4hlzw3Y1tj4uNwwifIhRa-ZxWJwWbnfror0pVK9qPdBPO5pW3gasPqn6wXHb0LNhXB_IrA1nFoSQJ9A', + response: { + authenticatorData: 'PdxHEOnAiLIp26idVjIguzn3Ipr_RlsKZWsa-5qK-KABAAAAAA', + clientDataJSON: + 'eyJjaGFsbGVuZ2UiOiJkRzkwWVd4c2VWVnVhWEYxWlZaaGJIVmxSWFpsY25sQmMzTmxjblJwYjI0IiwiY2xpZW50RXh0ZW5zaW9ucyI6e30sImhhc2hBbGdvcml0aG0iOiJTSEEtMjU2Iiwib3JpZ2luIjoiaHR0cHM6Ly9kZXYuZG9udG5lZWRhLnB3IiwidHlwZSI6IndlYmF1dGhuLmdldCJ9', + signature: + 'MEQCIBu6M-DGzu1O8iocGHEj0UaAZm0HmxTeRIE6-nS3_CPjAiBDsmIzy5sacYwwzgpXqfwRt_2vl5yiQZ_OAqWJQBGVsQ', + }, + type: 'public-key', + clientExtensionResults: {}, +}; + +const authenticatorFirstTimeUsed = { + publicKey: base64URLToBytes( + 'pQECAyYgASFYIGmaxR4mBbukc2QhtW2ldhAAd555r-ljlGQN8MbcTnPPIlgg9CyUlE-0AB2fbzZbNgBvJuRa7r6o2jPphOmtyNPR_kY', + ), + id: 'wSisR0_4hlzw3Y1tj4uNwwifIhRa-ZxWJwWbnfror0pVK9qPdBPO5pW3gasPqn6wXHb0LNhXB_IrA1nFoSQJ9A', + counter: 0, +}; + +const assertionChallenge = decodeClientDataJSON( + assertionResponse.response.clientDataJSON, +).challenge; + +const assertionFirstTimeUsedChallenge = decodeClientDataJSON( + assertionFirstTimeUsedResponse.response.clientDataJSON, +).challenge; + +describe('verifyAuthenticationResponse', () => { + it('verifies an assertion response', async () => { + const verification = await verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential, + requireUserVerification: false, + }); + + expect(verification.verified).toBe(true); + }); + + it('returns authenticator info after verification', async () => { + const verification = await verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential, + requireUserVerification: false, + }); + + expect(verification.verified).toBe(true); + if (!verification.verified) { + return; + } + expect(verification.authenticationInfo.newCounter).toBe(144); + expect(verification.authenticationInfo.credentialId).toBe(credential.id); + expect(verification.authenticationInfo.origin).toBe(EXPECTED_ORIGIN); + expect(verification.authenticationInfo.rpID).toBe(EXPECTED_RP_ID); + }); + + it('throws when response challenge is not expected value', async () => { + await expect( + verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: 'shouldhavebeenthisvalue', + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential, + }), + ).rejects.toThrow('Unexpected authentication response challenge'); + }); + + it('throws when response origin is not expected value', async () => { + await expect( + verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: 'https://different.address', + expectedRPID: EXPECTED_RP_ID, + credential, + }), + ).rejects.toThrow('Unexpected authentication response origin'); + }); + + it('returns { verified: false } when signature does not verify', async () => { + const signatureBytes = base64URLToBytes(assertionResponse.response.signature); + signatureBytes[0] = ((signatureBytes[0] ?? 0) + 1) % 256; + + const result = await verifyAuthenticationResponse({ + response: { + ...assertionResponse, + response: { + ...assertionResponse.response, + signature: bytesToBase64URL(signatureBytes), + }, + }, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential, + }); + + expect(result.verified).toBe(false); + expect(result.authenticationInfo).toBeUndefined(); + }); + + it('throws when authentication type is not webauthn.get', async () => { + const badTypeClientData = bytesToBase64URL( + new TextEncoder().encode( + JSON.stringify({ + ...decodeClientDataJSON(assertionResponse.response.clientDataJSON), + type: 'webauthn.badtype', + }), + ), + ); + + await expect( + verifyAuthenticationResponse({ + response: { + ...assertionResponse, + response: { + ...assertionResponse.response, + clientDataJSON: badTypeClientData, + }, + }, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential, + }), + ).rejects.toThrow('Unexpected authentication response type'); + }); + + it('throws when RP ID is not expected value', async () => { + await expect( + verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: 'wrong-rp.com', + credential, + }), + ).rejects.toThrow('Unexpected RP ID hash'); + }); + + it('throws when credential ID is missing in response', async () => { + await expect( + verifyAuthenticationResponse({ + response: { + ...assertionResponse, + id: '', + rawId: '', + }, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential, + }), + ).rejects.toThrow('Missing credential ID'); + }); + + it('throws when id and rawId differ', async () => { + await expect( + verifyAuthenticationResponse({ + response: { + ...assertionResponse, + rawId: 'different-raw-id', + }, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential, + }), + ).rejects.toThrow('Credential ID was not base64url-encoded'); + }); + + it('throws when credential type is not public-key', async () => { + await expect( + verifyAuthenticationResponse({ + response: { + ...assertionResponse, + type: 'not-public-key', + } as unknown as PasskeyAuthenticationResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential, + }), + ).rejects.toThrow('Unexpected credential type'); + }); + + it('throws error if user was not present', async () => { + const authData = base64URLToBytes(assertionResponse.response.authenticatorData); + authData[32] = 0x00; + + await expect( + verifyAuthenticationResponse({ + response: { + ...assertionResponse, + response: { + ...assertionResponse.response, + authenticatorData: bytesToBase64URL(authData), + }, + }, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential, + }), + ).rejects.toThrow('User not present during authentication'); + }); + + it('throws error if previous counter value is not less than in response', async () => { + await expect( + verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential: { + ...credential, + counter: 144, + }, + requireUserVerification: false, + }), + ).rejects.toThrow('Response counter value 144 was lower than expected 144'); + }); + + it('does not compare counters if both are 0', async () => { + const verification = await verifyAuthenticationResponse({ + response: assertionFirstTimeUsedResponse, + expectedChallenge: assertionFirstTimeUsedChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential: authenticatorFirstTimeUsed, + requireUserVerification: false, + }); + + expect(verification.verified).toBe(true); + }); + + it('throws if user verification is required but uv is false', async () => { + await expect( + verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential, + requireUserVerification: true, + }), + ).rejects.toThrow( + 'User verification required, but user could not be verified', + ); + }); + + it('accepts expectedOrigin as array', async () => { + const verification = await verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: ['https://other.com', EXPECTED_ORIGIN], + expectedRPID: EXPECTED_RP_ID, + credential, + requireUserVerification: false, + }); + + expect(verification.verified).toBe(true); + }); +}); diff --git a/packages/passkey-controller/src/webauthn/verify-registration-response.test.ts b/packages/passkey-controller/src/webauthn/verify-registration-response.test.ts new file mode 100644 index 00000000000..b13cddc69e9 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/verify-registration-response.test.ts @@ -0,0 +1,745 @@ +import { encodeCBOR } from '@levischuck/tiny-cbor'; +import { p256 } from '@noble/curves/p256'; +import { sha256 } from '@noble/hashes/sha2'; + +import { base64URLToBytes } from '../utils/encoding'; +import { bytesToBase64URL } from '../utils/encoding'; +import { COSEALG, COSECRV, COSEKEYS, COSEKTY } from './constants'; +import { decodeClientDataJSON } from './decode-client-data-json'; +import type { PasskeyRegistrationResponse } from './types'; +import { verifyRegistrationResponse } from './verify-registration-response'; + +const EXPECTED_ORIGIN = 'https://dev.dontneeda.pw'; +const EXPECTED_RP_ID = 'dev.dontneeda.pw'; + +const attestationNone: PasskeyRegistrationResponse = { + id: 'AdKXJEch1aV5Wo7bj7qLHskVY4OoNaj9qu8TPdJ7kSAgUeRxWNngXlcNIGt4gexZGKVGcqZpqqWordXb_he1izY', + rawId: + 'AdKXJEch1aV5Wo7bj7qLHskVY4OoNaj9qu8TPdJ7kSAgUeRxWNngXlcNIGt4gexZGKVGcqZpqqWordXb_he1izY', + response: { + attestationObject: + 'o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjFPdxHEOnAiLIp26idVjIguzn3I' + + 'pr_RlsKZWsa-5qK-KBFAAAAAAAAAAAAAAAAAAAAAAAAAAAAQQHSlyRHIdWleVqO24-6ix7JFWODqDWo_arvEz3Se' + + '5EgIFHkcVjZ4F5XDSBreIHsWRilRnKmaaqlqK3V2_4XtYs2pQECAyYgASFYID5PQTZQQg6haZFQWFzqfAOyQ_ENs' + + 'MH8xxQ4GRiNPsqrIlggU8IVUOV8qpgk_Jh-OTaLuZL52KdX1fTht07X4DiQPow', + clientDataJSON: + 'eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiYUVWalkxQlhkWHBw' + + 'VURBd1NEQndOV2Q0YURKZmRUVmZVRU0wVG1WWloyUSIsIm9yaWdpbiI6Imh0dHBzOlwvXC9kZXYuZG9udG5lZWRh' + + 'LnB3IiwiYW5kcm9pZFBhY2thZ2VOYW1lIjoib3JnLm1vemlsbGEuZmlyZWZveCJ9', + transports: [], + }, + type: 'public-key', + clientExtensionResults: {}, +}; + +const attestationFIDOU2F: PasskeyRegistrationResponse = { + id: 'VHzbxaYaJu2P8m1Y2iHn2gRNHrgK0iYbn9E978L3Qi7Q-chFeicIHwYCRophz5lth2nCgEVKcgWirxlgidgbUQ', + rawId: + 'VHzbxaYaJu2P8m1Y2iHn2gRNHrgK0iYbn9E978L3Qi7Q-chFeicIHwYCRophz5lth2nCgEVKcgWirxlgidgbUQ', + response: { + attestationObject: + 'o2NmbXRoZmlkby11MmZnYXR0U3RtdKJjc2lnWEcwRQIgRYUftNUmhT0VWTZmIgDmrOoP26Pcre-kL3DLnCrXbegCIQCOu_x5gqp-Rej76zeBuXlk8e7J-9WM_i-wZmCIbIgCGmN4NWOBWQLBMIICvTCCAaWgAwIBAgIEKudiYzANBgkqhkiG9w0BAQsFADAuMSwwKgYDVQQDEyNZdWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwMDYzMTAgFw0xNDA4MDEwMDAwMDBaGA8yMDUwMDkwNDAwMDAwMFowbjELMAkGA1UEBhMCU0UxEjAQBgNVBAoMCVl1YmljbyBBQjEiMCAGA1UECwwZQXV0aGVudGljYXRvciBBdHRlc3RhdGlvbjEnMCUGA1UEAwweWXViaWNvIFUyRiBFRSBTZXJpYWwgNzE5ODA3MDc1MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEKgOGXmBD2Z4R_xCqJVRXhL8Jr45rHjsyFykhb1USGozZENOZ3cdovf5Ke8fj2rxi5tJGn_VnW4_6iQzKdIaeP6NsMGowIgYJKwYBBAGCxAoCBBUxLjMuNi4xLjQuMS40MTQ4Mi4xLjEwEwYLKwYBBAGC5RwCAQEEBAMCBDAwIQYLKwYBBAGC5RwBAQQEEgQQbUS6m_bsLkm5MAyP6SDLczAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQByV9A83MPhFWmEkNb4DvlbUwcjc9nmRzJjKxHc3HeK7GvVkm0H4XucVDB4jeMvTke0WHb_jFUiApvpOHh5VyMx5ydwFoKKcRs5x0_WwSWL0eTZ5WbVcHkDR9pSNcA_D_5AsUKOBcbpF5nkdVRxaQHuuIuwV4k1iK2IqtMNcU8vL6w21U261xCcWwJ6sMq4zzVO8QCKCQhsoIaWrwz828GDmPzfAjFsJiLJXuYivdHACkeJ5KHMt0mjVLpfJ2BCML7_rgbmvwL7wBW80VHfNdcKmKjkLcpEiPzwcQQhiN_qHV90t-p4iyr5xRSpurlP5zic2hlRkLKxMH2_kRjhqSn4aGF1dGhEYXRhWMQ93EcQ6cCIsinbqJ1WMiC7Ofcimv9GWwplaxr7mor4oEEAAAAAAAAAAAAAAAAAAAAAAAAAAABAVHzbxaYaJu2P8m1Y2iHn2gRNHrgK0iYbn9E978L3Qi7Q-chFeicIHwYCRophz5lth2nCgEVKcgWirxlgidgbUaUBAgMmIAEhWCDIkcsOaVKDIQYwq3EDQ-pST2kRwNH_l1nCgW-WcFpNXiJYIBSbummp-KO3qZeqmvZ_U_uirCDL2RNj3E5y4_KzefIr', + clientDataJSON: + 'eyJjaGFsbGVuZ2UiOiJkRzkwWVd4c2VWVnVhWEYxWlZaaGJIVmxSWFpsY25sQmRIUmxjM1JoZEdsdmJnIiwiY2xpZW50RXh0ZW5zaW9ucyI6e30sImhhc2hBbGdvcml0aG0iOiJTSEEtMjU2Iiwib3JpZ2luIjoiaHR0cHM6Ly9kZXYuZG9udG5lZWRhLnB3IiwidHlwZSI6IndlYmF1dGhuLmNyZWF0ZSJ9', + transports: [], + }, + type: 'public-key', + clientExtensionResults: {}, +}; + +const attestationPacked: PasskeyRegistrationResponse = { + id: 'bbb', + rawId: 'bbb', + response: { + attestationObject: + 'o2NmbXRmcGFja2VkZ2F0dFN0bXSiY2FsZyZjc2lnWEcwRQIhANvrPZMUFrl_rvlgR' + + 'qz6lCPlF6B4y885FYUCCrhrzAYXAiAb4dQKXbP3IimsTTadkwXQlrRVdxzlbmPXt847-Oh6r2hhdXRoRGF0YVjhP' + + 'dxHEOnAiLIp26idVjIguzn3Ipr_RlsKZWsa-5qK-KBFXsOO-a3OAAI1vMYKZIsLJfHwVQMAXQGE4WNXLCDWOCa2x' + + '8hpqk5dZy_xdc4wBd4UgCJ4M_JAHI7oJgDDVb8WUcKqRB_mzRxwCL9vdTl-ZKPXg3_-Zrt1Adgb7EnK9ivqaTOKM' + + 'DqRrKsIObWYJaqpsSJtUKUBAgMmIAEhWCBKMVVaivqCBpqqAxMjuCo5jMeUdh3jDOC0EF4fLBNNTyJYILc7rqDDe' + + 'X1pwCLrl3ZX7IThrtZNwKQVLQyfHiorqP-n', + clientDataJSON: + 'eyJjaGFsbGVuZ2UiOiJjelpRU1dKQ2JsQlFibkpIVGxOQ2VFNWtkRVJ5VkRkVmNsWlpT' + + 'a3M1U0UwIiwib3JpZ2luIjoiaHR0cHM6Ly9kZXYuZG9udG5lZWRhLnB3IiwidHlwZSI6IndlYmF1dGhuLmNyZWF0' + + 'ZSJ9', + transports: [], + }, + clientExtensionResults: {}, + type: 'public-key', +}; + +const attestationPackedX5C: PasskeyRegistrationResponse = { + id: 'aaa', + rawId: 'aaa', + response: { + attestationObject: + 'o2NmbXRmcGFja2VkZ2F0dFN0bXSjY2FsZyZjc2lnWEcwRQIhAIMt_hGMtdgpIVIwMOeKK' + + 'w0IkUUFkXSY8arKh3Q0c5QQAiB9Sv9JavAEmppeH_XkZjB7TFM3jfxsgl97iIkvuJOUImN4NWOBWQLBMIICvTCCAaWgA' + + 'wIBAgIEKudiYzANBgkqhkiG9w0BAQsFADAuMSwwKgYDVQQDEyNZdWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwM' + + 'DYzMTAgFw0xNDA4MDEwMDAwMDBaGA8yMDUwMDkwNDAwMDAwMFowbjELMAkGA1UEBhMCU0UxEjAQBgNVBAoMCVl1Ymljb' + + 'yBBQjEiMCAGA1UECwwZQXV0aGVudGljYXRvciBBdHRlc3RhdGlvbjEnMCUGA1UEAwweWXViaWNvIFUyRiBFRSBTZXJpY' + + 'WwgNzE5ODA3MDc1MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEKgOGXmBD2Z4R_xCqJVRXhL8Jr45rHjsyFykhb1USG' + + 'ozZENOZ3cdovf5Ke8fj2rxi5tJGn_VnW4_6iQzKdIaeP6NsMGowIgYJKwYBBAGCxAoCBBUxLjMuNi4xLjQuMS40MTQ4M' + + 'i4xLjEwEwYLKwYBBAGC5RwCAQEEBAMCBDAwIQYLKwYBBAGC5RwBAQQEEgQQbUS6m_bsLkm5MAyP6SDLczAMBgNVHRMBA' + + 'f8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQByV9A83MPhFWmEkNb4DvlbUwcjc9nmRzJjKxHc3HeK7GvVkm0H4XucVDB4j' + + 'eMvTke0WHb_jFUiApvpOHh5VyMx5ydwFoKKcRs5x0_WwSWL0eTZ5WbVcHkDR9pSNcA_D_5AsUKOBcbpF5nkdVRxaQHuu' + + 'IuwV4k1iK2IqtMNcU8vL6w21U261xCcWwJ6sMq4zzVO8QCKCQhsoIaWrwz828GDmPzfAjFsJiLJXuYivdHACkeJ5KHMt' + + '0mjVLpfJ2BCML7_rgbmvwL7wBW80VHfNdcKmKjkLcpEiPzwcQQhiN_qHV90t-p4iyr5xRSpurlP5zic2hlRkLKxMH2_k' + + 'RjhqSn4aGF1dGhEYXRhWMQ93EcQ6cCIsinbqJ1WMiC7Ofcimv9GWwplaxr7mor4oEEAAAAcbUS6m_bsLkm5MAyP6SDLc' + + 'wBA4rrvMciHCkdLQ2HghazIp1sMc8TmV8W8RgoX-x8tqV_1AmlqWACqUK8mBGLandr-htduQKPzgb2yWxOFV56TlqUBA' + + 'gMmIAEhWCBsJbGAjckW-AA_XMk8OnB-VUvrs35ZpjtVJXRhnvXiGiJYIL2ncyg_KesCi44GH8UcZXYwjBkVdGMjNd6LF' + + 'myiD6xf', + clientDataJSON: + 'eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiZEc5MFlXeHNlVlZ1YVhG' + + 'MVpWWmhiSFZsUlhabGNubFVhVzFsIiwib3JpZ2luIjoiaHR0cHM6Ly9kZXYuZG9udG5lZWRhLnB3In0=', + transports: [], + }, + type: 'public-key', + clientExtensionResults: {}, +}; + +const noneChallenge = decodeClientDataJSON( + attestationNone.response.clientDataJSON, +).challenge; + +const fidoU2fChallenge = decodeClientDataJSON( + attestationFIDOU2F.response.clientDataJSON, +).challenge; + +const packedChallenge = decodeClientDataJSON( + attestationPacked.response.clientDataJSON, +).challenge; + +const packedX5cChallenge = decodeClientDataJSON( + attestationPackedX5C.response.clientDataJSON, +).challenge; + +const TEST_RP_ID = 'example.com'; +const TEST_ORIGIN = 'https://example.com'; +const TEST_CHALLENGE = bytesToBase64URL(new Uint8Array(32).fill(0xab)); + +function makeClientDataJSON( + overrides?: Partial<{ + type: string; + challenge: string; + origin: string; + }>, +): string { + const json = JSON.stringify({ + type: overrides?.type ?? 'webauthn.create', + challenge: overrides?.challenge ?? TEST_CHALLENGE, + origin: overrides?.origin ?? TEST_ORIGIN, + }); + return bytesToBase64URL(new TextEncoder().encode(json)); +} + +function buildCosePublicKeyMap( + pubKeyBytes: Uint8Array, +): Map { + const map = new Map(); + map.set(COSEKEYS.Kty, COSEKTY.EC2); + map.set(COSEKEYS.Alg, COSEALG.ES256); + map.set(COSEKEYS.Crv, COSECRV.P256); + map.set(COSEKEYS.X, pubKeyBytes.slice(1, 33)); + map.set(COSEKEYS.Y, pubKeyBytes.slice(33, 65)); + return map; +} + +function generateES256KeyPair(): { + privateKey: Uint8Array; + cosePublicKeyCBOR: Uint8Array; +} { + const privateKey = p256.utils.randomPrivateKey(); + const publicKeyRaw = p256.getPublicKey(privateKey, false); + const coseMap = buildCosePublicKeyMap(publicKeyRaw); + const cosePublicKeyCBOR = encodeCBOR(coseMap); + return { privateKey, cosePublicKeyCBOR }; +} + +function buildAuthenticatorData(opts: { + rpIdHash: Uint8Array; + flags: number; + counter: number; + aaguid?: Uint8Array; + credentialID?: Uint8Array; + credentialPublicKey?: Uint8Array; +}): Uint8Array { + const parts: Uint8Array[] = []; + parts.push(opts.rpIdHash); + parts.push(new Uint8Array([opts.flags])); + + const counterBuf = new Uint8Array(4); + new DataView(counterBuf.buffer).setUint32(0, opts.counter, false); + parts.push(counterBuf); + + if (opts.aaguid && opts.credentialID && opts.credentialPublicKey) { + parts.push(opts.aaguid); + + const credIDLen = new Uint8Array(2); + new DataView(credIDLen.buffer).setUint16( + 0, + opts.credentialID.length, + false, + ); + parts.push(credIDLen); + parts.push(opts.credentialID); + parts.push(opts.credentialPublicKey); + } + + let totalLength = 0; + for (const part of parts) { + totalLength += part.length; + } + const result = new Uint8Array(totalLength); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.length; + } + return result; +} + +function buildAttestationObject( + authData: Uint8Array, + fmt: string = 'none', + attStmt: Map = new Map(), +): Uint8Array { + const map = new Map(); + map.set('fmt', fmt); + map.set('attStmt', attStmt); + map.set('authData', authData); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return encodeCBOR(map as any); +} + +function buildRegistrationResponse( + authData: Uint8Array, + credentialId: string, + fmt: string = 'none', + attStmt: Map = new Map(), + clientDataJSONOverrides?: Partial<{ + type: string; + challenge: string; + origin: string; + }>, +): PasskeyRegistrationResponse { + const attestationObject = buildAttestationObject(authData, fmt, attStmt); + return { + id: credentialId, + rawId: credentialId, + type: 'public-key', + response: { + clientDataJSON: makeClientDataJSON(clientDataJSONOverrides), + attestationObject: bytesToBase64URL(attestationObject), + }, + clientExtensionResults: {}, + }; +} + +describe('verifyRegistrationResponse', () => { + it('verifies none attestation', async () => { + const verification = await verifyRegistrationResponse({ + response: attestationNone, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + }); + + expect(verification.verified).toBe(true); + if (!verification.verified) { + return; + } + + const { registrationInfo } = verification; + expect(registrationInfo.attestationFormat).toBe('none'); + expect(registrationInfo.counter).toBe(0); + expect(registrationInfo.publicKey).toStrictEqual( + base64URLToBytes( + 'pQECAyYgASFYID5PQTZQQg6haZFQWFzqfAOyQ_ENsMH8xxQ4GRiNPsqrIlggU8IVUOV8qpgk_Jh-OTaLuZL52KdX1fTht07X4DiQPow', + ), + ); + expect(registrationInfo.credentialId).toBe( + 'AdKXJEch1aV5Wo7bj7qLHskVY4OoNaj9qu8TPdJ7kSAgUeRxWNngXlcNIGt4gexZGKVGcqZpqqWordXb_he1izY', + ); + expect(registrationInfo.aaguid).toBe( + '00000000-0000-0000-0000-000000000000', + ); + // authData flags byte is 0x45 (UP | UV | AT); UV is set in this vector. + expect(registrationInfo.userVerified).toBe(true); + }); + + it('verifies packed self-attestation (SimpleWebAuthn conformance vector)', async () => { + const verification = await verifyRegistrationResponse({ + response: attestationPacked, + expectedChallenge: packedChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + }); + + expect(verification.verified).toBe(true); + if (!verification.verified) { + return; + } + + expect(verification.registrationInfo.attestationFormat).toBe('packed'); + expect(verification.registrationInfo.counter).toBe(1589874425); + expect(verification.registrationInfo.publicKey).toStrictEqual( + base64URLToBytes( + 'pQECAyYgASFYIEoxVVqK-oIGmqoDEyO4KjmMx5R2HeMM4LQQXh8sE01PIlggtzuuoMN5fWnAIuuXdlfshOGu1k3ApBUtDJ8eKiuo_6c', + ), + ); + expect(verification.registrationInfo.credentialId).toBe( + 'AYThY1csINY4JrbHyGmqTl1nL_F1zjAF3hSAIngz8kAcjugmAMNVvxZRwqpEH-bNHHAIv291OX5ko9eDf_5mu3UB2BvsScr2K-ppM4owOpGsqwg5tZglqqmxIm1Q', + ); + }); + + it('rejects when response challenge is not expected value', async () => { + await expect( + verifyRegistrationResponse({ + response: attestationNone, + expectedChallenge: 'shouldhavebeenthisvalue', + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + }), + ).rejects.toThrow('Unexpected registration response challenge'); + }); + + it('rejects when response origin is not expected value', async () => { + await expect( + verifyRegistrationResponse({ + response: attestationNone, + expectedChallenge: noneChallenge, + expectedOrigin: 'https://different.address', + expectedRPID: EXPECTED_RP_ID, + }), + ).rejects.toThrow('Unexpected registration response origin'); + }); + + it('rejects when RP ID is not expected value', async () => { + await expect( + verifyRegistrationResponse({ + response: attestationNone, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: 'wrong-rp.com', + }), + ).rejects.toThrow('Unexpected RP ID hash'); + }); + + it('rejects wrong clientDataJSON type', async () => { + const badTypeClientDataJSON = btoa( + JSON.stringify({ + ...decodeClientDataJSON(attestationNone.response.clientDataJSON), + type: 'webauthn.get', + }), + ) + .replace(/\+/gu, '-') + .replace(/\//gu, '_') + .replace(/[=]+$/u, ''); + + await expect( + verifyRegistrationResponse({ + response: { + ...attestationNone, + response: { + ...attestationNone.response, + clientDataJSON: badTypeClientDataJSON, + }, + }, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + }), + ).rejects.toThrow('Unexpected registration response type'); + }); + + it('rejects missing credential ID', async () => { + await expect( + verifyRegistrationResponse({ + response: { + ...attestationNone, + id: '', + rawId: '', + }, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + }), + ).rejects.toThrow('Missing credential ID'); + }); + + it('rejects fido-u2f attestation as unsupported format', async () => { + await expect( + verifyRegistrationResponse({ + response: attestationFIDOU2F, + expectedChallenge: fidoU2fChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + requireUserVerification: false, + }), + ).rejects.toThrow('Unsupported attestation format: fido-u2f'); + }); + + it('rejects packed attestation with x5c certificate chain', async () => { + await expect( + verifyRegistrationResponse({ + response: attestationPackedX5C, + expectedChallenge: packedX5cChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + requireUserVerification: false, + }), + ).rejects.toThrow( + 'Packed attestation with certificate chain (x5c) is not supported', + ); + }); +}); + +describe('verifyRegistrationResponse edge cases', () => { + it('rejects id !== rawId', async () => { + const response: PasskeyRegistrationResponse = { + id: 'id1', + rawId: 'id2', + type: 'public-key', + response: { + clientDataJSON: makeClientDataJSON(), + attestationObject: bytesToBase64URL(new Uint8Array([0])), + }, + clientExtensionResults: {}, + }; + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }), + ).rejects.toThrow('Credential ID was not base64url-encoded'); + }); + + it('rejects wrong credential type', async () => { + const response = { + id: 'abc', + rawId: 'abc', + type: 'not-public-key', + response: { + clientDataJSON: makeClientDataJSON(), + attestationObject: bytesToBase64URL(new Uint8Array([0])), + }, + clientExtensionResults: {}, + } as unknown as PasskeyRegistrationResponse; + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }), + ).rejects.toThrow('Unexpected credential type'); + }); + + it('rejects user verification not met when required', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x30); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: cosePublicKeyCBOR, + }); + + const credentialIdB64 = bytesToBase64URL(credentialID); + const response = buildRegistrationResponse(authData, credentialIdB64); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + requireUserVerification: true, + }), + ).rejects.toThrow('User verification was required'); + }); + + it('rejects user not present', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x31); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x40, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: cosePublicKeyCBOR, + }); + + const credentialIdB64 = bytesToBase64URL(credentialID); + const response = buildRegistrationResponse(authData, credentialIdB64); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }), + ).rejects.toThrow('User presence was required'); + }); + + it('rejects unsupported public key algorithm', async () => { + const unsupportedMap = new Map(); + unsupportedMap.set(COSEKEYS.Kty, COSEKTY.EC2); + unsupportedMap.set(COSEKEYS.Alg, -999); + unsupportedMap.set(COSEKEYS.Crv, COSECRV.P256); + unsupportedMap.set(COSEKEYS.X, new Uint8Array(32).fill(0x01)); + unsupportedMap.set(COSEKEYS.Y, new Uint8Array(32).fill(0x02)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const unsupportedKeyCBOR = encodeCBOR(unsupportedMap as any); + + const credentialID = new Uint8Array(16).fill(0x32); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: unsupportedKeyCBOR, + }); + + const credentialIdB64 = bytesToBase64URL(credentialID); + const response = buildRegistrationResponse(authData, credentialIdB64); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }), + ).rejects.toThrow('Unexpected public key alg'); + }); + + it('rejects packed attestation with missing alg', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x61); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: cosePublicKeyCBOR, + }); + + const attStmt = new Map(); + attStmt.set('sig', new Uint8Array(64)); + + const credentialIdB64 = bytesToBase64URL(credentialID); + const response = buildRegistrationResponse( + authData, + credentialIdB64, + 'packed', + attStmt, + ); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }), + ).rejects.toThrow('Packed attestation statement missing alg'); + }); + + it('rejects packed attestation with mismatched alg', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x62); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: cosePublicKeyCBOR, + }); + + const attStmt = new Map(); + attStmt.set('alg', COSEALG.RS256); + attStmt.set('sig', new Uint8Array(64)); + + const credentialIdB64 = bytesToBase64URL(credentialID); + const response = buildRegistrationResponse( + authData, + credentialIdB64, + 'packed', + attStmt, + ); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }), + ).rejects.toThrow('does not match credential alg'); + }); + + it('rejects packed attestation with missing signature', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x35); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: cosePublicKeyCBOR, + }); + + const attStmt = new Map(); + attStmt.set('alg', COSEALG.ES256); + + const credentialIdB64 = bytesToBase64URL(credentialID); + const response = buildRegistrationResponse( + authData, + credentialIdB64, + 'packed', + attStmt, + ); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }), + ).rejects.toThrow('Packed attestation missing signature'); + }); + + it('rejects none attestation with non-empty attStmt', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x37); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: cosePublicKeyCBOR, + }); + + const attStmt = new Map(); + attStmt.set('unexpected', 'value'); + + const credentialIdB64 = bytesToBase64URL(credentialID); + const response = buildRegistrationResponse( + authData, + credentialIdB64, + 'none', + attStmt, + ); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }), + ).rejects.toThrow('None attestation had unexpected attestation statement'); + }); + + it('accepts expectedOrigin as array', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x38); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: cosePublicKeyCBOR, + }); + + const credentialIdB64 = bytesToBase64URL(credentialID); + const response = buildRegistrationResponse(authData, credentialIdB64); + + const result = await verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: ['https://other.com', TEST_ORIGIN], + expectedRPID: TEST_RP_ID, + }); + + expect(result.verified).toBe(true); + }); +}); + +describe('verifyRegistrationResponse missing public key fields', () => { + it('rejects public key missing alg field', async () => { + const coseMapNoAlg = new Map(); + coseMapNoAlg.set(COSEKEYS.Kty, COSEKTY.EC2); + coseMapNoAlg.set(COSEKEYS.Crv, COSECRV.P256); + coseMapNoAlg.set(COSEKEYS.X, new Uint8Array(32).fill(0x01)); + coseMapNoAlg.set(COSEKEYS.Y, new Uint8Array(32).fill(0x02)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const coseNoAlgCBOR = encodeCBOR(coseMapNoAlg as any); + + const credentialID = new Uint8Array(16).fill(0x40); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: coseNoAlgCBOR, + }); + + const credentialIdB64 = bytesToBase64URL(credentialID); + const response = buildRegistrationResponse(authData, credentialIdB64); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }), + ).rejects.toThrow('Credential public key was missing numeric alg'); + }); +}); diff --git a/packages/passkey-controller/src/webauthn/verify-signature.test.ts b/packages/passkey-controller/src/webauthn/verify-signature.test.ts new file mode 100644 index 00000000000..9c1084140a5 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/verify-signature.test.ts @@ -0,0 +1,369 @@ +import { ed25519 } from '@noble/curves/ed25519'; +import { p384 } from '@noble/curves/nist'; +import { sha384 } from '@noble/hashes/sha2'; + +import { base64URLToBytes } from '../utils/encoding'; +import { COSEALG, COSECRV, COSEKEYS, COSEKTY } from './constants'; +import { verifySignature } from './verify-signature'; + +function decodeJwkBase64Url(value: string): Uint8Array { + return Uint8Array.from( + atob( + value.replace(/-/gu, '+').replace(/_/gu, '/') + + '='.repeat((4 - (value.length % 4)) % 4), + ), + (char) => char.charCodeAt(0), + ); +} + +describe('verifySignature', () => { + it('verifies P-256 EC2 signature from conformance vector', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.EC2); + coseMap.set(COSEKEYS.Alg, COSEALG.ES256); + coseMap.set(COSEKEYS.Crv, COSECRV.P256); + coseMap.set( + COSEKEYS.X, + base64URLToBytes('_qRi-kwOVobsqJ_1GAHZYfC77QoIdsVFYkx2Mw20UM4'), + ); + coseMap.set( + COSEKEYS.Y, + base64URLToBytes('BXEathwyOK_uQRmlZ_m4wReHLujSXk_-e3-9co5B2MY'), + ); + + const data = base64URLToBytes('Bt81jmu3ieajF4w1at8HmieVOTDymHd7xJguJCUsL-Q'); + const signature = base64URLToBytes( + 'MEQCH1h_F7TPTMVh_kwb_ssjD0_2U77bbXazz2ux-P6khLQCIQCutHs9eCBkCIMP3yA9mmNRKEfFd-REmhGY2GbHozaC7w', + ); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + + it('verifies P-384 EC2 signature', async () => { + const privateKey = p384.utils.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 P-384 EC2 signature from conformance vector', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.EC2); + coseMap.set(COSEKEYS.Alg, COSEALG.ES384); + coseMap.set(COSEKEYS.Crv, COSECRV.P384); + coseMap.set( + COSEKEYS.X, + base64URLToBytes( + 'pm-0exykk1x0O72S9sm6fl-iXxFrGikjQHi1CgONIiEz_yDJdCPxN453qg6HLkOx', + ), + ); + coseMap.set( + COSEKEYS.Y, + base64URLToBytes( + '2B7yW7sgza8Sf7ifznQlGJqmJxgupkAevUqqOJTWaWBZiQ7sAf-TfAaNBukiz12K', + ), + ); + + const data = base64URLToBytes('D7mI8UwWXv4rpfSQUNqtUXAhZEPbRLugmWclPpJ9m7c'); + const signature = base64URLToBytes( + 'MGMCL3lZ2Rjxo5WcmTCdWyB6jTE9PVuduOR_AsJu956J9S_mFNbHP_-MbyWem4dfb5iqAjABJhTRltNl5Y0O4XC7YLNsYKq2WxYQ1HFOMGsr6oNkUPsX3UAr2zeeWL_Tp1VgHeM', + ); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + + it('verifies 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 signature = ed25519.sign(data, privateKey); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + + it('verifies Ed25519 OKP signature from conformance vector', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.OKP); + coseMap.set(COSEKEYS.Alg, COSEALG.EdDSA); + coseMap.set(COSEKEYS.Crv, COSECRV.ED25519); + coseMap.set( + COSEKEYS.X, + base64URLToBytes('bN-2dTH53XfUq55T1RkvXMpwHV0dRVnMBPxuOBm1-vI'), + ); + + const data = base64URLToBytes( + 'SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NBAAAAMpHf6teVnkR1rSabDUgr4IkAIBqlqljErWIWWTGYn6Lqjsb8p3djr7sVZW7WYoECyh5xpAEBAycgBiFYIGzftnUx-d131KueU9UZL1zKcB1dHUVZzAT8bjgZtfrytEHOGqAdESuKacg0dIwKWfEP8VP4or6CINxkD5qWQYw', + ); + const signature = base64URLToBytes( + 'HdoQloEiGSUHf9dJXbVzyWNbDh0K25tpNQQpj5hrkhCcdfz0pCBPtqChka_4kfIbhf6JyY1EGAuf9pQdwqJVBQ', + ); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + + it('throws for unsupported EC2 curve', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.EC2); + coseMap.set(COSEKEYS.Alg, COSEALG.ES256); + coseMap.set(COSEKEYS.Crv, 99); + coseMap.set(COSEKEYS.X, new Uint8Array(32)); + coseMap.set(COSEKEYS.Y, new Uint8Array(32)); + + await expect( + verifySignature({ + cosePublicKey: coseMap, + signature: new Uint8Array(64), + data: new Uint8Array(32), + }), + ).rejects.toThrow('Unsupported EC2 curve'); + }); + + it('throws for missing EC2 coordinates', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.EC2); + coseMap.set(COSEKEYS.Alg, COSEALG.ES256); + coseMap.set(COSEKEYS.Crv, COSECRV.P256); + + await expect( + verifySignature({ + cosePublicKey: coseMap, + signature: new Uint8Array(64), + data: new Uint8Array(32), + }), + ).rejects.toThrow('EC2 public key missing x or y coordinate'); + }); + + it('throws for missing 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); + + const nBytes = decodeJwkBase64Url(jwk.n as string); + const eBytes = decodeJwkBase64Url(jwk.e as string); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.Alg, COSEALG.RS256); + coseMap.set(-1, nBytes); + coseMap.set(-2, eBytes); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + /* 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'); + }); +}); + +/* eslint-disable n/no-unsupported-features/node-builtins */ +describe('verifySignature RSA hash variants', () => { + 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 = decodeJwkBase64Url(jwk.n as string); + const eBytes = decodeJwkBase64Url(jwk.e as string); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.Alg, alg); + coseMap.set(-1, nBytes); + coseMap.set(-2, eBytes); + + return { coseMap, signature, data }; + } + + it('verifies RS384 signature', async () => { + const { coseMap, signature, data } = await generateRSAKeyPairAndSign( + 'SHA-384', + COSEALG.RS384, + ); + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + expect(result).toBe(true); + }); + + it('verifies RS512 signature', async () => { + const { coseMap, signature, data } = await generateRSAKeyPairAndSign( + 'SHA-512', + COSEALG.RS512, + ); + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + expect(result).toBe(true); + }); +}); +/* eslint-enable n/no-unsupported-features/node-builtins */ diff --git a/packages/passkey-controller/src/webauthn/webauthn.test.ts b/packages/passkey-controller/src/webauthn/webauthn.test.ts deleted file mode 100644 index b8202d80764..00000000000 --- a/packages/passkey-controller/src/webauthn/webauthn.test.ts +++ /dev/null @@ -1,1952 +0,0 @@ -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 { bytesToBase64URL } from '../utils/encoding'; -import { COSEALG, COSEKEYS, COSEKTY, COSECRV } from './constants'; -import type { - PasskeyRegistrationResponse, - PasskeyAuthenticationResponse, -} from './types'; -import { verifyAuthenticationResponse } from './verify-authentication-response'; -import { verifyRegistrationResponse } from './verify-registration-response'; - -// --------------------------------------------------------------------------- -// 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, - }, - }); - - if (!result.verified) { - throw new Error('expected verified result'); - } - expect(result.authenticationInfo.newCounter).toBe(1); - expect(result.authenticationInfo.rpID).toBe(TEST_RP_ID); - }); - - it('returns { verified: false } when the signature does not verify', async () => { - const { cosePublicKeyCBOR } = generateES256KeyPair(); - // Sign with a different key so the public key does not match the signature. - const { privateKey: otherPrivateKey } = 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, otherPrivateKey); - - const credentialIdB64 = bytesToBase64URL(new Uint8Array(16).fill(0x11)); - - 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(false); - expect(result.authenticationInfo).toBeUndefined(); - }); - - 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, - }, - }); - - if (!result.verified) { - throw new Error('expected verified result'); - } - 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('./verify-signature'); - - 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('./parse-authenticator-data'); - /* 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('./match-expected-rp-id'); - - 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('./verify-signature'); - /* 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 */ From a0298b5a04124fc79545a33fcfa6a20093f9a485 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Wed, 22 Apr 2026 16:52:36 +0700 Subject: [PATCH 29/44] feat: update verify siganture to keep parity with simplewebauthn --- .../src/webauthn/verify-signature.test.ts | 211 ++++++++++++++++-- .../src/webauthn/verify-signature.ts | 70 +++++- 2 files changed, 258 insertions(+), 23 deletions(-) diff --git a/packages/passkey-controller/src/webauthn/verify-signature.test.ts b/packages/passkey-controller/src/webauthn/verify-signature.test.ts index 9c1084140a5..8ec5736964d 100644 --- a/packages/passkey-controller/src/webauthn/verify-signature.test.ts +++ b/packages/passkey-controller/src/webauthn/verify-signature.test.ts @@ -1,6 +1,8 @@ +import { webcrypto } from 'node:crypto'; + import { ed25519 } from '@noble/curves/ed25519'; -import { p384 } from '@noble/curves/nist'; -import { sha384 } from '@noble/hashes/sha2'; +import { p384, p521 } from '@noble/curves/nist'; +import { sha384, sha512 } from '@noble/hashes/sha2'; import { base64URLToBytes } from '../utils/encoding'; import { COSEALG, COSECRV, COSEKEYS, COSEKTY } from './constants'; @@ -46,7 +48,7 @@ describe('verifySignature', () => { }); it('verifies P-384 EC2 signature', async () => { - const privateKey = p384.utils.randomPrivateKey(); + const privateKey = p384.utils.randomSecretKey(); const publicKeyRaw = p384.getPublicKey(privateKey, false); const coseMap = new Map(); @@ -101,8 +103,40 @@ describe('verifySignature', () => { expect(result).toBe(true); }); + it('verifies P-521 EC2 signature from conformance vector', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.EC2); + coseMap.set(COSEKEYS.Alg, COSEALG.ES512); + coseMap.set(COSEKEYS.Crv, COSECRV.P521); + coseMap.set( + COSEKEYS.X, + base64URLToBytes( + 'AaLbnrCvCuQivbknRW50FjdqPQv4NRF9tHsN4QuVQ3sw8uSspd33o-NTBfjg5JzX9rnpbkKDigb6NugmrVjzNMNK', + ), + ); + coseMap.set( + COSEKEYS.Y, + base64URLToBytes( + 'AE64axa8L8PkLX5Td0GaX79cLOW9E2-8-ObhL9XT_ih-1XxbGQcA5VhL1gI0xIQq5zYAxgZYey6PmbbqgtcUPRVt', + ), + ); + + const data = base64URLToBytes('5p0h9RZTjLoBlnL2nY5pqOnhGy4q60NzbjDe2rVDR7o'); + const signature = base64URLToBytes( + 'MIGHAkFRpbGknlgpETORypMprGBXMkJMfuqgJupy3NcgCOaJJdj3Voz74kV2pjPqkLNpuO9FqVtXeEsUw-jYsBHcMqHZhwJCAQ88uFDJS5g81XVBcLMIgf6ro-F-5jgRAmHx3CRVNGdk81MYbFJhT3hd2w9RdhT8qBG0zzRBXYAcHrKo0qJwQZot', + ); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + it('verifies Ed25519 OKP signature', async () => { - const privateKey = ed25519.utils.randomPrivateKey(); + const privateKey = ed25519.utils.randomSecretKey(); const publicKey = ed25519.getPublicKey(privateKey); const coseMap = new Map(); @@ -196,6 +230,44 @@ describe('verifySignature', () => { ).rejects.toThrow('OKP public key missing x coordinate'); }); + it('throws for unsupported OKP algorithm', async () => { + const privateKey = ed25519.utils.randomSecretKey(); + const publicKey = ed25519.getPublicKey(privateKey); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.OKP); + coseMap.set(COSEKEYS.Alg, COSEALG.ES256); + coseMap.set(COSEKEYS.Crv, COSECRV.ED25519); + coseMap.set(COSEKEYS.X, publicKey); + + await expect( + verifySignature({ + cosePublicKey: coseMap, + signature: ed25519.sign(new Uint8Array(32), privateKey), + data: new Uint8Array(32), + }), + ).rejects.toThrow('Unexpected OKP algorithm'); + }); + + it('throws for unsupported OKP curve', async () => { + const privateKey = ed25519.utils.randomSecretKey(); + const publicKey = ed25519.getPublicKey(privateKey); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.OKP); + coseMap.set(COSEKEYS.Alg, COSEALG.EdDSA); + coseMap.set(COSEKEYS.Crv, COSECRV.P256); + coseMap.set(COSEKEYS.X, publicKey); + + await expect( + verifySignature({ + cosePublicKey: coseMap, + signature: ed25519.sign(new Uint8Array(32), privateKey), + data: new Uint8Array(32), + }), + ).rejects.toThrow('Unsupported OKP curve'); + }); + it('throws for missing kty', async () => { const coseMap = new Map(); coseMap.set(COSEKEYS.Alg, COSEALG.ES256); @@ -223,9 +295,8 @@ describe('verifySignature', () => { ).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( + const keyPair = await webcrypto.subtle.generateKey( { name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, @@ -238,14 +309,14 @@ describe('verifySignature', () => { const data = new Uint8Array(32).fill(0xee); const signature = new Uint8Array( - await globalThis.crypto.subtle.sign( + await webcrypto.subtle.sign( 'RSASSA-PKCS1-v1_5', keyPair.privateKey, data, ), ); - const jwk = await globalThis.crypto.subtle.exportKey('jwk', keyPair.publicKey); + const jwk = await webcrypto.subtle.exportKey('jwk', keyPair.publicKey); const nBytes = decodeJwkBase64Url(jwk.n as string); const eBytes = decodeJwkBase64Url(jwk.e as string); @@ -264,7 +335,6 @@ describe('verifySignature', () => { expect(result).toBe(true); }); - /* eslint-enable n/no-unsupported-features/node-builtins */ it('throws for unsupported RSA algorithm', async () => { const coseMap = new Map(); @@ -295,9 +365,99 @@ describe('verifySignature', () => { }), ).rejects.toThrow('RSA public key missing n or e'); }); + + it('verifies PS256 signature via Web Crypto', async () => { + const keyPair = await webcrypto.subtle.generateKey( + { + name: 'RSA-PSS', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: { name: 'SHA-256' }, + }, + true, + ['sign', 'verify'], + ); + + const data = new Uint8Array(32).fill(0x9a); + const signature = new Uint8Array( + await webcrypto.subtle.sign( + { name: 'RSA-PSS', saltLength: 32 }, + keyPair.privateKey, + data, + ), + ); + + const jwk = await webcrypto.subtle.exportKey('jwk', keyPair.publicKey); + const nBytes = decodeJwkBase64Url(jwk.n as string); + const eBytes = decodeJwkBase64Url(jwk.e as string); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.Alg, COSEALG.PS256); + coseMap.set(COSEKEYS.N, nBytes); + coseMap.set(COSEKEYS.E, eBytes); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + + it('verifies RS256 with subarray buffers', async () => { + const keyPair = await webcrypto.subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: { name: 'SHA-256' }, + }, + true, + ['sign', 'verify'], + ); + + const data = new Uint8Array(32).fill(0x7c); + const signature = new Uint8Array( + await webcrypto.subtle.sign( + 'RSASSA-PKCS1-v1_5', + keyPair.privateKey, + data, + ), + ); + + const signatureContainer = new Uint8Array(signature.length + 8); + signatureContainer.set(signature, 4); + const signatureSubarray = signatureContainer.subarray( + 4, + 4 + signature.length, + ); + + const dataContainer = new Uint8Array(data.length + 10); + dataContainer.set(data, 5); + const dataSubarray = dataContainer.subarray(5, 5 + data.length); + + const jwk = await webcrypto.subtle.exportKey('jwk', keyPair.publicKey); + const nBytes = decodeJwkBase64Url(jwk.n as string); + const eBytes = decodeJwkBase64Url(jwk.e as string); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.Alg, COSEALG.RS256); + coseMap.set(COSEKEYS.N, nBytes); + coseMap.set(COSEKEYS.E, eBytes); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature: signatureSubarray, + data: dataSubarray, + }); + + expect(result).toBe(true); + }); }); -/* eslint-disable n/no-unsupported-features/node-builtins */ describe('verifySignature RSA hash variants', () => { async function generateRSAKeyPairAndSign( hashName: string, @@ -307,7 +467,7 @@ describe('verifySignature RSA hash variants', () => { signature: Uint8Array; data: Uint8Array; }> { - const keyPair = await globalThis.crypto.subtle.generateKey( + const keyPair = await webcrypto.subtle.generateKey( { name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, @@ -320,14 +480,14 @@ describe('verifySignature RSA hash variants', () => { const data = new Uint8Array(32).fill(0xff); const signature = new Uint8Array( - await globalThis.crypto.subtle.sign( + await webcrypto.subtle.sign( 'RSASSA-PKCS1-v1_5', keyPair.privateKey, data, ), ); - const jwk = await globalThis.crypto.subtle.exportKey('jwk', keyPair.publicKey); + const jwk = await webcrypto.subtle.exportKey('jwk', keyPair.publicKey); const nBytes = decodeJwkBase64Url(jwk.n as string); const eBytes = decodeJwkBase64Url(jwk.e as string); @@ -365,5 +525,28 @@ describe('verifySignature RSA hash variants', () => { }); expect(result).toBe(true); }); + + it('verifies ES512/P-521 signature', async () => { + const privateKey = p521.utils.randomSecretKey(); + const publicKeyRaw = p521.getPublicKey(privateKey, false); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.EC2); + coseMap.set(COSEKEYS.Alg, COSEALG.ES512); + coseMap.set(COSEKEYS.Crv, COSECRV.P521); + coseMap.set(COSEKEYS.X, publicKeyRaw.slice(1, 67)); + coseMap.set(COSEKEYS.Y, publicKeyRaw.slice(67, 133)); + + const data = new Uint8Array(32).fill(0xab); + const hash = sha512(data); + const ecdsaSig = p521.sign(hash, privateKey); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature: new Uint8Array(ecdsaSig.toDERRawBytes()), + data, + }); + + expect(result).toBe(true); + }); }); -/* eslint-enable n/no-unsupported-features/node-builtins */ diff --git a/packages/passkey-controller/src/webauthn/verify-signature.ts b/packages/passkey-controller/src/webauthn/verify-signature.ts index e6dbe9f7a9a..fa7a5662c43 100644 --- a/packages/passkey-controller/src/webauthn/verify-signature.ts +++ b/packages/passkey-controller/src/webauthn/verify-signature.ts @@ -1,7 +1,7 @@ import { concatBytes } from '@metamask/utils'; import { ed25519 } from '@noble/curves/ed25519'; -import { p256, p384 } from '@noble/curves/nist'; -import { sha256, sha384 } from '@noble/hashes/sha2'; +import { p256, p384, p521 } from '@noble/curves/nist'; +import { sha256, sha384, sha512 } from '@noble/hashes/sha2'; import { bytesToBase64URL } from '../utils/encoding'; import { COSEALG, COSECRV, COSEKEYS, COSEKTY } from './constants'; @@ -23,7 +23,7 @@ function getKeyType(cosePublicKey: COSEPublicKey): number { } /** - * Verify an EC2 (P-256, P-384) signature using @noble/curves. + * Verify an EC2 (P-256, P-384, P-521) signature using @noble/curves. * * ECDSA requires the data to be hashed with the curve-appropriate * algorithm before verification: SHA-256 for P-256 and SHA-384 for P-384. @@ -38,10 +38,15 @@ function verifyEC2( signature: Uint8Array, data: Uint8Array, ): boolean { + const alg = cosePublicKey.get(COSEKEYS.Alg); const crv = cosePublicKey.get(COSEKEYS.Crv) as number; const xCoord = cosePublicKey.get(COSEKEYS.X) as Uint8Array; const yCoord = cosePublicKey.get(COSEKEYS.Y) as Uint8Array; + if (typeof alg !== 'number') { + throw new Error('EC2 public key missing alg'); + } + if (!xCoord || !yCoord) { throw new Error('EC2 public key missing x or y coordinate'); } @@ -57,6 +62,8 @@ function verifyEC2( return p256.verify(signature, sha256(data), uncompressed); case COSECRV.P384: return p384.verify(signature, sha384(data), uncompressed); + case COSECRV.P521: + return p521.verify(signature, sha512(data), uncompressed); default: throw new Error(`Unsupported EC2 curve: ${crv}`); } @@ -75,8 +82,18 @@ function verifyOKP( signature: Uint8Array, data: Uint8Array, ): boolean { + const alg = cosePublicKey.get(COSEKEYS.Alg); + const crv = cosePublicKey.get(COSEKEYS.Crv); const xCoord = cosePublicKey.get(COSEKEYS.X) as Uint8Array; + if (alg !== COSEALG.EdDSA) { + throw new Error(`Unexpected OKP algorithm: ${String(alg)}`); + } + + if (crv !== COSECRV.ED25519) { + throw new Error(`Unsupported OKP curve: ${String(crv)}`); + } + if (!xCoord) { throw new Error('OKP public key missing x coordinate'); } @@ -85,7 +102,7 @@ function verifyOKP( } /** - * Verify an RSA (RS256/RS384/RS512) signature using Web Crypto API. + * Verify an RSA signature using Web Crypto API. * * @param cosePublicKey - COSE-encoded RSA public key. * @param signature - RSA PKCS#1 v1.5 signature. @@ -97,24 +114,52 @@ async function verifyRSA( signature: Uint8Array, data: Uint8Array, ): Promise { - const alg = cosePublicKey.get(COSEKEYS.Alg) as number; + const alg = cosePublicKey.get(COSEKEYS.Alg); const modulus = cosePublicKey.get(COSEKEYS.N) as Uint8Array; const exponent = cosePublicKey.get(COSEKEYS.E) as Uint8Array; + if (typeof alg !== 'number') { + throw new Error('RSA public key missing alg'); + } + if (!modulus || !exponent) { throw new Error('RSA public key missing n or e'); } + let keyAlgorithmName: 'RSASSA-PKCS1-v1_5' | 'RSA-PSS'; let hashAlg: string; + let saltLength: number | undefined; switch (alg) { + case COSEALG.RS1: + keyAlgorithmName = 'RSASSA-PKCS1-v1_5'; + hashAlg = 'SHA-1'; + break; case COSEALG.RS256: + keyAlgorithmName = 'RSASSA-PKCS1-v1_5'; hashAlg = 'SHA-256'; break; case COSEALG.RS384: + keyAlgorithmName = 'RSASSA-PKCS1-v1_5'; hashAlg = 'SHA-384'; break; case COSEALG.RS512: + keyAlgorithmName = 'RSASSA-PKCS1-v1_5'; + hashAlg = 'SHA-512'; + break; + case COSEALG.PS256: + keyAlgorithmName = 'RSA-PSS'; + hashAlg = 'SHA-256'; + saltLength = 32; + break; + case COSEALG.PS384: + keyAlgorithmName = 'RSA-PSS'; + hashAlg = 'SHA-384'; + saltLength = 48; + break; + case COSEALG.PS512: + keyAlgorithmName = 'RSA-PSS'; hashAlg = 'SHA-512'; + saltLength = 64; break; default: throw new Error(`Unsupported RSA algorithm: ${alg}`); @@ -127,16 +172,23 @@ async function verifyRSA( n: bytesToBase64URL(modulus), e: bytesToBase64URL(exponent), }, - { name: 'RSASSA-PKCS1-v1_5', hash: { name: hashAlg } }, + { name: keyAlgorithmName, hash: { name: hashAlg } }, false, ['verify'], ); + const verifyAlgorithm = + keyAlgorithmName === 'RSA-PSS' + ? { name: 'RSA-PSS', saltLength: saltLength ?? 0 } + : 'RSASSA-PKCS1-v1_5'; + + const signatureBytes = Uint8Array.from(signature); + const dataBytes = Uint8Array.from(data); return globalThis.crypto.subtle.verify( - 'RSASSA-PKCS1-v1_5', + verifyAlgorithm, key, - signature.buffer as ArrayBuffer, - data.buffer as ArrayBuffer, + signatureBytes, + dataBytes, ); } From 85df40fd2da52e410992dd295c47738880f123a8 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Wed, 22 Apr 2026 16:56:28 +0700 Subject: [PATCH 30/44] fix: address review comments --- packages/passkey-controller/package.json | 2 +- .../src/webauthn/verify-authentication-response.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/passkey-controller/package.json b/packages/passkey-controller/package.json index 931a45cd83a..e6cf23047f5 100644 --- a/packages/passkey-controller/package.json +++ b/packages/passkey-controller/package.json @@ -73,6 +73,6 @@ "typescript": "~5.3.3" }, "engines": { - "node": "^18.18 || >=20" + "node": ">=20" } } diff --git a/packages/passkey-controller/src/webauthn/verify-authentication-response.ts b/packages/passkey-controller/src/webauthn/verify-authentication-response.ts index 0437d408226..4d549ec9eb3 100644 --- a/packages/passkey-controller/src/webauthn/verify-authentication-response.ts +++ b/packages/passkey-controller/src/webauthn/verify-authentication-response.ts @@ -148,7 +148,7 @@ export async function verifyAuthenticationResponse(opts: { } if ( - !['present', 'supported', 'notSupported'].includes(tokenBinding.status) + !['present', 'supported', 'not-supported'].includes(tokenBinding.status) ) { throw new Error(`Unexpected tokenBinding status ${tokenBinding.status}`); } From b49dc0f235d49aee392ea7f8d3f9bc3e2a5c2136 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Wed, 22 Apr 2026 17:08:02 +0700 Subject: [PATCH 31/44] fix: address CI failure --- .../webauthn/parse-authenticator-data.test.ts | 2 +- .../verify-authentication-response.test.ts | 98 ++++++++++- .../verify-authentication-response.ts | 4 +- .../verify-registration-response.test.ts | 107 +++++++++++ .../webauthn/verify-registration-response.ts | 4 +- .../src/webauthn/verify-signature.test.ts | 166 +++++++++++++++++- .../src/webauthn/verify-signature.ts | 6 +- 7 files changed, 368 insertions(+), 19 deletions(-) diff --git a/packages/passkey-controller/src/webauthn/parse-authenticator-data.test.ts b/packages/passkey-controller/src/webauthn/parse-authenticator-data.test.ts index 05fd73cd1a8..825215db4e7 100644 --- a/packages/passkey-controller/src/webauthn/parse-authenticator-data.test.ts +++ b/packages/passkey-controller/src/webauthn/parse-authenticator-data.test.ts @@ -1,6 +1,6 @@ import { encodeCBOR } from '@levischuck/tiny-cbor'; -import { sha256 } from '@noble/hashes/sha2'; import { base64ToBytes, bytesToBase64 } from '@metamask/utils'; +import { sha256 } from '@noble/hashes/sha2'; import { bytesToBase64URL } from '../utils/encoding'; import { parseAuthenticatorData } from './parse-authenticator-data'; diff --git a/packages/passkey-controller/src/webauthn/verify-authentication-response.test.ts b/packages/passkey-controller/src/webauthn/verify-authentication-response.test.ts index 15a89957926..492682c5a36 100644 --- a/packages/passkey-controller/src/webauthn/verify-authentication-response.test.ts +++ b/packages/passkey-controller/src/webauthn/verify-authentication-response.test.ts @@ -122,7 +122,9 @@ describe('verifyAuthenticationResponse', () => { }); it('returns { verified: false } when signature does not verify', async () => { - const signatureBytes = base64URLToBytes(assertionResponse.response.signature); + const signatureBytes = base64URLToBytes( + assertionResponse.response.signature, + ); signatureBytes[0] = ((signatureBytes[0] ?? 0) + 1) % 256; const result = await verifyAuthenticationResponse({ @@ -229,7 +231,9 @@ describe('verifyAuthenticationResponse', () => { }); it('throws error if user was not present', async () => { - const authData = base64URLToBytes(assertionResponse.response.authenticatorData); + const authData = base64URLToBytes( + assertionResponse.response.authenticatorData, + ); authData[32] = 0x00; await expect( @@ -305,4 +309,94 @@ describe('verifyAuthenticationResponse', () => { expect(verification.verified).toBe(true); }); + + it('throws when clientDataJSON is not a string', async () => { + await expect( + verifyAuthenticationResponse({ + response: { + ...assertionResponse, + response: { + ...assertionResponse.response, + clientDataJSON: 1 as unknown as string, + }, + }, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential, + }), + ).rejects.toThrow('Credential response clientDataJSON was not a string'); + }); + + it('throws when userHandle is not a string', async () => { + await expect( + verifyAuthenticationResponse({ + response: { + ...assertionResponse, + response: { + ...assertionResponse.response, + userHandle: 1 as unknown as string, + }, + }, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential, + }), + ).rejects.toThrow('Credential response userHandle was not a string'); + }); + + it('throws when tokenBinding is not an object', async () => { + const clientDataJSON = bytesToBase64URL( + new TextEncoder().encode( + JSON.stringify({ + ...decodeClientDataJSON(assertionResponse.response.clientDataJSON), + tokenBinding: 'invalid', + }), + ), + ); + + await expect( + verifyAuthenticationResponse({ + response: { + ...assertionResponse, + response: { + ...assertionResponse.response, + clientDataJSON, + }, + }, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential, + }), + ).rejects.toThrow('ClientDataJSON tokenBinding was not an object'); + }); + + it('throws when tokenBinding status is invalid', async () => { + const clientDataJSON = bytesToBase64URL( + new TextEncoder().encode( + JSON.stringify({ + ...decodeClientDataJSON(assertionResponse.response.clientDataJSON), + tokenBinding: { status: 'invalid-status' }, + }), + ), + ); + + await expect( + verifyAuthenticationResponse({ + response: { + ...assertionResponse, + response: { + ...assertionResponse.response, + clientDataJSON, + }, + }, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential, + }), + ).rejects.toThrow('Unexpected tokenBinding status'); + }); }); diff --git a/packages/passkey-controller/src/webauthn/verify-authentication-response.ts b/packages/passkey-controller/src/webauthn/verify-authentication-response.ts index 4d549ec9eb3..029f12169e8 100644 --- a/packages/passkey-controller/src/webauthn/verify-authentication-response.ts +++ b/packages/passkey-controller/src/webauthn/verify-authentication-response.ts @@ -113,9 +113,7 @@ export async function verifyAuthenticationResponse(opts: { // Make sure we're handling an authentication if (type !== 'webauthn.get') { - throw new Error( - `Unexpected authentication response type: ${type}`, - ); + throw new Error(`Unexpected authentication response type: ${type}`); } // Ensure the device provided the challenge we gave it diff --git a/packages/passkey-controller/src/webauthn/verify-registration-response.test.ts b/packages/passkey-controller/src/webauthn/verify-registration-response.test.ts index b13cddc69e9..a8c538470fd 100644 --- a/packages/passkey-controller/src/webauthn/verify-registration-response.test.ts +++ b/packages/passkey-controller/src/webauthn/verify-registration-response.test.ts @@ -705,6 +705,113 @@ describe('verifyRegistrationResponse edge cases', () => { expect(result.verified).toBe(true); }); + + it('rejects tokenBinding that is not an object', async () => { + const clientDataJSON = bytesToBase64URL( + new TextEncoder().encode( + JSON.stringify({ + ...decodeClientDataJSON(attestationNone.response.clientDataJSON), + tokenBinding: 'invalid', + }), + ), + ); + + await expect( + verifyRegistrationResponse({ + response: { + ...attestationNone, + response: { + ...attestationNone.response, + clientDataJSON, + }, + }, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + }), + ).rejects.toThrow('ClientDataJSON tokenBinding was not an object'); + }); + + it('rejects tokenBinding with invalid status', async () => { + const clientDataJSON = bytesToBase64URL( + new TextEncoder().encode( + JSON.stringify({ + ...decodeClientDataJSON(attestationNone.response.clientDataJSON), + tokenBinding: { status: 'invalid-status' }, + }), + ), + ); + + await expect( + verifyRegistrationResponse({ + response: { + ...attestationNone, + response: { + ...attestationNone.response, + clientDataJSON, + }, + }, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + }), + ).rejects.toThrow('Unexpected tokenBinding.status value'); + }); + + it('rejects missing attested credential data', async () => { + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x01, + counter: 0, + }); + + const response = buildRegistrationResponse(authData, 'missing-attested'); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }), + ).rejects.toThrow('No credential ID was provided by authenticator'); + }); + + it('returns verified false for packed attestation with invalid signature', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x91); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: cosePublicKeyCBOR, + }); + + const attStmt = new Map(); + attStmt.set('alg', COSEALG.ES256); + attStmt.set('sig', new Uint8Array(64).fill(0xff)); + + const response = buildRegistrationResponse( + authData, + bytesToBase64URL(credentialID), + 'packed', + attStmt, + ); + + const verification = await verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }); + + expect(verification).toStrictEqual({ verified: false }); + }); }); describe('verifyRegistrationResponse missing public key fields', () => { diff --git a/packages/passkey-controller/src/webauthn/verify-registration-response.ts b/packages/passkey-controller/src/webauthn/verify-registration-response.ts index 39d35da62c5..5f6cde646f1 100644 --- a/packages/passkey-controller/src/webauthn/verify-registration-response.ts +++ b/packages/passkey-controller/src/webauthn/verify-registration-response.ts @@ -112,9 +112,7 @@ export async function verifyRegistrationResponse(opts: { // Make sure we're handling an registration if (type !== 'webauthn.create') { - throw new Error( - `Unexpected registration response type: ${type}`, - ); + throw new Error(`Unexpected registration response type: ${type}`); } // Ensure the device provided the challenge we gave it diff --git a/packages/passkey-controller/src/webauthn/verify-signature.test.ts b/packages/passkey-controller/src/webauthn/verify-signature.test.ts index 8ec5736964d..3632ec93cdc 100644 --- a/packages/passkey-controller/src/webauthn/verify-signature.test.ts +++ b/packages/passkey-controller/src/webauthn/verify-signature.test.ts @@ -1,8 +1,7 @@ -import { webcrypto } from 'node:crypto'; - import { ed25519 } from '@noble/curves/ed25519'; import { p384, p521 } from '@noble/curves/nist'; import { sha384, sha512 } from '@noble/hashes/sha2'; +import { webcrypto } from 'node:crypto'; import { base64URLToBytes } from '../utils/encoding'; import { COSEALG, COSECRV, COSEKEYS, COSEKTY } from './constants'; @@ -33,7 +32,9 @@ describe('verifySignature', () => { base64URLToBytes('BXEathwyOK_uQRmlZ_m4wReHLujSXk_-e3-9co5B2MY'), ); - const data = base64URLToBytes('Bt81jmu3ieajF4w1at8HmieVOTDymHd7xJguJCUsL-Q'); + const data = base64URLToBytes( + 'Bt81jmu3ieajF4w1at8HmieVOTDymHd7xJguJCUsL-Q', + ); const signature = base64URLToBytes( 'MEQCH1h_F7TPTMVh_kwb_ssjD0_2U77bbXazz2ux-P6khLQCIQCutHs9eCBkCIMP3yA9mmNRKEfFd-REmhGY2GbHozaC7w', ); @@ -89,7 +90,9 @@ describe('verifySignature', () => { ), ); - const data = base64URLToBytes('D7mI8UwWXv4rpfSQUNqtUXAhZEPbRLugmWclPpJ9m7c'); + const data = base64URLToBytes( + 'D7mI8UwWXv4rpfSQUNqtUXAhZEPbRLugmWclPpJ9m7c', + ); const signature = base64URLToBytes( 'MGMCL3lZ2Rjxo5WcmTCdWyB6jTE9PVuduOR_AsJu956J9S_mFNbHP_-MbyWem4dfb5iqAjABJhTRltNl5Y0O4XC7YLNsYKq2WxYQ1HFOMGsr6oNkUPsX3UAr2zeeWL_Tp1VgHeM', ); @@ -121,7 +124,9 @@ describe('verifySignature', () => { ), ); - const data = base64URLToBytes('5p0h9RZTjLoBlnL2nY5pqOnhGy4q60NzbjDe2rVDR7o'); + const data = base64URLToBytes( + '5p0h9RZTjLoBlnL2nY5pqOnhGy4q60NzbjDe2rVDR7o', + ); const signature = base64URLToBytes( 'MIGHAkFRpbGknlgpETORypMprGBXMkJMfuqgJupy3NcgCOaJJdj3Voz74kV2pjPqkLNpuO9FqVtXeEsUw-jYsBHcMqHZhwJCAQ88uFDJS5g81XVBcLMIgf6ro-F-5jgRAmHx3CRVNGdk81MYbFJhT3hd2w9RdhT8qBG0zzRBXYAcHrKo0qJwQZot', ); @@ -215,6 +220,22 @@ describe('verifySignature', () => { ).rejects.toThrow('EC2 public key missing x or y coordinate'); }); + it('throws for missing EC2 alg', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.EC2); + coseMap.set(COSEKEYS.Crv, COSECRV.P256); + coseMap.set(COSEKEYS.X, new Uint8Array(32)); + coseMap.set(COSEKEYS.Y, new Uint8Array(32)); + + await expect( + verifySignature({ + cosePublicKey: coseMap, + signature: new Uint8Array(64), + data: new Uint8Array(32), + }), + ).rejects.toThrow('EC2 public key missing alg'); + }); + it('throws for missing OKP x coordinate', async () => { const coseMap = new Map(); coseMap.set(COSEKEYS.Kty, COSEKTY.OKP); @@ -366,6 +387,21 @@ describe('verifySignature', () => { ).rejects.toThrow('RSA public key missing n or e'); }); + it('throws for missing RSA alg', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.N, new Uint8Array(256).fill(1)); + coseMap.set(COSEKEYS.E, new Uint8Array([1, 0, 1])); + + await expect( + verifySignature({ + cosePublicKey: coseMap, + signature: new Uint8Array(256), + data: new Uint8Array(32), + }), + ).rejects.toThrow('RSA public key missing alg'); + }); + it('verifies PS256 signature via Web Crypto', async () => { const keyPair = await webcrypto.subtle.generateKey( { @@ -406,6 +442,126 @@ describe('verifySignature', () => { expect(result).toBe(true); }); + it('verifies RS1 signature via Web Crypto', async () => { + const keyPair = await webcrypto.subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: { name: 'SHA-1' }, + }, + true, + ['sign', 'verify'], + ); + + const data = new Uint8Array(32).fill(0x44); + const signature = new Uint8Array( + await webcrypto.subtle.sign( + 'RSASSA-PKCS1-v1_5', + keyPair.privateKey, + data, + ), + ); + + const jwk = await webcrypto.subtle.exportKey('jwk', keyPair.publicKey); + const nBytes = decodeJwkBase64Url(jwk.n as string); + const eBytes = decodeJwkBase64Url(jwk.e as string); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.Alg, COSEALG.RS1); + coseMap.set(COSEKEYS.N, nBytes); + coseMap.set(COSEKEYS.E, eBytes); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + + it('verifies PS384 signature via Web Crypto', async () => { + const keyPair = await webcrypto.subtle.generateKey( + { + name: 'RSA-PSS', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: { name: 'SHA-384' }, + }, + true, + ['sign', 'verify'], + ); + + const data = new Uint8Array(32).fill(0x4a); + const signature = new Uint8Array( + await webcrypto.subtle.sign( + { name: 'RSA-PSS', saltLength: 48 }, + keyPair.privateKey, + data, + ), + ); + + const jwk = await webcrypto.subtle.exportKey('jwk', keyPair.publicKey); + const nBytes = decodeJwkBase64Url(jwk.n as string); + const eBytes = decodeJwkBase64Url(jwk.e as string); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.Alg, COSEALG.PS384); + coseMap.set(COSEKEYS.N, nBytes); + coseMap.set(COSEKEYS.E, eBytes); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + + it('verifies PS512 signature via Web Crypto', async () => { + const keyPair = await webcrypto.subtle.generateKey( + { + name: 'RSA-PSS', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: { name: 'SHA-512' }, + }, + true, + ['sign', 'verify'], + ); + + const data = new Uint8Array(32).fill(0x5a); + const signature = new Uint8Array( + await webcrypto.subtle.sign( + { name: 'RSA-PSS', saltLength: 64 }, + keyPair.privateKey, + data, + ), + ); + + const jwk = await webcrypto.subtle.exportKey('jwk', keyPair.publicKey); + const nBytes = decodeJwkBase64Url(jwk.n as string); + const eBytes = decodeJwkBase64Url(jwk.e as string); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.Alg, COSEALG.PS512); + coseMap.set(COSEKEYS.N, nBytes); + coseMap.set(COSEKEYS.E, eBytes); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + it('verifies RS256 with subarray buffers', async () => { const keyPair = await webcrypto.subtle.generateKey( { diff --git a/packages/passkey-controller/src/webauthn/verify-signature.ts b/packages/passkey-controller/src/webauthn/verify-signature.ts index fa7a5662c43..07f7c78da2d 100644 --- a/packages/passkey-controller/src/webauthn/verify-signature.ts +++ b/packages/passkey-controller/src/webauthn/verify-signature.ts @@ -51,11 +51,7 @@ function verifyEC2( throw new Error('EC2 public key missing x or y coordinate'); } - const uncompressed = concatBytes([ - new Uint8Array([0x04]), - xCoord, - yCoord, - ]); + const uncompressed = concatBytes([new Uint8Array([0x04]), xCoord, yCoord]); switch (crv) { case COSECRV.P256: From 5cc58c97b51ba558383b18f155d24017595fa3e6 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Wed, 22 Apr 2026 18:55:43 +0700 Subject: [PATCH 32/44] fix: address cursor review and constraints --- packages/passkey-controller/package.json | 2 +- .../src/PasskeyController.test.ts | 80 ++++++++++++++++++- .../src/PasskeyController.ts | 13 ++- 3 files changed, 88 insertions(+), 7 deletions(-) diff --git a/packages/passkey-controller/package.json b/packages/passkey-controller/package.json index e6cf23047f5..931a45cd83a 100644 --- a/packages/passkey-controller/package.json +++ b/packages/passkey-controller/package.json @@ -73,6 +73,6 @@ "typescript": "~5.3.3" }, "engines": { - "node": ">=20" + "node": "^18.18 || >=20" } } diff --git a/packages/passkey-controller/src/PasskeyController.test.ts b/packages/passkey-controller/src/PasskeyController.test.ts index e51ac32e46c..b91d2a453a9 100644 --- a/packages/passkey-controller/src/PasskeyController.test.ts +++ b/packages/passkey-controller/src/PasskeyController.test.ts @@ -6,7 +6,10 @@ import { passkeyControllerSelectors, PasskeyController, } from './PasskeyController'; -import type { PasskeyControllerMessenger } from './PasskeyController'; +import type { + PasskeyControllerMessenger, + PasskeyControllerState, +} from './PasskeyController'; import type { PasskeyRecord, PrfClientExtensionResults } from './types'; import type { PasskeyRegistrationResponse, @@ -1270,6 +1273,79 @@ describe('PasskeyController', () => { ).toBe(vaultKey); }); + it('does not overwrite passkey fields updated while authentication verification awaits', async () => { + setupRegistrationMocks(); + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(99)); + const vaultKey = 'vault-concurrent-field'; + + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + vaultKey, + }); + + let finishVerify!: (value: unknown) => void; + mockVerifyAuthenticationResponse.mockImplementationOnce( + () => + new Promise((resolve) => { + finishVerify = resolve; + }), + ); + + const authOpts = controller.generateAuthenticationOptions(); + const retrievePromise = controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + ); + + await Promise.resolve(); + + const concurrentTransports = ['hybrid', 'internal'] as const; + expect( + controller.state.passkeyRecord?.credential.transports, + ).toStrictEqual(['internal']); + + ( + controller as unknown as { + update: (callback: (state: PasskeyControllerState) => void) => void; + } + ).update((state) => { + if (!state.passkeyRecord) { + return; + } + state.passkeyRecord.credential.transports = [...concurrentTransports]; + }); + + finishVerify({ + verified: true, + authenticationInfo: { + credentialId: TEST_CREDENTIAL_ID, + newCounter: 3, + userVerified: true, + origin: TEST_ORIGIN, + rpID: TEST_RP_ID, + }, + }); + + await retrievePromise; + + expect( + controller.state.passkeyRecord?.credential.transports, + ).toStrictEqual([...concurrentTransports]); + expect(controller.state.passkeyRecord?.credential.counter).toBe(3); + }); + it('completes registration using the first challenge after a second generateRegistrationOptions', async () => { setupRegistrationMocks(); const controller = createController(); @@ -1293,7 +1369,7 @@ describe('PasskeyController', () => { describe('ceremony TTL', () => { it('drops expired registration ceremonies before protectVaultKeyWithPasskey', async () => { - jest.useFakeTimers('modern'); + jest.useFakeTimers(); jest.setSystemTime(1_000_000); setupRegistrationMocks(); const controller = createController(); diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index e80c6a73f99..837eacb1c74 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -553,12 +553,17 @@ export class PasskeyController extends BaseController< ); } - // persist passkey record with updated counter + // persist passkey record with updated counter without clobbering concurrent updates + const latestRecord = this.#requireEnrolled(); + const mergedCounter = Math.max( + result.authenticationInfo.newCounter, + latestRecord.credential.counter, + ); const updatedRecord: PasskeyRecord = { - ...record, + ...latestRecord, credential: { - ...record.credential, - counter: result.authenticationInfo.newCounter, + ...latestRecord.credential, + counter: mergedCounter, }, }; this.#setPasskeyRecord(updatedRecord); From c5a5d0cf5088ba8c763669f77a8956adff0ef913 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Thu, 23 Apr 2026 11:24:58 +0700 Subject: [PATCH 33/44] fix: remove barrel pattern for webauthn and utils --- .../src/PasskeyController.test.ts | 10 +++++++--- .../passkey-controller/src/PasskeyController.ts | 12 +++++------- packages/passkey-controller/src/index.ts | 2 +- .../src/key-derivation.test.ts | 2 +- .../passkey-controller/src/key-derivation.ts | 2 +- packages/passkey-controller/src/utils/index.ts | 2 -- .../passkey-controller/src/webauthn/index.ts | 17 ----------------- 7 files changed, 15 insertions(+), 32 deletions(-) delete mode 100644 packages/passkey-controller/src/utils/index.ts delete mode 100644 packages/passkey-controller/src/webauthn/index.ts diff --git a/packages/passkey-controller/src/PasskeyController.test.ts b/packages/passkey-controller/src/PasskeyController.test.ts index b91d2a453a9..1bf00359965 100644 --- a/packages/passkey-controller/src/PasskeyController.test.ts +++ b/packages/passkey-controller/src/PasskeyController.test.ts @@ -14,7 +14,7 @@ import type { PasskeyRecord, PrfClientExtensionResults } from './types'; import type { PasskeyRegistrationResponse, PasskeyAuthenticationResponse, -} from './webauthn'; +} from './webauthn/types'; type ExtOutputsWithPrf = Record & PrfClientExtensionResults; @@ -32,10 +32,14 @@ function prfResults(first: string, enabled?: boolean): ExtOutputsWithPrf { const mockVerifyRegistrationResponse = jest.fn(); const mockVerifyAuthenticationResponse = jest.fn(); -jest.mock('./webauthn', () => ({ - ...jest.requireActual('./webauthn'), +jest.mock('./webauthn/verify-registration-response', () => ({ + ...jest.requireActual('./webauthn/verify-registration-response'), verifyRegistrationResponse: (...args: unknown[]): unknown => mockVerifyRegistrationResponse(...args), +})); + +jest.mock('./webauthn/verify-authentication-response', () => ({ + ...jest.requireActual('./webauthn/verify-authentication-response'), verifyAuthenticationResponse: (...args: unknown[]): unknown => mockVerifyAuthenticationResponse(...args), })); diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index 837eacb1c74..30add426571 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -16,18 +16,16 @@ import { import type { PasskeyRecord } from './types'; import { decryptWithKey, encryptWithKey } from './utils/crypto'; import { base64URLToBytes, bytesToBase64URL } from './utils/encoding'; -import { - COSEALG, - decodeClientDataJSON, - verifyAuthenticationResponse, - verifyRegistrationResponse, -} from './webauthn'; +import { COSEALG } from './webauthn/constants'; +import { decodeClientDataJSON } from './webauthn/decode-client-data-json'; import type { PasskeyAuthenticationOptions, PasskeyAuthenticationResponse, PasskeyRegistrationOptions, PasskeyRegistrationResponse, -} from './webauthn'; +} from './webauthn/types'; +import { verifyAuthenticationResponse } from './webauthn/verify-authentication-response'; +import { verifyRegistrationResponse } from './webauthn/verify-registration-response'; const controllerName = 'PasskeyController'; diff --git a/packages/passkey-controller/src/index.ts b/packages/passkey-controller/src/index.ts index 2b61f6d5812..d86ecf4f62f 100644 --- a/packages/passkey-controller/src/index.ts +++ b/packages/passkey-controller/src/index.ts @@ -33,4 +33,4 @@ export type { PasskeyAuthenticationOptions, PasskeyAuthenticationResponse, PublicKeyCredentialHint, -} from './webauthn'; +} from './webauthn/types'; diff --git a/packages/passkey-controller/src/key-derivation.test.ts b/packages/passkey-controller/src/key-derivation.test.ts index d9c31b4a6a4..95ed46b8185 100644 --- a/packages/passkey-controller/src/key-derivation.test.ts +++ b/packages/passkey-controller/src/key-derivation.test.ts @@ -6,7 +6,7 @@ import type { PasskeyRecord, PasskeyRegistrationCeremony } from './types'; import type { PasskeyAuthenticationResponse, PasskeyRegistrationResponse, -} from './webauthn'; +} from './webauthn/types'; function b64url(str: string): string { return btoa(str) diff --git a/packages/passkey-controller/src/key-derivation.ts b/packages/passkey-controller/src/key-derivation.ts index bcc354fd557..5dce2913684 100644 --- a/packages/passkey-controller/src/key-derivation.ts +++ b/packages/passkey-controller/src/key-derivation.ts @@ -10,7 +10,7 @@ import { base64URLToBytes } from './utils/encoding'; import type { PasskeyAuthenticationResponse, PasskeyRegistrationResponse, -} from './webauthn'; +} from './webauthn/types'; /** * Derives an AES-256 wrapping key from a WebAuthn registration ceremony diff --git a/packages/passkey-controller/src/utils/index.ts b/packages/passkey-controller/src/utils/index.ts deleted file mode 100644 index 5a1e80dc611..00000000000 --- a/packages/passkey-controller/src/utils/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { deriveEncryptionKey, encryptWithKey, decryptWithKey } from './crypto'; -export { bytesToBase64URL, base64URLToBytes, bytesToHex } from './encoding'; diff --git a/packages/passkey-controller/src/webauthn/index.ts b/packages/passkey-controller/src/webauthn/index.ts deleted file mode 100644 index 36011c77788..00000000000 --- a/packages/passkey-controller/src/webauthn/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { COSEALG } from './constants'; -export { decodeClientDataJSON } from './decode-client-data-json'; -export { - verifyRegistrationResponse, - type VerifiedRegistrationResponse, -} from './verify-registration-response'; -export { - verifyAuthenticationResponse, - type VerifiedAuthenticationResponse, -} from './verify-authentication-response'; -export type { - PasskeyRegistrationOptions, - PasskeyRegistrationResponse, - PasskeyAuthenticationOptions, - PasskeyAuthenticationResponse, - PublicKeyCredentialHint, -} from './types'; From 25805d79bc2190645645a661bd3d4c5dd8182722 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Thu, 23 Apr 2026 12:54:07 +0700 Subject: [PATCH 34/44] fix: address review comments --- packages/passkey-controller/CHANGELOG.md | 32 +--- .../src/PasskeyController.ts | 158 ++++++++---------- tsconfig.json | 3 + 3 files changed, 81 insertions(+), 112 deletions(-) diff --git a/packages/passkey-controller/CHANGELOG.md b/packages/passkey-controller/CHANGELOG.md index 7dafdb15110..6133b51c640 100644 --- a/packages/passkey-controller/CHANGELOG.md +++ b/packages/passkey-controller/CHANGELOG.md @@ -9,29 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `PasskeyController` constructor accepts optional `userName` and `userDisplayName` so consumers can override the values shown in the OS passkey UI; both default to `rpName`. ([#8422](https://github.com/MetaMask/core/pull/8422)) -- `PasskeyController.destroy()` clears in-flight ceremony state in addition to the standard `BaseController` teardown. ([#8422](https://github.com/MetaMask/core/pull/8422)) -- `passkeyControllerSelectors.selectIsPasskeyEnrolled` selector for use in Redux selectors and other code paths that only have access to a state object. ([#8422](https://github.com/MetaMask/core/pull/8422)) -- `PasskeyController.verifyPasskeyAuthentication` — returns whether the passkey authentication response is valid without returning the vault key (delegates to the same path as `retrieveVaultKeyWithPasskey`). ([#8422](https://github.com/MetaMask/core/pull/8422)) -- Initial `@metamask/passkey-controller` package: passkey-based vault key protection using WebAuthn, orchestrating enrollment, authentication, and vault key wrap/unwrap with AES-256-GCM and HKDF-derived keys. ([#8422](https://github.com/MetaMask/core/pull/8422)) -- `PasskeyController` API: ([#8422](https://github.com/MetaMask/core/pull/8422)) - - `generateRegistrationOptions` / `protectVaultKeyWithPasskey` — enrollment and vault key protection - - `generateAuthenticationOptions` / `retrieveVaultKeyWithPasskey` — unlock and vault key recovery - - `renewVaultKeyProtection` — re-wrap vault key for password-change flows without re-enrolling the passkey - - `removePasskey` — unenroll and clear key material - - `isPasskeyEnrolled` — enrollment check (also available as `passkeyControllerSelectors.selectIsPasskeyEnrolled`) - - `clearState` — reset persisted state and clear in-flight WebAuthn ceremony state (for app lifecycle, e.g. wallet reset) -- Adaptive key derivation during enrollment: **PRF** (WebAuthn PRF extension output as HKDF input) or **userHandle** fallback; PRF path used only when `prf.results.first` is a non-empty string. ([#8422](https://github.com/MetaMask/core/pull/8422)) -- Self-contained WebAuthn verification (no Node server): `clientDataJSON` and `authenticatorData` checks, signature verification (`@noble/curves` + Web Crypto RSA), attestation formats `none` and `packed` self-attestation. ([#8422](https://github.com/MetaMask/core/pull/8422)) -- In-flight **ceremony** coordination (distinct from user login sessions): challenge-keyed registration/authentication state in `src/ceremony-manager.ts` (`CeremonyManager` and timing/capacity constants), TTL aligned with WebAuthn `timeout`, and a cap on concurrent ceremonies per flow so multiple tabs/contexts do not overwrite a single in-memory entry. ([#8422](https://github.com/MetaMask/core/pull/8422)) -- Exported timing and limits from package entry: `WEBAUTHN_TIMEOUT_MS`, `CEREMONY_TTL_SLACK_MS`, `CEREMONY_MAX_AGE_MS`, `MAX_CONCURRENT_PASSKEY_CEREMONIES` (`PasskeyController` does not re-export these; import from `@metamask/passkey-controller` or `./ceremony-manager` in the monorepo). ([#8422](https://github.com/MetaMask/core/pull/8422)) -- Exported controller types (`PasskeyControllerState`, messenger types, actions, events) and WebAuthn option/response types; internal ceremony payload types `PasskeyRegistrationCeremony` / `PasskeyAuthenticationCeremony` in `types.ts`. ([#8422](https://github.com/MetaMask/core/pull/8422)) -- AES-256-GCM helpers and COSE enums (`COSEALG`, `COSEKEYS`, `COSEKTY`, `COSECRV`). ([#8422](https://github.com/MetaMask/core/pull/8422)) -- `PasskeyAuthenticationRejectedError` for expected authentication or enrollment failures (not enrolled, missing ceremony, failed verification, missing key material, vault decrypt failure). `PasskeyController.verifyPasskeyAuthentication` returns `false` only for this error and rethrows anything else. ([#8422](https://github.com/MetaMask/core/pull/8422)) -- `renewVaultKeyProtection` throws `PasskeyAuthenticationRejectedError('Passkey is not enrolled')` when called without an enrolled passkey instead of an opaque `TypeError`. ([#8422](https://github.com/MetaMask/core/pull/8422)) -- `verifyAuthenticationResponse` returns a discriminated union (`{ verified: false }` or `{ verified: true; authenticationInfo }`) so consumers cannot accidentally read `authenticationInfo` on a failed verification; signature verification failure short-circuits before the counter-monotonicity check. ([#8422](https://github.com/MetaMask/core/pull/8422)) -- `PasskeyController` constructor defaults `state` to `{}` per controller guidelines. ([#8422](https://github.com/MetaMask/core/pull/8422)) -- `PasskeyController.isPasskeyEnrolled` delegates to `passkeyControllerSelectors.selectIsPasskeyEnrolled`. ([#8422](https://github.com/MetaMask/core/pull/8422)) -- `PasskeyController.removePasskey` clears in-flight ceremony state (same as `clearState` for the ceremony manager). ([#8422](https://github.com/MetaMask/core/pull/8422)) +- Initial release ([#8422](https://github.com/MetaMask/core/pull/8422)) + - `PasskeyController` for passkey-based vault key protection with WebAuthn, AES-256-GCM wrap/unwrap, and HKDF-derived keys; optional `userName` / `userDisplayName` for the OS passkey UI (default `rpName`); constructor defaults `state` to `{}` + - Enrollment and unlock: `generateRegistrationOptions` / `protectVaultKeyWithPasskey`, `generateAuthenticationOptions` / `retrieveVaultKeyWithPasskey`, `renewVaultKeyProtection`, `removePasskey`, `clearState`; `verifyPasskeyAuthentication` validates authentication without returning the vault key + - `isPasskeyEnrolled` and `passkeyControllerSelectors.selectIsPasskeyEnrolled`; `destroy` / `removePasskey` / `clearState` clear in-flight ceremony state + - Adaptive enrollment key derivation (WebAuthn **PRF** or **userHandle** fallback); self-contained verification (`clientDataJSON` / `authenticatorData`, RSA signatures via `@noble/curves` and Web Crypto; attestation `none` and `packed` self-attestation) + - Challenge-keyed in-memory ceremony coordination (`CeremonyManager`), timing/limit constants exported from the package entry (`WEBAUTHN_TIMEOUT_MS`, `CEREMONY_TTL_SLACK_MS`, `CEREMONY_MAX_AGE_MS`, `MAX_CONCURRENT_PASSKEY_CEREMONIES`) + - `PasskeyAuthenticationRejectedError` for expected failures; `verifyAuthenticationResponse` returns a discriminated union on success vs failure + - Exported controller and WebAuthn types, AES-GCM helpers, and COSE enums (`COSEALG`, `COSEKEYS`, `COSEKTY`, `COSECRV`) [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index 30add426571..d0f02824b18 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -166,26 +166,8 @@ export class PasskeyController extends BaseController< this.#userDisplayName = userDisplayName ?? rpName; } - /** - * Releases all in-flight ceremony state and tears down the messenger. - */ - destroy(): void { - this.#ceremonyManager.clear(); - super.destroy(); - } - - #setPasskeyRecord(record: PasskeyRecord): void { - this.update((state) => { - state.passkeyRecord = record; - }); - } - - #getPasskeyRecord(): PasskeyRecord | null { - return this.state.passkeyRecord; - } - #requireEnrolled(): PasskeyRecord { - const record = this.#getPasskeyRecord(); + const record = this.state.passkeyRecord; if (!record) { throw new PasskeyAuthenticationRejectedError('Passkey is not enrolled'); } @@ -327,45 +309,45 @@ export class PasskeyController extends BaseController< throw new Error('No active passkey registration ceremony'); } - // verify registration response - const { verified, registrationInfo } = await verifyRegistrationResponse({ - response: registrationResponse, - expectedChallenge: registrationCeremony.challenge, - expectedOrigin: this.#expectedOrigin, - expectedRPID: this.#rpID, - requireUserVerification: false, - }).catch((error) => { - this.#ceremonyManager.deleteRegistrationCeremony(challenge); - throw error; - }); - if (!verified || !registrationInfo) { - this.#ceremonyManager.deleteRegistrationCeremony(challenge); - throw new Error('Passkey registration verification failed'); - } - - // derive key - const { encKey, keyDerivation } = deriveKeyFromRegistrationResponse( - registrationResponse, - registrationCeremony, - ); - - // encrypt vault key - const { ciphertext, iv } = encryptWithKey(vaultKey, encKey); + try { + // verify registration response + const { verified, registrationInfo } = await verifyRegistrationResponse({ + response: registrationResponse, + expectedChallenge: registrationCeremony.challenge, + expectedOrigin: this.#expectedOrigin, + expectedRPID: this.#rpID, + requireUserVerification: false, + }); + if (!verified || !registrationInfo) { + throw new Error('Passkey registration verification failed'); + } - // persist passkey record - this.#setPasskeyRecord({ - credential: { - id: registrationInfo.credentialId, - publicKey: bytesToBase64URL(registrationInfo.publicKey), - counter: registrationInfo.counter, - transports: registrationInfo.transports, - }, - encryptedVaultKey: { ciphertext, iv }, - keyDerivation, - }); + // derive key + const { encKey, keyDerivation } = deriveKeyFromRegistrationResponse( + registrationResponse, + registrationCeremony, + ); - // delete ceremony - this.#ceremonyManager.deleteRegistrationCeremony(challenge); + // encrypt vault key + const { ciphertext, iv } = encryptWithKey(vaultKey, encKey); + + // persist passkey record + this.update((state) => { + state.passkeyRecord = { + credential: { + id: registrationInfo.credentialId, + publicKey: bytesToBase64URL(registrationInfo.publicKey), + counter: registrationInfo.counter, + transports: registrationInfo.transports, + }, + encryptedVaultKey: { ciphertext, iv }, + keyDerivation, + }; + }); + } finally { + + this.#ceremonyManager.deleteRegistrationCeremony(challenge); + } } /** @@ -477,31 +459,38 @@ export class PasskeyController extends BaseController< // encrypt new vault key const { ciphertext, iv } = encryptWithKey(newVaultKey, encKey); - // persist passkey record - this.#setPasskeyRecord({ - ...passkeyRecord, - encryptedVaultKey: { ciphertext, iv }, - }); - } - - /** Resets state and clears in-flight registration/authentication ceremonies. */ - clearState(): void { + // persist passkey record (mutate current state only for vault key material) this.update((state) => { - Object.assign(state, getDefaultPasskeyControllerState()); + if (!state.passkeyRecord) { + throw new PasskeyAuthenticationRejectedError('Passkey is not enrolled'); + } + state.passkeyRecord.encryptedVaultKey = { ciphertext, iv }; }); - this.#ceremonyManager.clear(); } /** * Unenrolls the passkey, removing the protected vault key material. */ removePasskey(): void { - this.update((state) => { - Object.assign(state, getDefaultPasskeyControllerState()); - }); + this.update(() => getDefaultPasskeyControllerState()); this.#ceremonyManager.clear(); } + /** + * Resets state and clears in-flight registration/authentication ceremonies. + */ + clearState(): void { + this.removePasskey(); + } + + /** + * Releases all in-flight ceremony state and tears down the messenger. + */ + destroy(): void { + this.#ceremonyManager.clear(); + super.destroy(); + } + /** * Verifies an authentication response for the enrolled passkey. * @@ -552,27 +541,20 @@ export class PasskeyController extends BaseController< } // persist passkey record with updated counter without clobbering concurrent updates - const latestRecord = this.#requireEnrolled(); - const mergedCounter = Math.max( - result.authenticationInfo.newCounter, - latestRecord.credential.counter, - ); - const updatedRecord: PasskeyRecord = { - ...latestRecord, - credential: { - ...latestRecord.credential, - counter: mergedCounter, - }, - }; - this.#setPasskeyRecord(updatedRecord); - - // delete ceremony - this.#ceremonyManager.deleteAuthenticationCeremony(challenge); - } catch (error) { + this.update((state) => { + if (!state.passkeyRecord) { + throw new PasskeyAuthenticationRejectedError('Passkey is not enrolled'); + } + const latest = state.passkeyRecord; + latest.credential.counter = Math.max( + result.authenticationInfo.newCounter, + latest.credential.counter, + ); + }); + } finally { if (challenge) { this.#ceremonyManager.deleteAuthenticationCeremony(challenge); } - throw error; } } } diff --git a/tsconfig.json b/tsconfig.json index 72a51c2e29e..52c1abf20cc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -173,6 +173,9 @@ { "path": "./packages/notification-services-controller" }, + { + "path": "./packages/passkey-controller" + }, { "path": "./packages/permission-controller" }, From 8a3bf04720ec095cf9d8530720d90940cd31639e Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Thu, 23 Apr 2026 15:00:58 +0700 Subject: [PATCH 35/44] test: increase test coverage to 100% --- packages/passkey-controller/jest.config.js | 2 +- .../src/PasskeyController.test.ts | 91 +++++++++++++++++++ .../src/PasskeyController.ts | 5 +- .../src/ceremony-manager.test.ts | 23 +++++ .../src/webauthn/match-expected-rp-id.ts | 2 +- .../src/webauthn/parse-authenticator-data.ts | 2 +- .../verify-registration-response.test.ts | 75 +++++++++++++++ .../src/webauthn/verify-signature.ts | 2 +- 8 files changed, 196 insertions(+), 6 deletions(-) diff --git a/packages/passkey-controller/jest.config.js b/packages/passkey-controller/jest.config.js index e317e421c58..1dbe6a6c174 100644 --- a/packages/passkey-controller/jest.config.js +++ b/packages/passkey-controller/jest.config.js @@ -9,6 +9,6 @@ module.exports = merge(baseConfig, { displayName, testEnvironment: '/jest.environment.js', coverageThreshold: { - global: { branches: 90, functions: 100, lines: 98, statements: 98 }, + global: { branches: 100, functions: 100, lines: 100, statements: 100 }, }, }); diff --git a/packages/passkey-controller/src/PasskeyController.test.ts b/packages/passkey-controller/src/PasskeyController.test.ts index 1bf00359965..a9eca14c245 100644 --- a/packages/passkey-controller/src/PasskeyController.test.ts +++ b/packages/passkey-controller/src/PasskeyController.test.ts @@ -1,6 +1,7 @@ import { Messenger } from '@metamask/messenger'; import { CEREMONY_MAX_AGE_MS, WEBAUTHN_TIMEOUT_MS } from './ceremony-manager'; +import { PasskeyAuthenticationRejectedError } from './errors'; import { getDefaultPasskeyControllerState, passkeyControllerSelectors, @@ -557,6 +558,50 @@ describe('PasskeyController', () => { ).rejects.toThrow('Passkey authentication verification failed'); }); + it('throws when passkey record disappears while persisting counter after auth', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + vaultKey: 'secret', + }); + + const updateSpy = jest.spyOn(controller, 'update' as never); + (updateSpy as unknown as jest.Mock).mockImplementation( + (updater: (state: PasskeyControllerState) => void) => { + updater({ + ...getDefaultPasskeyControllerState(), + passkeyRecord: null, + } as PasskeyControllerState); + }, + ); + + const authOpts = controller.generateAuthenticationOptions(); + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + ), + ).rejects.toThrow(PasskeyAuthenticationRejectedError); + + updateSpy.mockRestore(); + }); + it('clears the authentication ceremony after successful retrieval (prf)', async () => { setupRegistrationMocks(); setupAuthenticationMocks(); @@ -879,6 +924,52 @@ describe('PasskeyController', () => { ); }); + it('throws when passkey record disappears while persisting renewed ciphertext', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + vaultKey: 'wrapped', + }); + + const updateSpy = jest.spyOn(controller, 'update' as never); + (updateSpy as unknown as jest.Mock).mockImplementation( + (updater: (state: PasskeyControllerState) => void) => { + updater({ + ...getDefaultPasskeyControllerState(), + passkeyRecord: null, + } as PasskeyControllerState); + }, + ); + + const authOpts = controller.generateAuthenticationOptions(); + await expect( + controller.renewVaultKeyProtection({ + authenticationResponse: minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + oldVaultKey: 'wrapped', + newVaultKey: 'next', + }), + ).rejects.toThrow(PasskeyAuthenticationRejectedError); + + updateSpy.mockRestore(); + }); + it('completes renewal without an active authentication ceremony (prf)', async () => { setupRegistrationMocks(); setupAuthenticationMocks(); diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index d0f02824b18..8c419f28b75 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -345,7 +345,6 @@ export class PasskeyController extends BaseController< }; }); } finally { - this.#ceremonyManager.deleteRegistrationCeremony(challenge); } } @@ -543,7 +542,9 @@ export class PasskeyController extends BaseController< // persist passkey record with updated counter without clobbering concurrent updates this.update((state) => { if (!state.passkeyRecord) { - throw new PasskeyAuthenticationRejectedError('Passkey is not enrolled'); + throw new PasskeyAuthenticationRejectedError( + 'Passkey is not enrolled', + ); } const latest = state.passkeyRecord; latest.credential.counter = Math.max( diff --git a/packages/passkey-controller/src/ceremony-manager.test.ts b/packages/passkey-controller/src/ceremony-manager.test.ts index 7dedb828fec..d5df52e8c0c 100644 --- a/packages/passkey-controller/src/ceremony-manager.test.ts +++ b/packages/passkey-controller/src/ceremony-manager.test.ts @@ -76,6 +76,29 @@ describe('CeremonyManager', () => { }); }); + it('still saves when at capacity and all existing ceremonies have NaN createdAt', () => { + const manager = new CeremonyManager(); + const cap = MAX_CONCURRENT_PASSKEY_CEREMONIES; + for (let i = 0; i < cap; i += 1) { + jest.setSystemTime(1000 + i); + manager.saveRegistrationCeremony(`k${i}`, { + ...baseReg, + challenge: `k${i}`, + createdAt: Number.NaN, + }); + } + jest.setSystemTime(5000); + manager.saveRegistrationCeremony('newest', { + ...baseReg, + challenge: 'newest', + createdAt: 5000, + }); + expect(manager.getRegistrationCeremony('newest')).toMatchObject({ + challenge: 'newest', + createdAt: 5000, + }); + }); + it('delete removes a single entry', () => { const manager = new CeremonyManager(); jest.setSystemTime(0); diff --git a/packages/passkey-controller/src/webauthn/match-expected-rp-id.ts b/packages/passkey-controller/src/webauthn/match-expected-rp-id.ts index 01e489a27c5..fcd10400095 100644 --- a/packages/passkey-controller/src/webauthn/match-expected-rp-id.ts +++ b/packages/passkey-controller/src/webauthn/match-expected-rp-id.ts @@ -16,7 +16,7 @@ function areEqual(first: Uint8Array, second: Uint8Array): boolean { let diff = 0; for (let i = 0; i < first.length; i++) { // eslint-disable-next-line no-bitwise - diff |= (first[i] ?? 0) ^ (second[i] ?? 0); + diff |= first[i] ^ second[i]; } return diff === 0; } diff --git a/packages/passkey-controller/src/webauthn/parse-authenticator-data.ts b/packages/passkey-controller/src/webauthn/parse-authenticator-data.ts index 9b52d08732c..a60840690c1 100644 --- a/packages/passkey-controller/src/webauthn/parse-authenticator-data.ts +++ b/packages/passkey-controller/src/webauthn/parse-authenticator-data.ts @@ -25,7 +25,7 @@ export function parseAuthenticatorData( const rpIdHash = authData.slice(pointer, pointer + 32); pointer += 32; - const flagsByte = authData[pointer] ?? 0; + const flagsByte = authData[pointer]; const flags: AuthenticatorDataFlags = { up: Boolean(flagsByte & (1 << 0)), uv: Boolean(flagsByte & (1 << 2)), diff --git a/packages/passkey-controller/src/webauthn/verify-registration-response.test.ts b/packages/passkey-controller/src/webauthn/verify-registration-response.test.ts index a8c538470fd..662a90319fe 100644 --- a/packages/passkey-controller/src/webauthn/verify-registration-response.test.ts +++ b/packages/passkey-controller/src/webauthn/verify-registration-response.test.ts @@ -6,6 +6,7 @@ import { base64URLToBytes } from '../utils/encoding'; import { bytesToBase64URL } from '../utils/encoding'; import { COSEALG, COSECRV, COSEKEYS, COSEKTY } from './constants'; import { decodeClientDataJSON } from './decode-client-data-json'; +import * as parseAuthenticatorDataModule from './parse-authenticator-data'; import type { PasskeyRegistrationResponse } from './types'; import { verifyRegistrationResponse } from './verify-registration-response'; @@ -812,6 +813,80 @@ describe('verifyRegistrationResponse edge cases', () => { expect(verification).toStrictEqual({ verified: false }); }); + + const mockParsedAuthBase = { + rpIdHash: sha256(new TextEncoder().encode(EXPECTED_RP_ID)), + flags: { + up: true, + uv: false, + be: false, + bs: false, + at: true, + ed: false, + flagsByte: 0x41, + }, + counter: 0, + } as const; + + it('throws when parsed authenticator data has no credential ID', async () => { + const spy = jest + .spyOn(parseAuthenticatorDataModule, 'parseAuthenticatorData') + .mockReturnValueOnce({ + ...mockParsedAuthBase, + }); + + await expect( + verifyRegistrationResponse({ + response: attestationNone, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + }), + ).rejects.toThrow('No credential ID was provided by authenticator'); + + spy.mockRestore(); + }); + + it('throws when parsed authenticator data has no credential public key', async () => { + const spy = jest + .spyOn(parseAuthenticatorDataModule, 'parseAuthenticatorData') + .mockReturnValueOnce({ + ...mockParsedAuthBase, + credentialID: new Uint8Array([1]), + }); + + await expect( + verifyRegistrationResponse({ + response: attestationNone, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + }), + ).rejects.toThrow('No public key was provided by authenticator'); + + spy.mockRestore(); + }); + + it('throws when parsed authenticator data has no AAGUID', async () => { + const spy = jest + .spyOn(parseAuthenticatorDataModule, 'parseAuthenticatorData') + .mockReturnValueOnce({ + ...mockParsedAuthBase, + credentialID: new Uint8Array([1]), + credentialPublicKey: new Uint8Array([0xa1]), + }); + + await expect( + verifyRegistrationResponse({ + response: attestationNone, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + }), + ).rejects.toThrow('No AAGUID was present during registration'); + + spy.mockRestore(); + }); }); describe('verifyRegistrationResponse missing public key fields', () => { diff --git a/packages/passkey-controller/src/webauthn/verify-signature.ts b/packages/passkey-controller/src/webauthn/verify-signature.ts index 07f7c78da2d..b7e1b6f50b8 100644 --- a/packages/passkey-controller/src/webauthn/verify-signature.ts +++ b/packages/passkey-controller/src/webauthn/verify-signature.ts @@ -175,7 +175,7 @@ async function verifyRSA( const verifyAlgorithm = keyAlgorithmName === 'RSA-PSS' - ? { name: 'RSA-PSS', saltLength: saltLength ?? 0 } + ? { name: 'RSA-PSS', saltLength: saltLength as number } : 'RSASSA-PKCS1-v1_5'; const signatureBytes = Uint8Array.from(signature); From 210b8b9191a52d309555e5ccafa19d097cec1bfe Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Fri, 24 Apr 2026 01:30:40 +0700 Subject: [PATCH 36/44] refactor: add errors and logging --- packages/passkey-controller/CHANGELOG.md | 14 +- packages/passkey-controller/README.md | 18 +- .../src/PasskeyController.test.ts | 272 ++++++++++++++++-- .../src/PasskeyController.ts | 147 ++++++++-- packages/passkey-controller/src/constants.ts | 33 +++ .../passkey-controller/src/errors.test.ts | 79 +++++ packages/passkey-controller/src/errors.ts | 88 +++++- packages/passkey-controller/src/index.ts | 8 +- .../src/key-derivation.test.ts | 9 +- .../passkey-controller/src/key-derivation.ts | 20 +- packages/passkey-controller/src/logger.ts | 9 + 11 files changed, 612 insertions(+), 85 deletions(-) create mode 100644 packages/passkey-controller/src/constants.ts create mode 100644 packages/passkey-controller/src/errors.test.ts create mode 100644 packages/passkey-controller/src/logger.ts diff --git a/packages/passkey-controller/CHANGELOG.md b/packages/passkey-controller/CHANGELOG.md index 6133b51c640..d666717b929 100644 --- a/packages/passkey-controller/CHANGELOG.md +++ b/packages/passkey-controller/CHANGELOG.md @@ -9,13 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Initial release ([#8422](https://github.com/MetaMask/core/pull/8422)) - - `PasskeyController` for passkey-based vault key protection with WebAuthn, AES-256-GCM wrap/unwrap, and HKDF-derived keys; optional `userName` / `userDisplayName` for the OS passkey UI (default `rpName`); constructor defaults `state` to `{}` - - Enrollment and unlock: `generateRegistrationOptions` / `protectVaultKeyWithPasskey`, `generateAuthenticationOptions` / `retrieveVaultKeyWithPasskey`, `renewVaultKeyProtection`, `removePasskey`, `clearState`; `verifyPasskeyAuthentication` validates authentication without returning the vault key - - `isPasskeyEnrolled` and `passkeyControllerSelectors.selectIsPasskeyEnrolled`; `destroy` / `removePasskey` / `clearState` clear in-flight ceremony state - - Adaptive enrollment key derivation (WebAuthn **PRF** or **userHandle** fallback); self-contained verification (`clientDataJSON` / `authenticatorData`, RSA signatures via `@noble/curves` and Web Crypto; attestation `none` and `packed` self-attestation) - - Challenge-keyed in-memory ceremony coordination (`CeremonyManager`), timing/limit constants exported from the package entry (`WEBAUTHN_TIMEOUT_MS`, `CEREMONY_TTL_SLACK_MS`, `CEREMONY_MAX_AGE_MS`, `MAX_CONCURRENT_PASSKEY_CEREMONIES`) - - `PasskeyAuthenticationRejectedError` for expected failures; `verifyAuthenticationResponse` returns a discriminated union on success vs failure - - Exported controller and WebAuthn types, AES-GCM helpers, and COSE enums (`COSEALG`, `COSEKEYS`, `COSEKTY`, `COSECRV`) +- Initial `@metamask/passkey-controller` ([#8422](https://github.com/MetaMask/core/pull/8422)): `PasskeyController` for WebAuthn passkey vault key protection (HKDF-derived keys, AES-256-GCM wrap/unwrap), PRF or `userHandle` derivation, challenge-keyed `CeremonyManager`, enrollment/unlock/renewal flows, `verifyPasskeyAuthentication`, selectors, and exported ceremony timing constants. +- `PasskeyControllerError` with stable `code`, optional `cause` / `context`, `toJSON`, and `toString`; `PasskeyControllerErrorCode`, `PasskeyControllerErrorMessage`, and `controllerName`. Replaces `PasskeyAuthenticationRejectedError`—use `PasskeyControllerError` and `code` for auth failures. +- **BREAKING:** Operational error messages are prefixed with `PasskeyController - `; prefer `code` or `instanceof PasskeyControllerError` over matching raw strings. +- `renewVaultKeyProtection` uses the same `vault_key_decryption_failed` code as `retrieveVaultKeyWithPasskey` when AES-GCM decrypt fails. +- Thrown failures from `verifyRegistrationResponse` / `verifyAuthenticationResponse` are wrapped in `PasskeyControllerError` with `registration_verification_failed` / `authentication_verification_failed` and the underlying error as `cause` (aligned with the `verified: false` path). +- Debug logging (via `@metamask/utils`) for registration/authentication verification failures, missing ceremony state, vault decrypt failures, and vault key mismatch during renewal. [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/passkey-controller/README.md b/packages/passkey-controller/README.md index 11c95864500..2acd9de9cf3 100644 --- a/packages/passkey-controller/README.md +++ b/packages/passkey-controller/README.md @@ -114,13 +114,17 @@ passkeyControllerSelectors.selectIsPasskeyEnrolled(state); // boolean ### Errors -`PasskeyAuthenticationRejectedError` is thrown when authentication or vault-key -recovery fails in an expected operational way (for example: not enrolled, no -active ceremony, failed WebAuthn verification, missing PRF / `userHandle` -material, or AES-GCM decrypt failure). `retrieveVaultKeyWithPasskey` and -related paths surface this type so callers can distinguish it from bugs or -malformed input. `verifyPasskeyAuthentication` returns `false` only for this -error and rethrows any other thrown value. +`PasskeyControllerError` is thrown for controller failures. Expected operational +cases use a stable `code` from `PasskeyControllerErrorCode` (for example: +`not_enrolled`, `no_registration_ceremony`, `authentication_verification_failed`, +`missing_key_material`, `vault_key_decryption_failed`). Human-readable strings +live on `PasskeyControllerErrorMessage`. Use `instanceof PasskeyControllerError` +and a defined `error.code` to tell these apart from malformed WebAuthn payloads +and other `Error` values. Thrown errors from the internal WebAuthn verify helpers +are also surfaced as `PasskeyControllerError` with the same `registration_verification_failed` +or `authentication_verification_failed` code and the original error as `cause`. +`verifyPasskeyAuthentication` returns `false` only for +those controller errors (with `code`) and rethrows everything else. ## API diff --git a/packages/passkey-controller/src/PasskeyController.test.ts b/packages/passkey-controller/src/PasskeyController.test.ts index a9eca14c245..c2a1e74b58a 100644 --- a/packages/passkey-controller/src/PasskeyController.test.ts +++ b/packages/passkey-controller/src/PasskeyController.test.ts @@ -1,7 +1,11 @@ import { Messenger } from '@metamask/messenger'; +import { + PasskeyControllerErrorCode, + PasskeyControllerErrorMessage, +} from './constants'; import { CEREMONY_MAX_AGE_MS, WEBAUTHN_TIMEOUT_MS } from './ceremony-manager'; -import { PasskeyAuthenticationRejectedError } from './errors'; +import { PasskeyControllerError } from './errors'; import { getDefaultPasskeyControllerState, passkeyControllerSelectors, @@ -16,6 +20,7 @@ import type { PasskeyRegistrationResponse, PasskeyAuthenticationResponse, } from './webauthn/types'; +import * as passkeyCrypto from './utils/crypto'; type ExtOutputsWithPrf = Record & PrfClientExtensionResults; @@ -318,7 +323,7 @@ describe('PasskeyController', () => { it('throws when passkey is not enrolled', () => { const controller = createController(); expect(() => controller.generateAuthenticationOptions()).toThrow( - 'Passkey is not enrolled', + PasskeyControllerErrorMessage.NotEnrolled, ); }); @@ -363,7 +368,7 @@ describe('PasskeyController', () => { registrationResponse: minimalRegistrationResponse(), vaultKey: 'k', }), - ).rejects.toThrow('No active passkey registration ceremony'); + ).rejects.toThrow(PasskeyControllerErrorMessage.NoRegistrationCeremony); }); it('throws when verification fails', async () => { @@ -382,10 +387,32 @@ describe('PasskeyController', () => { ), vaultKey: 'k', }), - ).rejects.toThrow('Passkey registration verification failed'); + ).rejects.toThrow( + PasskeyControllerErrorMessage.RegistrationVerificationFailed, + ); + }); + + it('wraps non-Error verifyRegistrationResponse rejection in RegistrationVerificationFailed', async () => { + mockVerifyRegistrationResponse.mockRejectedValue('verify-string-error'); + + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'k', + }), + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.RegistrationVerificationFailed, + cause: expect.objectContaining({ message: 'verify-string-error' }), + }); }); - it('propagates when verifyRegistrationResponse rejects and clears ceremony state', async () => { + it('wraps verifyRegistrationResponse rejection in RegistrationVerificationFailed and clears ceremony state', async () => { mockVerifyRegistrationResponse.mockRejectedValue( new Error('verify-error'), ); @@ -401,7 +428,11 @@ describe('PasskeyController', () => { ), vaultKey: 'k', }), - ).rejects.toThrow('verify-error'); + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.RegistrationVerificationFailed, + message: PasskeyControllerErrorMessage.RegistrationVerificationFailed, + cause: expect.objectContaining({ message: 'verify-error' }), + }); await expect( controller.protectVaultKeyWithPasskey({ @@ -411,7 +442,7 @@ describe('PasskeyController', () => { ), vaultKey: 'k', }), - ).rejects.toThrow('No active passkey registration ceremony'); + ).rejects.toThrow(PasskeyControllerErrorMessage.NoRegistrationCeremony); }); it('stores passkey record with publicKey after successful verification', async () => { @@ -511,7 +542,7 @@ describe('PasskeyController', () => { controller.retrieveVaultKeyWithPasskey( minimalAuthenticationResponse('uh'), ), - ).rejects.toThrow('Passkey is not enrolled'); + ).rejects.toThrow(PasskeyControllerErrorMessage.NotEnrolled); }); it('throws when there is no authentication ceremony', async () => { @@ -530,7 +561,7 @@ describe('PasskeyController', () => { controller.retrieveVaultKeyWithPasskey( minimalAuthenticationResponse('uh'), ), - ).rejects.toThrow('No active passkey authentication ceremony'); + ).rejects.toThrow(PasskeyControllerErrorMessage.NoAuthenticationCeremony); }); it('throws when verification fails', async () => { @@ -555,7 +586,112 @@ describe('PasskeyController', () => { controller.retrieveVaultKeyWithPasskey( minimalAuthenticationResponse('uh', undefined, authOpts.challenge), ), - ).rejects.toThrow('Passkey authentication verification failed'); + ).rejects.toThrow( + PasskeyControllerErrorMessage.AuthenticationVerificationFailed, + ); + }); + + it('wraps non-Error verifyAuthenticationResponse rejection in AuthenticationVerificationFailed', async () => { + setupRegistrationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'k', + }); + + mockVerifyAuthenticationResponse.mockRejectedValue( + 'auth-string-error', + ); + + const authOpts = controller.generateAuthenticationOptions(); + + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse('uh', undefined, authOpts.challenge), + ), + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.AuthenticationVerificationFailed, + cause: expect.objectContaining({ message: 'auth-string-error' }), + }); + }); + + it('wraps verifyAuthenticationResponse rejection in AuthenticationVerificationFailed', async () => { + setupRegistrationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'k', + }); + + mockVerifyAuthenticationResponse.mockRejectedValue( + new Error('auth-verify-error'), + ); + + const authOpts = controller.generateAuthenticationOptions(); + + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse('uh', undefined, authOpts.challenge), + ), + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.AuthenticationVerificationFailed, + message: PasskeyControllerErrorMessage.AuthenticationVerificationFailed, + cause: expect.objectContaining({ message: 'auth-verify-error' }), + }); + }); + + it('wraps non-Error decrypt failure in VaultKeyDecryptionFailed when retrieving vault key', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + vaultKey: 'secret', + }); + + const decryptSpy = jest + .spyOn(passkeyCrypto, 'decryptWithKey') + .mockImplementation(() => { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- exercise non-Error rejection normalization + throw 'decrypt-string-fail'; + }); + + const authOpts = controller.generateAuthenticationOptions(); + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + ), + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.VaultKeyDecryptionFailed, + cause: expect.objectContaining({ + message: 'decrypt-string-fail', + }), + }); + + decryptSpy.mockRestore(); }); it('throws when passkey record disappears while persisting counter after auth', async () => { @@ -597,7 +733,7 @@ describe('PasskeyController', () => { authOpts.challenge, ), ), - ).rejects.toThrow(PasskeyAuthenticationRejectedError); + ).rejects.toThrow(PasskeyControllerError); updateSpy.mockRestore(); }); @@ -637,7 +773,7 @@ describe('PasskeyController', () => { clientExtensionResults: prfResults(prfFirst), }), ), - ).rejects.toThrow('No active passkey authentication ceremony'); + ).rejects.toThrow(PasskeyControllerErrorMessage.NoAuthenticationCeremony); }); }); @@ -778,7 +914,7 @@ describe('PasskeyController', () => { authOpts.challenge, ), ), - ).rejects.toThrow('Passkey vault key decryption failed'); + ).rejects.toThrow(PasskeyControllerErrorMessage.VaultKeyDecryptionFailed); authOpts = controller.generateAuthenticationOptions(); await expect( @@ -789,7 +925,7 @@ describe('PasskeyController', () => { authOpts.challenge, ), ), - ).rejects.toThrow('Passkey assertion missing required key material'); + ).rejects.toThrow(PasskeyControllerErrorMessage.MissingKeyMaterial); }); }); @@ -838,7 +974,7 @@ describe('PasskeyController', () => { oldVaultKey: 'old', newVaultKey: 'new', }), - ).rejects.toThrow('Passkey is not enrolled'); + ).rejects.toThrow(PasskeyControllerErrorMessage.NotEnrolled); }); it('updates the passkey wrap when before/after vault keys match', async () => { @@ -919,9 +1055,97 @@ describe('PasskeyController', () => { oldVaultKey: 'wrong-expected-key', newVaultKey: 'new-key', }), - ).rejects.toThrow( - 'Passkey authentication does not match the current vault key', - ); + ).rejects.toThrow(PasskeyControllerErrorMessage.VaultKeyMismatch); + }); + + it('throws when decrypting the wrapped vault key fails during renewal', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + vaultKey: 'wrapped-key', + }); + + const decryptSpy = jest + .spyOn(passkeyCrypto, 'decryptWithKey') + .mockImplementation(() => { + throw new Error('decrypt failed'); + }); + + const authOpts = controller.generateAuthenticationOptions(); + await expect( + controller.renewVaultKeyProtection({ + authenticationResponse: minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + oldVaultKey: 'wrapped-key', + newVaultKey: 'new-key', + }), + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.VaultKeyDecryptionFailed, + }); + + decryptSpy.mockRestore(); + }); + + it('wraps non-Error decrypt failure in VaultKeyDecryptionFailed during renewal', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + + const regOpts = controller.generateRegistrationOptions(); + await controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + vaultKey: 'wrapped-key', + }); + + const decryptSpy = jest + .spyOn(passkeyCrypto, 'decryptWithKey') + .mockImplementation(() => { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- exercise non-Error rejection normalization + throw 'renew-decrypt-fail'; + }); + + const authOpts = controller.generateAuthenticationOptions(); + await expect( + controller.renewVaultKeyProtection({ + authenticationResponse: minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + oldVaultKey: 'wrapped-key', + newVaultKey: 'new-key', + }), + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.VaultKeyDecryptionFailed, + cause: expect.objectContaining({ message: 'renew-decrypt-fail' }), + }); + + decryptSpy.mockRestore(); }); it('throws when passkey record disappears while persisting renewed ciphertext', async () => { @@ -965,7 +1189,7 @@ describe('PasskeyController', () => { oldVaultKey: 'wrapped', newVaultKey: 'next', }), - ).rejects.toThrow(PasskeyAuthenticationRejectedError); + ).rejects.toThrow(PasskeyControllerError); updateSpy.mockRestore(); }); @@ -1065,7 +1289,7 @@ describe('PasskeyController', () => { ), vaultKey: 'k', }), - ).rejects.toThrow('No active passkey registration ceremony'); + ).rejects.toThrow(PasskeyControllerErrorMessage.NoRegistrationCeremony); }); it('clears stored record and resets enrollment', async () => { @@ -1123,7 +1347,7 @@ describe('PasskeyController', () => { ), vaultKey: 'k', }), - ).rejects.toThrow('No active passkey registration ceremony'); + ).rejects.toThrow(PasskeyControllerErrorMessage.NoRegistrationCeremony); }); }); @@ -1480,7 +1704,7 @@ describe('PasskeyController', () => { ), vaultKey: 'k', }), - ).rejects.toThrow('No active passkey registration ceremony'); + ).rejects.toThrow(PasskeyControllerErrorMessage.NoRegistrationCeremony); jest.useRealTimers(); }); @@ -1513,7 +1737,9 @@ describe('PasskeyController', () => { authOpts.challenge, ), ), - ).rejects.toThrow('Passkey authentication verification failed'); + ).rejects.toThrow( + PasskeyControllerErrorMessage.AuthenticationVerificationFailed, + ); mockVerifyAuthenticationResponse.mockResolvedValue({ verified: true, diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index 8c419f28b75..ca4c943b87c 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -8,7 +8,13 @@ import type { Messenger } from '@metamask/messenger'; import { randomBytes } from '@noble/ciphers/webcrypto'; import { WEBAUTHN_TIMEOUT_MS, CeremonyManager } from './ceremony-manager'; -import { PasskeyAuthenticationRejectedError } from './errors'; +import { + controllerName, + PasskeyControllerErrorCode, + PasskeyControllerErrorMessage, +} from './constants'; +import { PasskeyControllerError } from './errors'; +import { createModuleLogger, projectLogger } from './logger'; import { deriveKeyFromAuthenticationResponse, deriveKeyFromRegistrationResponse, @@ -27,8 +33,6 @@ import type { import { verifyAuthenticationResponse } from './webauthn/verify-authentication-response'; import { verifyRegistrationResponse } from './webauthn/verify-registration-response'; -const controllerName = 'PasskeyController'; - export type PasskeyControllerState = { passkeyRecord: PasskeyRecord | null; }; @@ -81,6 +85,8 @@ const passkeyControllerMetadata = { }, } satisfies StateMetadata; +const log = createModuleLogger(projectLogger, controllerName); + /** * Selectors for {@link PasskeyControllerState}. * @@ -169,7 +175,12 @@ export class PasskeyController extends BaseController< #requireEnrolled(): PasskeyRecord { const record = this.state.passkeyRecord; if (!record) { - throw new PasskeyAuthenticationRejectedError('Passkey is not enrolled'); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.NotEnrolled, + { + code: PasskeyControllerErrorCode.NotEnrolled, + }, + ); } return record; } @@ -298,15 +309,20 @@ export class PasskeyController extends BaseController< registrationResponse: PasskeyRegistrationResponse; vaultKey: string; }): Promise { - // get challenge const { registrationResponse, vaultKey } = params; + + // get challenge const challenge = this.#getChallengeFromClientData( registrationResponse.response.clientDataJSON, ); const registrationCeremony = this.#ceremonyManager.getRegistrationCeremony(challenge); if (!registrationCeremony) { - throw new Error('No active passkey registration ceremony'); + log('No active passkey registration ceremony for challenge'); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.NoRegistrationCeremony, + { code: PasskeyControllerErrorCode.NoRegistrationCeremony }, + ); } try { @@ -317,9 +333,28 @@ export class PasskeyController extends BaseController< expectedOrigin: this.#expectedOrigin, expectedRPID: this.#rpID, requireUserVerification: false, + }).catch((error) => { + log( + 'Error verifying passkey registration response', + error, + ); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.RegistrationVerificationFailed, + { + code: PasskeyControllerErrorCode.RegistrationVerificationFailed, + cause: + error instanceof Error ? error : new Error(String(error)), + }, + ); }); if (!verified || !registrationInfo) { - throw new Error('Passkey registration verification failed'); + log( + 'Passkey registration verification returned unverified or missing registration info', + ); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.RegistrationVerificationFailed, + { code: PasskeyControllerErrorCode.RegistrationVerificationFailed }, + ); } // derive key @@ -377,9 +412,17 @@ export class PasskeyController extends BaseController< passkeyRecord.encryptedVaultKey.iv, encKey, ); - } catch { - throw new PasskeyAuthenticationRejectedError( - 'Passkey vault key decryption failed', + } catch (cause) { + log( + 'Error decrypting vault key with passkey', + cause instanceof Error ? cause : new Error(String(cause)), + ); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.VaultKeyDecryptionFailed, + { + code: PasskeyControllerErrorCode.VaultKeyDecryptionFailed, + cause: cause instanceof Error ? cause : new Error(String(cause)), + }, ); } @@ -390,9 +433,9 @@ export class PasskeyController extends BaseController< * Returns whether passkey authentication succeeds for this credential (same * work as {@link retrieveVaultKeyWithPasskey} without exposing the vault key). * - * Returns `false` only when the failure is a normal authentication outcome - * ({@link PasskeyAuthenticationRejectedError}). Unexpected errors (e.g. malformed - * `clientDataJSON`, internal bugs) are rethrown. + * Returns `false` only when the failure is a {@link PasskeyControllerError} + * with a defined `code`. Unexpected errors (e.g. malformed `clientDataJSON`, + * internal bugs) are rethrown. * * @param authenticationResponse - Credential from `navigator.credentials.get()`. * @returns `true` if authentication succeeds, otherwise `false`. @@ -404,7 +447,10 @@ export class PasskeyController extends BaseController< await this.retrieveVaultKeyWithPasskey(authenticationResponse); return true; } catch (error: unknown) { - if (error instanceof PasskeyAuthenticationRejectedError) { + if ( + error instanceof PasskeyControllerError && + error.code !== undefined + ) { return false; } throw error; @@ -441,17 +487,34 @@ export class PasskeyController extends BaseController< ); // decrypt vault key - const decryptedVaultKey = decryptWithKey( - passkeyRecord.encryptedVaultKey.ciphertext, - passkeyRecord.encryptedVaultKey.iv, - encKey, - ); + let decryptedVaultKey: string; + try { + decryptedVaultKey = decryptWithKey( + passkeyRecord.encryptedVaultKey.ciphertext, + passkeyRecord.encryptedVaultKey.iv, + encKey, + ); + } catch (error) { + log( + 'Error decrypting vault key during passkey vault key renewal', + error instanceof Error ? error : new Error(String(error)), + ); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.VaultKeyDecryptionFailed, + { + code: PasskeyControllerErrorCode.VaultKeyDecryptionFailed, + cause: error instanceof Error ? error : new Error(String(error)), + }, + ); + } // check if vault key matches const { oldVaultKey, newVaultKey } = params; if (decryptedVaultKey !== oldVaultKey) { - throw new Error( - 'Passkey authentication does not match the current vault key', + log('Passkey renewal rejected: decrypted vault key does not match oldVaultKey'); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.VaultKeyMismatch, + { code: PasskeyControllerErrorCode.VaultKeyMismatch }, ); } @@ -461,7 +524,12 @@ export class PasskeyController extends BaseController< // persist passkey record (mutate current state only for vault key material) this.update((state) => { if (!state.passkeyRecord) { - throw new PasskeyAuthenticationRejectedError('Passkey is not enrolled'); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.NotEnrolled, + { + code: PasskeyControllerErrorCode.NotEnrolled, + }, + ); } state.passkeyRecord.encryptedVaultKey = { ciphertext, iv }; }); @@ -512,8 +580,10 @@ export class PasskeyController extends BaseController< const authenticationCeremony = this.#ceremonyManager.getAuthenticationCeremony(challenge); if (!authenticationCeremony) { - throw new PasskeyAuthenticationRejectedError( - 'No active passkey authentication ceremony', + log('No active passkey authentication ceremony for challenge'); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.NoAuthenticationCeremony, + { code: PasskeyControllerErrorCode.NoAuthenticationCeremony }, ); } @@ -531,19 +601,36 @@ export class PasskeyController extends BaseController< }, // UV optional for device compatibility; vault key remains password-gated. requireUserVerification: false, - }); - + }).catch((error) => { + log( + 'Error verifying passkey authentication response', + error instanceof Error ? error : new Error(String(error)), + ); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.AuthenticationVerificationFailed, + { + code: PasskeyControllerErrorCode.AuthenticationVerificationFailed, + cause: + error instanceof Error ? error : new Error(String(error)), + }, + ); + }) if (!result.verified) { - throw new PasskeyAuthenticationRejectedError( - 'Passkey authentication verification failed', + log('Passkey authentication verification returned unverified'); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.AuthenticationVerificationFailed, + { + code: PasskeyControllerErrorCode.AuthenticationVerificationFailed, + }, ); } // persist passkey record with updated counter without clobbering concurrent updates this.update((state) => { if (!state.passkeyRecord) { - throw new PasskeyAuthenticationRejectedError( - 'Passkey is not enrolled', + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.NotEnrolled, + { code: PasskeyControllerErrorCode.NotEnrolled }, ); } const latest = state.passkeyRecord; diff --git a/packages/passkey-controller/src/constants.ts b/packages/passkey-controller/src/constants.ts new file mode 100644 index 00000000000..595a8549120 --- /dev/null +++ b/packages/passkey-controller/src/constants.ts @@ -0,0 +1,33 @@ +export const controllerName = 'PasskeyController'; + +/** + * Stable programmatic codes for {@link PasskeyControllerError}. + * Use these instead of matching `message` strings. + */ +export const PasskeyControllerErrorCode = { + NotEnrolled: 'not_enrolled', + NoRegistrationCeremony: 'no_registration_ceremony', + RegistrationVerificationFailed: 'registration_verification_failed', + NoAuthenticationCeremony: 'no_authentication_ceremony', + AuthenticationVerificationFailed: 'authentication_verification_failed', + MissingKeyMaterial: 'missing_key_material', + VaultKeyDecryptionFailed: 'vault_key_decryption_failed', + VaultKeyMismatch: 'vault_key_mismatch', +} as const; + +export type PasskeyControllerErrorCode = + (typeof PasskeyControllerErrorCode)[keyof typeof PasskeyControllerErrorCode]; + +/** + * Human-readable messages for {@link PasskeyControllerError}. + */ +export enum PasskeyControllerErrorMessage { + NotEnrolled = `${controllerName} - Passkey is not enrolled`, + NoRegistrationCeremony = `${controllerName} - No active passkey registration ceremony`, + RegistrationVerificationFailed = `${controllerName} - Passkey registration verification failed`, + NoAuthenticationCeremony = `${controllerName} - No active passkey authentication ceremony`, + AuthenticationVerificationFailed = `${controllerName} - Passkey authentication verification failed`, + MissingKeyMaterial = `${controllerName} - Passkey assertion missing required key material`, + VaultKeyDecryptionFailed = `${controllerName} - Passkey vault key decryption failed`, + VaultKeyMismatch = `${controllerName} - Passkey authentication does not match the current vault key`, +} diff --git a/packages/passkey-controller/src/errors.test.ts b/packages/passkey-controller/src/errors.test.ts new file mode 100644 index 00000000000..ccd249f4d01 --- /dev/null +++ b/packages/passkey-controller/src/errors.test.ts @@ -0,0 +1,79 @@ +import { + PasskeyControllerErrorCode, + PasskeyControllerErrorMessage, +} from './constants'; +import { PasskeyControllerError } from './errors'; + +describe('PasskeyControllerError', () => { + it('sets code and cause from options', () => { + const cause = new Error('inner'); + const controllerError = new PasskeyControllerError( + PasskeyControllerErrorMessage.VaultKeyDecryptionFailed, + { + code: PasskeyControllerErrorCode.VaultKeyDecryptionFailed, + cause, + }, + ); + expect(controllerError.code).toBe( + PasskeyControllerErrorCode.VaultKeyDecryptionFailed, + ); + expect(controllerError.cause).toBe(cause); + expect(controllerError.toJSON()).toMatchObject({ + name: 'PasskeyControllerError', + code: PasskeyControllerErrorCode.VaultKeyDecryptionFailed, + message: PasskeyControllerErrorMessage.VaultKeyDecryptionFailed, + }); + }); + + it('supports Error as second argument for cause', () => { + const inner = new Error('x'); + const controllerError = new PasskeyControllerError('msg', inner); + expect(controllerError.cause).toBe(inner); + }); + + it('sets context from options', () => { + const controllerError = new PasskeyControllerError('msg', { + code: PasskeyControllerErrorCode.NotEnrolled, + context: { detail: 'x' }, + }); + expect(controllerError.context).toStrictEqual({ detail: 'x' }); + expect(controllerError.toJSON().context).toStrictEqual({ detail: 'x' }); + }); + + it('serializes cause in toJSON', () => { + const cause = new Error('inner'); + const controllerError = new PasskeyControllerError('msg', { + code: PasskeyControllerErrorCode.NotEnrolled, + cause, + }); + expect(controllerError.toJSON().cause).toMatchObject({ + name: 'Error', + message: 'inner', + }); + }); + + it('toString includes code when set', () => { + const controllerError = new PasskeyControllerError('msg', { + code: PasskeyControllerErrorCode.NotEnrolled, + }); + expect(controllerError.toString()).toContain('[not_enrolled]'); + }); + + it('toString includes cause when set', () => { + const cause = new Error('inner'); + const controllerError = new PasskeyControllerError('msg', { cause }); + expect(controllerError.toString()).toContain('Caused by:'); + expect(controllerError.toString()).toContain('inner'); + }); + + it('toString includes code and cause when both are set', () => { + const cause = new Error('inner'); + const controllerError = new PasskeyControllerError('msg', { + code: PasskeyControllerErrorCode.NotEnrolled, + cause, + }); + const text = controllerError.toString(); + expect(text).toContain('[not_enrolled]'); + expect(text).toContain('Caused by:'); + }); +}); diff --git a/packages/passkey-controller/src/errors.ts b/packages/passkey-controller/src/errors.ts index f40376e89b6..e0bbf6ebbe4 100644 --- a/packages/passkey-controller/src/errors.ts +++ b/packages/passkey-controller/src/errors.ts @@ -1,8 +1,86 @@ +import type { PasskeyControllerErrorCode as PasskeyControllerErrorCodeType } from './constants'; + /** - * Thrown when passkey authentication or vault-key recovery fails in an - * expected operational way (not enrolled, no ceremony, bad assertion, missing - * key material, decrypt failure). + * Options for creating a {@link PasskeyControllerError}. */ -export class PasskeyAuthenticationRejectedError extends Error { - override readonly name = 'PasskeyAuthenticationRejectedError'; +export type PasskeyControllerErrorOptions = { + /** + * The underlying error that caused this error (for error chaining). + */ + cause?: Error; + /** + * Stable code for programmatic handling (see {@link PasskeyControllerErrorCode}). + */ + code?: PasskeyControllerErrorCodeType; + /** + * Additional context for debugging or reporting. + */ + context?: Record; +}; + +/** + * Error class for PasskeyController-related errors. + */ +export class PasskeyControllerError extends Error { + code?: PasskeyControllerErrorCodeType; + + context?: Record; + + cause?: Error; + + /** + * @param message - The error message. + * @param options - Error options or an `Error` instance used as `cause` (Keyring-style overload). + */ + constructor( + message: string, + options?: PasskeyControllerErrorOptions | Error, + ) { + super(message); + this.name = 'PasskeyControllerError'; + + const cause = options instanceof Error ? options : options?.cause; + const code = options instanceof Error ? undefined : options?.code; + const context = options instanceof Error ? undefined : options?.context; + + if (cause) { + this.cause = cause; + } + if (code) { + this.code = code; + } + if (context) { + this.context = context; + } + + Object.setPrototypeOf(this, PasskeyControllerError.prototype); + } + + toJSON(): Record { + return { + name: this.name, + message: this.message, + code: this.code, + context: this.context, + stack: this.stack, + cause: this.cause + ? { + name: this.cause.name, + message: this.cause.message, + stack: this.cause.stack, + } + : undefined, + }; + } + + override toString(): string { + let result = `${this.name}: ${this.message}`; + if (this.code) { + result += ` [${this.code}]`; + } + if (this.cause) { + result += `\n Caused by: ${this.cause}`; + } + return result; + } } diff --git a/packages/passkey-controller/src/index.ts b/packages/passkey-controller/src/index.ts index d86ecf4f62f..06378d1b6ef 100644 --- a/packages/passkey-controller/src/index.ts +++ b/packages/passkey-controller/src/index.ts @@ -1,4 +1,10 @@ -export { PasskeyAuthenticationRejectedError } from './errors'; +export { + controllerName, + PasskeyControllerErrorCode, + PasskeyControllerErrorMessage, +} from './constants'; +export { PasskeyControllerError } from './errors'; +export type { PasskeyControllerErrorOptions } from './errors'; export { PasskeyController, getDefaultPasskeyControllerState, diff --git a/packages/passkey-controller/src/key-derivation.test.ts b/packages/passkey-controller/src/key-derivation.test.ts index 95ed46b8185..3c997a2247b 100644 --- a/packages/passkey-controller/src/key-derivation.test.ts +++ b/packages/passkey-controller/src/key-derivation.test.ts @@ -1,6 +1,7 @@ +import { PasskeyControllerErrorMessage } from './constants'; import { - deriveKeyFromRegistrationResponse, deriveKeyFromAuthenticationResponse, + deriveKeyFromRegistrationResponse, } from './key-derivation'; import type { PasskeyRecord, PasskeyRegistrationCeremony } from './types'; import type { @@ -219,7 +220,7 @@ describe('deriveKeyFromAuthenticationResponse', () => { expect(() => deriveKeyFromAuthenticationResponse(response, makeRecord('userHandle')), - ).toThrow('Passkey assertion missing required key material'); + ).toThrow(PasskeyControllerErrorMessage.MissingKeyMaterial); }); it('throws when PRF derivation is needed but PRF output is missing', () => { @@ -227,7 +228,7 @@ describe('deriveKeyFromAuthenticationResponse', () => { expect(() => deriveKeyFromAuthenticationResponse(response, makeRecord('prf')), - ).toThrow('Passkey assertion missing required key material'); + ).toThrow(PasskeyControllerErrorMessage.MissingKeyMaterial); }); it('throws when PRF derivation is needed but prf.results.first is empty', () => { @@ -237,7 +238,7 @@ describe('deriveKeyFromAuthenticationResponse', () => { expect(() => deriveKeyFromAuthenticationResponse(response, makeRecord('prf')), - ).toThrow('Passkey assertion missing required key material'); + ).toThrow(PasskeyControllerErrorMessage.MissingKeyMaterial); }); it('produces consistent keys across registration and authentication', () => { diff --git a/packages/passkey-controller/src/key-derivation.ts b/packages/passkey-controller/src/key-derivation.ts index 5dce2913684..8dfab9ccffb 100644 --- a/packages/passkey-controller/src/key-derivation.ts +++ b/packages/passkey-controller/src/key-derivation.ts @@ -1,4 +1,8 @@ -import { PasskeyAuthenticationRejectedError } from './errors'; +import { + PasskeyControllerErrorCode, + PasskeyControllerErrorMessage, +} from './constants'; +import { PasskeyControllerError } from './errors'; import type { PasskeyKeyDerivation, PasskeyRecord, @@ -66,8 +70,8 @@ export function deriveKeyFromRegistrationResponse( * @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. + * @throws {@link PasskeyControllerError} with code `missing_key_material` if the + * required key material (PRF result or userHandle) is missing from the response. */ export function deriveKeyFromAuthenticationResponse( authenticationResponse: PasskeyAuthenticationResponse, @@ -82,16 +86,18 @@ export function deriveKeyFromAuthenticationResponse( let ikm: Uint8Array; if (record.keyDerivation.method === 'prf') { if (!hasPrfOutput) { - throw new PasskeyAuthenticationRejectedError( - 'Passkey assertion missing required key material', + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.MissingKeyMaterial, + { code: PasskeyControllerErrorCode.MissingKeyMaterial }, ); } ikm = base64URLToBytes(prfFirst); } else if (userHandle) { ikm = base64URLToBytes(userHandle); } else { - throw new PasskeyAuthenticationRejectedError( - 'Passkey assertion missing required key material', + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.MissingKeyMaterial, + { code: PasskeyControllerErrorCode.MissingKeyMaterial }, ); } diff --git a/packages/passkey-controller/src/logger.ts b/packages/passkey-controller/src/logger.ts new file mode 100644 index 00000000000..81fa93678f7 --- /dev/null +++ b/packages/passkey-controller/src/logger.ts @@ -0,0 +1,9 @@ +/* istanbul ignore file */ + +import { createProjectLogger, createModuleLogger } from '@metamask/utils'; + +import { controllerName } from './constants'; + +export const projectLogger = createProjectLogger(controllerName); + +export { createModuleLogger }; From 819f0c4fc992f29de7f10c833f8266904c9eeeac Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Fri, 24 Apr 2026 17:47:57 +0700 Subject: [PATCH 37/44] Update Release 935.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 84834cd6b8c..aa59c1ec9b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "930.0.0", + "version": "935.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { From 11fb44f48358b71fc244f77acb47d91fd2dd9a2b Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Fri, 24 Apr 2026 23:50:41 +0700 Subject: [PATCH 38/44] fix: address review comments --- packages/passkey-controller/CHANGELOG.md | 5 ++ .../src/PasskeyController.test.ts | 2 +- .../src/PasskeyController.ts | 3 +- .../src/key-derivation.test.ts | 25 ++++++--- .../passkey-controller/src/key-derivation.ts | 10 +++- .../verify-registration-response.test.ts | 56 +++++++++++++++++-- .../webauthn/verify-registration-response.ts | 13 ++++- 7 files changed, 94 insertions(+), 20 deletions(-) diff --git a/packages/passkey-controller/CHANGELOG.md b/packages/passkey-controller/CHANGELOG.md index d666717b929..349b1314029 100644 --- a/packages/passkey-controller/CHANGELOG.md +++ b/packages/passkey-controller/CHANGELOG.md @@ -16,4 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Thrown failures from `verifyRegistrationResponse` / `verifyAuthenticationResponse` are wrapped in `PasskeyControllerError` with `registration_verification_failed` / `authentication_verification_failed` and the underlying error as `cause` (aligned with the `verified: false` path). - Debug logging (via `@metamask/utils`) for registration/authentication verification failures, missing ceremony state, vault decrypt failures, and vault key mismatch during renewal. +### Fixed + +- Registration verification requires the credential `id`/`rawId` to match the credential id in authenticator data; vault wrapping key derivation uses that verified credential id so enrollment keys align with the stored credential. +- Registration options request attestation conveyance `'none'` so clients are not asked for direct attestation formats the verifier does not implement (`none` and self-attested `packed` only). + [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/passkey-controller/src/PasskeyController.test.ts b/packages/passkey-controller/src/PasskeyController.test.ts index c2a1e74b58a..dae87272ecc 100644 --- a/packages/passkey-controller/src/PasskeyController.test.ts +++ b/packages/passkey-controller/src/PasskeyController.test.ts @@ -237,7 +237,7 @@ describe('PasskeyController', () => { { alg: -7, type: 'public-key' }, { alg: -257, type: 'public-key' }, ]); - expect(options.attestation).toBe('direct'); + expect(options.attestation).toBe('none'); expect(options.timeout).toBe(WEBAUTHN_TIMEOUT_MS); expect( (options.extensions as Record)?.prf, diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index ca4c943b87c..e271f5f4e22 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -242,7 +242,7 @@ export class PasskeyController extends BaseController< residentKey: 'preferred', }, hints: ['client-device', 'hybrid'], - attestation: 'direct', + attestation: 'none', ...(Object.keys(extensions).length > 0 ? { extensions } : {}), }; @@ -361,6 +361,7 @@ export class PasskeyController extends BaseController< const { encKey, keyDerivation } = deriveKeyFromRegistrationResponse( registrationResponse, registrationCeremony, + registrationInfo.credentialId, ); // encrypt vault key diff --git a/packages/passkey-controller/src/key-derivation.test.ts b/packages/passkey-controller/src/key-derivation.test.ts index 3c997a2247b..a47b89766df 100644 --- a/packages/passkey-controller/src/key-derivation.test.ts +++ b/packages/passkey-controller/src/key-derivation.test.ts @@ -90,6 +90,7 @@ describe('deriveKeyFromRegistrationResponse', () => { const { encKey, keyDerivation } = deriveKeyFromRegistrationResponse( response, makeRegistrationCeremony(), + CREDENTIAL_ID, ); expect(keyDerivation).toStrictEqual({ method: 'prf', prfSalt: PRF_SALT }); @@ -105,6 +106,7 @@ describe('deriveKeyFromRegistrationResponse', () => { const { keyDerivation } = deriveKeyFromRegistrationResponse( response, makeRegistrationCeremony(), + CREDENTIAL_ID, ); expect(keyDerivation).toStrictEqual({ method: 'prf', prfSalt: PRF_SALT }); @@ -118,6 +120,7 @@ describe('deriveKeyFromRegistrationResponse', () => { const { keyDerivation } = deriveKeyFromRegistrationResponse( response, makeRegistrationCeremony(), + CREDENTIAL_ID, ); expect(keyDerivation).toStrictEqual({ method: 'userHandle' }); @@ -129,6 +132,7 @@ describe('deriveKeyFromRegistrationResponse', () => { const { encKey, keyDerivation } = deriveKeyFromRegistrationResponse( response, makeRegistrationCeremony(), + CREDENTIAL_ID, ); expect(keyDerivation).toStrictEqual({ method: 'userHandle' }); @@ -144,23 +148,25 @@ describe('deriveKeyFromRegistrationResponse', () => { const { keyDerivation } = deriveKeyFromRegistrationResponse( response, makeRegistrationCeremony(), + CREDENTIAL_ID, ); expect(keyDerivation).toStrictEqual({ method: 'userHandle' }); }); - it('produces different keys for different credential IDs', () => { - const response1 = makeRegistrationResponse({}); - const response2 = makeRegistrationResponse({}); - response2.id = b64url('different-cred-id'); + it('produces different keys for different verified credential IDs', () => { + const response = makeRegistrationResponse({}); + const ceremony = makeRegistrationCeremony(); const { encKey: key1 } = deriveKeyFromRegistrationResponse( - response1, - makeRegistrationCeremony(), + response, + ceremony, + CREDENTIAL_ID, ); const { encKey: key2 } = deriveKeyFromRegistrationResponse( - response2, - makeRegistrationCeremony(), + response, + ceremony, + b64url('different-cred-id'), ); expect(key1).not.toStrictEqual(key2); @@ -177,10 +183,12 @@ describe('deriveKeyFromRegistrationResponse', () => { const { encKey: prfKey } = deriveKeyFromRegistrationResponse( responseWithPrf, registrationCeremony, + CREDENTIAL_ID, ); const { encKey: uhKey } = deriveKeyFromRegistrationResponse( responseWithoutPrf, registrationCeremony, + CREDENTIAL_ID, ); expect(prfKey).not.toStrictEqual(uhKey); @@ -248,6 +256,7 @@ describe('deriveKeyFromAuthenticationResponse', () => { const { encKey: regKey } = deriveKeyFromRegistrationResponse( regResponse, registrationCeremony, + CREDENTIAL_ID, ); const authResponse = makeAuthenticationResponse({}, USER_HANDLE); diff --git a/packages/passkey-controller/src/key-derivation.ts b/packages/passkey-controller/src/key-derivation.ts index 8dfab9ccffb..001ab5fe8df 100644 --- a/packages/passkey-controller/src/key-derivation.ts +++ b/packages/passkey-controller/src/key-derivation.ts @@ -29,17 +29,20 @@ import type { * `navigator.credentials.create()`. * @param registrationCeremony - In-flight registration ceremony state from * when `generateRegistrationOptions()` was called. + * @param verifiedCredentialId - Base64url credential id from verified + * authenticator data (same value as persisted `credential.id` after + * `verifyRegistrationResponse`), not the client wrapper field alone. * @returns The derived 32-byte AES wrapping key and the * {@link PasskeyKeyDerivation} parameters needed to reproduce it. */ export function deriveKeyFromRegistrationResponse( registrationResponse: PasskeyRegistrationResponse, registrationCeremony: PasskeyRegistrationCeremony, + verifiedCredentialId: string, ): { encKey: Uint8Array; keyDerivation: PasskeyKeyDerivation; } { - const credentialId = registrationResponse.id; const prfFirst = ( registrationResponse.clientExtensionResults as PrfClientExtensionResults )?.prf?.results?.first; @@ -52,7 +55,10 @@ export function deriveKeyFromRegistrationResponse( keyDerivation.method === 'prf' ? base64URLToBytes(prfFirst as string) : base64URLToBytes(registrationCeremony.userHandle); - const encKey = deriveEncryptionKey(ikm, base64URLToBytes(credentialId)); + const encKey = deriveEncryptionKey( + ikm, + base64URLToBytes(verifiedCredentialId), + ); return { encKey, keyDerivation }; } diff --git a/packages/passkey-controller/src/webauthn/verify-registration-response.test.ts b/packages/passkey-controller/src/webauthn/verify-registration-response.test.ts index 662a90319fe..d1d9b8d096e 100644 --- a/packages/passkey-controller/src/webauthn/verify-registration-response.test.ts +++ b/packages/passkey-controller/src/webauthn/verify-registration-response.test.ts @@ -49,8 +49,10 @@ const attestationFIDOU2F: PasskeyRegistrationResponse = { }; const attestationPacked: PasskeyRegistrationResponse = { - id: 'bbb', - rawId: 'bbb', + id: + 'AYThY1csINY4JrbHyGmqTl1nL_F1zjAF3hSAIngz8kAcjugmAMNVvxZRwqpEH-bNHHAIv291OX5ko9eDf_5mu3UB2BvsScr2K-ppM4owOpGsqwg5tZglqqmxIm1Q', + rawId: + 'AYThY1csINY4JrbHyGmqTl1nL_F1zjAF3hSAIngz8kAcjugmAMNVvxZRwqpEH-bNHHAIv291OX5ko9eDf_5mu3UB2BvsScr2K-ppM4owOpGsqwg5tZglqqmxIm1Q', response: { attestationObject: 'o2NmbXRmcGFja2VkZ2F0dFN0bXSiY2FsZyZjc2lnWEcwRQIhANvrPZMUFrl_rvlgR' + @@ -70,8 +72,10 @@ const attestationPacked: PasskeyRegistrationResponse = { }; const attestationPackedX5C: PasskeyRegistrationResponse = { - id: 'aaa', - rawId: 'aaa', + id: + '4rrvMciHCkdLQ2HghazIp1sMc8TmV8W8RgoX-x8tqV_1AmlqWACqUK8mBGLandr-htduQKPzgb2yWxOFV56Tlg', + rawId: + '4rrvMciHCkdLQ2HghazIp1sMc8TmV8W8RgoX-x8tqV_1AmlqWACqUK8mBGLandr-htduQKPzgb2yWxOFV56Tlg', response: { attestationObject: 'o2NmbXRmcGFja2VkZ2F0dFN0bXSjY2FsZyZjc2lnWEcwRQIhAIMt_hGMtdgpIVIwMOeKK' + @@ -498,6 +502,36 @@ describe('verifyRegistrationResponse edge cases', () => { ).rejects.toThrow('User presence was required'); }); + it('rejects credential id not matching authenticator data', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x30); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: cosePublicKeyCBOR, + }); + + const wrongWrapperId = bytesToBase64URL(new Uint8Array(16).fill(0x42)); + const response = buildRegistrationResponse(authData, wrongWrapperId); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPID: TEST_RP_ID, + }), + ).rejects.toThrow( + 'Credential id does not match the credential id in authenticator data', + ); + }); + it('rejects unsupported public key algorithm', async () => { const unsupportedMap = new Map(); unsupportedMap.set(COSEKEYS.Kty, COSEKTY.EC2); @@ -855,9 +889,14 @@ describe('verifyRegistrationResponse edge cases', () => { credentialID: new Uint8Array([1]), }); + const credentialIdB64 = bytesToBase64URL(new Uint8Array([1])); await expect( verifyRegistrationResponse({ - response: attestationNone, + response: { + ...attestationNone, + id: credentialIdB64, + rawId: credentialIdB64, + }, expectedChallenge: noneChallenge, expectedOrigin: EXPECTED_ORIGIN, expectedRPID: EXPECTED_RP_ID, @@ -876,9 +915,14 @@ describe('verifyRegistrationResponse edge cases', () => { credentialPublicKey: new Uint8Array([0xa1]), }); + const credentialIdB64 = bytesToBase64URL(new Uint8Array([1])); await expect( verifyRegistrationResponse({ - response: attestationNone, + response: { + ...attestationNone, + id: credentialIdB64, + rawId: credentialIdB64, + }, expectedChallenge: noneChallenge, expectedOrigin: EXPECTED_ORIGIN, expectedRPID: EXPECTED_RP_ID, diff --git a/packages/passkey-controller/src/webauthn/verify-registration-response.ts b/packages/passkey-controller/src/webauthn/verify-registration-response.ts index 5f6cde646f1..ce8eebf6fd0 100644 --- a/packages/passkey-controller/src/webauthn/verify-registration-response.ts +++ b/packages/passkey-controller/src/webauthn/verify-registration-response.ts @@ -36,7 +36,8 @@ export type VerifiedRegistrationResponse = * W3C WebAuthn Level 3 §7.1. * * Performs the following checks in order: - * 1. Credential ID presence and base64url consistency (`id === rawId`). + * 1. Credential ID presence and base64url consistency (`id === rawId`), and + * that `id` matches the credential id inside parsed authenticator data. * 2. Credential type is `"public-key"`. * 3. `clientDataJSON` -- type is `"webauthn.create"`, challenge and origin * match the expected values. @@ -181,6 +182,14 @@ export async function verifyRegistrationResponse(opts: { if (!credentialID) { throw new Error('No credential ID was provided by authenticator'); } + + const attestedCredentialId = bytesToBase64URL(credentialID); + if (id !== attestedCredentialId) { + throw new Error( + 'Credential id does not match the credential id in authenticator data', + ); + } + if (!credentialPublicKey) { throw new Error('No public key was provided by authenticator'); } @@ -238,7 +247,7 @@ export async function verifyRegistrationResponse(opts: { return { verified: true, registrationInfo: { - credentialId: bytesToBase64URL(credentialID), + credentialId: attestedCredentialId, publicKey: credentialPublicKey, counter, transports: From b97bc05d2e3fee6857db02fa0af2dae2298b4e9b Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Fri, 24 Apr 2026 23:59:19 +0700 Subject: [PATCH 39/44] fix: lint --- .../src/PasskeyController.test.ts | 8 +++---- .../src/PasskeyController.ts | 24 +++++++------------ .../verify-registration-response.test.ts | 6 ++--- 3 files changed, 14 insertions(+), 24 deletions(-) diff --git a/packages/passkey-controller/src/PasskeyController.test.ts b/packages/passkey-controller/src/PasskeyController.test.ts index dae87272ecc..93ab6595308 100644 --- a/packages/passkey-controller/src/PasskeyController.test.ts +++ b/packages/passkey-controller/src/PasskeyController.test.ts @@ -1,10 +1,10 @@ import { Messenger } from '@metamask/messenger'; +import { CEREMONY_MAX_AGE_MS, WEBAUTHN_TIMEOUT_MS } from './ceremony-manager'; import { PasskeyControllerErrorCode, PasskeyControllerErrorMessage, } from './constants'; -import { CEREMONY_MAX_AGE_MS, WEBAUTHN_TIMEOUT_MS } from './ceremony-manager'; import { PasskeyControllerError } from './errors'; import { getDefaultPasskeyControllerState, @@ -16,11 +16,11 @@ import type { PasskeyControllerState, } from './PasskeyController'; import type { PasskeyRecord, PrfClientExtensionResults } from './types'; +import * as passkeyCrypto from './utils/crypto'; import type { PasskeyRegistrationResponse, PasskeyAuthenticationResponse, } from './webauthn/types'; -import * as passkeyCrypto from './utils/crypto'; type ExtOutputsWithPrf = Record & PrfClientExtensionResults; @@ -603,9 +603,7 @@ describe('PasskeyController', () => { vaultKey: 'k', }); - mockVerifyAuthenticationResponse.mockRejectedValue( - 'auth-string-error', - ); + mockVerifyAuthenticationResponse.mockRejectedValue('auth-string-error'); const authOpts = controller.generateAuthenticationOptions(); diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index e271f5f4e22..bbb6dd71c65 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -14,11 +14,11 @@ import { PasskeyControllerErrorMessage, } from './constants'; import { PasskeyControllerError } from './errors'; -import { createModuleLogger, projectLogger } from './logger'; import { deriveKeyFromAuthenticationResponse, deriveKeyFromRegistrationResponse, } from './key-derivation'; +import { createModuleLogger, projectLogger } from './logger'; import type { PasskeyRecord } from './types'; import { decryptWithKey, encryptWithKey } from './utils/crypto'; import { base64URLToBytes, bytesToBase64URL } from './utils/encoding'; @@ -334,16 +334,12 @@ export class PasskeyController extends BaseController< expectedRPID: this.#rpID, requireUserVerification: false, }).catch((error) => { - log( - 'Error verifying passkey registration response', - error, - ); + log('Error verifying passkey registration response', error); throw new PasskeyControllerError( PasskeyControllerErrorMessage.RegistrationVerificationFailed, { code: PasskeyControllerErrorCode.RegistrationVerificationFailed, - cause: - error instanceof Error ? error : new Error(String(error)), + cause: error instanceof Error ? error : new Error(String(error)), }, ); }); @@ -448,10 +444,7 @@ export class PasskeyController extends BaseController< await this.retrieveVaultKeyWithPasskey(authenticationResponse); return true; } catch (error: unknown) { - if ( - error instanceof PasskeyControllerError && - error.code !== undefined - ) { + if (error instanceof PasskeyControllerError && error.code !== undefined) { return false; } throw error; @@ -512,7 +505,9 @@ export class PasskeyController extends BaseController< // check if vault key matches const { oldVaultKey, newVaultKey } = params; if (decryptedVaultKey !== oldVaultKey) { - log('Passkey renewal rejected: decrypted vault key does not match oldVaultKey'); + log( + 'Passkey renewal rejected: decrypted vault key does not match oldVaultKey', + ); throw new PasskeyControllerError( PasskeyControllerErrorMessage.VaultKeyMismatch, { code: PasskeyControllerErrorCode.VaultKeyMismatch }, @@ -611,11 +606,10 @@ export class PasskeyController extends BaseController< PasskeyControllerErrorMessage.AuthenticationVerificationFailed, { code: PasskeyControllerErrorCode.AuthenticationVerificationFailed, - cause: - error instanceof Error ? error : new Error(String(error)), + cause: error instanceof Error ? error : new Error(String(error)), }, ); - }) + }); if (!result.verified) { log('Passkey authentication verification returned unverified'); throw new PasskeyControllerError( diff --git a/packages/passkey-controller/src/webauthn/verify-registration-response.test.ts b/packages/passkey-controller/src/webauthn/verify-registration-response.test.ts index d1d9b8d096e..ae70ac73d20 100644 --- a/packages/passkey-controller/src/webauthn/verify-registration-response.test.ts +++ b/packages/passkey-controller/src/webauthn/verify-registration-response.test.ts @@ -49,8 +49,7 @@ const attestationFIDOU2F: PasskeyRegistrationResponse = { }; const attestationPacked: PasskeyRegistrationResponse = { - id: - 'AYThY1csINY4JrbHyGmqTl1nL_F1zjAF3hSAIngz8kAcjugmAMNVvxZRwqpEH-bNHHAIv291OX5ko9eDf_5mu3UB2BvsScr2K-ppM4owOpGsqwg5tZglqqmxIm1Q', + id: 'AYThY1csINY4JrbHyGmqTl1nL_F1zjAF3hSAIngz8kAcjugmAMNVvxZRwqpEH-bNHHAIv291OX5ko9eDf_5mu3UB2BvsScr2K-ppM4owOpGsqwg5tZglqqmxIm1Q', rawId: 'AYThY1csINY4JrbHyGmqTl1nL_F1zjAF3hSAIngz8kAcjugmAMNVvxZRwqpEH-bNHHAIv291OX5ko9eDf_5mu3UB2BvsScr2K-ppM4owOpGsqwg5tZglqqmxIm1Q', response: { @@ -72,8 +71,7 @@ const attestationPacked: PasskeyRegistrationResponse = { }; const attestationPackedX5C: PasskeyRegistrationResponse = { - id: - '4rrvMciHCkdLQ2HghazIp1sMc8TmV8W8RgoX-x8tqV_1AmlqWACqUK8mBGLandr-htduQKPzgb2yWxOFV56Tlg', + id: '4rrvMciHCkdLQ2HghazIp1sMc8TmV8W8RgoX-x8tqV_1AmlqWACqUK8mBGLandr-htduQKPzgb2yWxOFV56Tlg', rawId: '4rrvMciHCkdLQ2HghazIp1sMc8TmV8W8RgoX-x8tqV_1AmlqWACqUK8mBGLandr-htduQKPzgb2yWxOFV56Tlg', response: { From 4c0846313dead6c481d6f11c62e564ca17b043b4 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Sat, 25 Apr 2026 00:06:58 +0700 Subject: [PATCH 40/44] Revert "Update Release 935.0.0" This reverts commit 819f0c4fc992f29de7f10c833f8266904c9eeeac. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index aa59c1ec9b5..84834cd6b8c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "935.0.0", + "version": "930.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { From 872f43703ddac7384d8534571408c41f8b37b496 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Mon, 27 Apr 2026 16:14:10 +0700 Subject: [PATCH 41/44] feat: save aaguid to passkey record --- .github/CODEOWNERS | 2 +- packages/passkey-controller/src/PasskeyController.test.ts | 5 +++++ packages/passkey-controller/src/PasskeyController.ts | 1 + packages/passkey-controller/src/types.ts | 2 ++ 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f2dd5c5b6fd..a0d4ee6543c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -102,7 +102,7 @@ ## Web3Auth Team /packages/seedless-onboarding-controller @MetaMask/web3auth -/packages/passkey-controller @MetaMask/web3auth +/packages/passkey-controller @MetaMask/web3auth /packages/shield-controller @MetaMask/web3auth /packages/subscription-controller @MetaMask/web3auth /packages/claims-controller @MetaMask/web3auth diff --git a/packages/passkey-controller/src/PasskeyController.test.ts b/packages/passkey-controller/src/PasskeyController.test.ts index 93ab6595308..1d7f36566ac 100644 --- a/packages/passkey-controller/src/PasskeyController.test.ts +++ b/packages/passkey-controller/src/PasskeyController.test.ts @@ -199,6 +199,7 @@ describe('PasskeyController', () => { publicKey: TEST_PUBLIC_KEY, counter: 0, transports: ['internal'], + aaguid: '00000000-0000-0000-0000-000000000000', }, encryptedVaultKey: { ciphertext: 'YQ==', @@ -463,6 +464,9 @@ describe('PasskeyController', () => { expect(record?.credential.id).toBe(TEST_CREDENTIAL_ID); expect(record?.credential.publicKey).toBe(TEST_PUBLIC_KEY); expect(record?.credential.transports).toStrictEqual(['internal']); + expect(record?.credential.aaguid).toBe( + '00000000-0000-0000-0000-000000000000', + ); expect(record?.keyDerivation.method).toBe('userHandle'); }); @@ -1366,6 +1370,7 @@ describe('PasskeyController', () => { publicKey: TEST_PUBLIC_KEY, counter: 0, transports: ['internal'], + aaguid: '00000000-0000-0000-0000-000000000000', }, encryptedVaultKey: { ciphertext: 'YQ==', iv: 'YWFhYWFhYWFhYQ==' }, keyDerivation: { method: 'userHandle' }, diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index bbb6dd71c65..1e90bc75197 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -371,6 +371,7 @@ export class PasskeyController extends BaseController< publicKey: bytesToBase64URL(registrationInfo.publicKey), counter: registrationInfo.counter, transports: registrationInfo.transports, + aaguid: registrationInfo.aaguid, }, encryptedVaultKey: { ciphertext, iv }, keyDerivation, diff --git a/packages/passkey-controller/src/types.ts b/packages/passkey-controller/src/types.ts index 013358d6606..68eefe8de2a 100644 --- a/packages/passkey-controller/src/types.ts +++ b/packages/passkey-controller/src/types.ts @@ -24,6 +24,8 @@ export type PasskeyCredentialInfo = { counter: number; /** Authenticator transports hint for `allowCredentials`. */ transports?: AuthenticatorTransportFuture[]; + /** Authenticator AAGUID captured from attested credential data at registration. */ + aaguid: string; }; /** From 938fc5d87824f770e49e5f6c2101bed8255cf1a3 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Mon, 27 Apr 2026 17:01:32 +0700 Subject: [PATCH 42/44] refactor: use equal helper from metamask util --- .../src/PasskeyController.ts | 8 ++++++- .../src/webauthn/match-expected-rp-id.ts | 22 ++----------------- 2 files changed, 9 insertions(+), 21 deletions(-) diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index 1e90bc75197..2eaaf39df89 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -5,6 +5,7 @@ import type { } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; +import { areUint8ArraysEqual, stringToBytes } from '@metamask/utils'; import { randomBytes } from '@noble/ciphers/webcrypto'; import { WEBAUTHN_TIMEOUT_MS, CeremonyManager } from './ceremony-manager'; @@ -505,7 +506,12 @@ export class PasskeyController extends BaseController< // check if vault key matches const { oldVaultKey, newVaultKey } = params; - if (decryptedVaultKey !== oldVaultKey) { + if ( + !areUint8ArraysEqual( + stringToBytes(decryptedVaultKey), + stringToBytes(oldVaultKey), + ) + ) { log( 'Passkey renewal rejected: decrypted vault key does not match oldVaultKey', ); diff --git a/packages/passkey-controller/src/webauthn/match-expected-rp-id.ts b/packages/passkey-controller/src/webauthn/match-expected-rp-id.ts index fcd10400095..8faba44d6d5 100644 --- a/packages/passkey-controller/src/webauthn/match-expected-rp-id.ts +++ b/packages/passkey-controller/src/webauthn/match-expected-rp-id.ts @@ -1,26 +1,8 @@ +import { areUint8ArraysEqual } from '@metamask/utils'; 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] ^ second[i]; - } - return diff === 0; -} - /** * Verify that an authenticator data rpIdHash matches one of the expected * RP IDs by SHA-256 hashing each candidate and comparing. @@ -36,7 +18,7 @@ export function matchExpectedRPID( ): string { for (const rpID of expectedRPIDs) { const expectedHash = sha256(new TextEncoder().encode(rpID)); - if (areEqual(rpIdHash, expectedHash)) { + if (areUint8ArraysEqual(rpIdHash, expectedHash)) { return rpID; } } From f9216088bd978e99bb0e03144c54a6ed8559b73e Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Mon, 27 Apr 2026 22:05:43 +0700 Subject: [PATCH 43/44] refactor: remove unnecessary exports of passkey controller --- packages/passkey-controller/src/index.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/passkey-controller/src/index.ts b/packages/passkey-controller/src/index.ts index 06378d1b6ef..013ec299595 100644 --- a/packages/passkey-controller/src/index.ts +++ b/packages/passkey-controller/src/index.ts @@ -1,21 +1,13 @@ export { - controllerName, PasskeyControllerErrorCode, PasskeyControllerErrorMessage, } from './constants'; export { PasskeyControllerError } from './errors'; -export type { PasskeyControllerErrorOptions } from './errors'; export { PasskeyController, getDefaultPasskeyControllerState, passkeyControllerSelectors, } from './PasskeyController'; -export { - WEBAUTHN_TIMEOUT_MS, - CEREMONY_TTL_SLACK_MS, - CEREMONY_MAX_AGE_MS, - MAX_CONCURRENT_PASSKEY_CEREMONIES, -} from './ceremony-manager'; export type { PasskeyControllerState, PasskeyControllerMessenger, @@ -25,7 +17,6 @@ export type { PasskeyControllerEvents, } from './PasskeyController'; export type { - EncryptedVaultKey, PasskeyCredentialInfo, PasskeyDerivationMethod, PasskeyKeyDerivation, @@ -38,5 +29,4 @@ export type { PasskeyRegistrationResponse, PasskeyAuthenticationOptions, PasskeyAuthenticationResponse, - PublicKeyCredentialHint, } from './webauthn/types'; From 7317ce4a42f9f22c63b9007718555d6799929a23 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Mon, 27 Apr 2026 22:23:17 +0700 Subject: [PATCH 44/44] fix: misleading error message of passkey auth counter --- .../verify-authentication-response.test.ts | 24 +++++++++++++++++-- .../verify-authentication-response.ts | 2 +- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/passkey-controller/src/webauthn/verify-authentication-response.test.ts b/packages/passkey-controller/src/webauthn/verify-authentication-response.test.ts index 492682c5a36..acb86d85f38 100644 --- a/packages/passkey-controller/src/webauthn/verify-authentication-response.test.ts +++ b/packages/passkey-controller/src/webauthn/verify-authentication-response.test.ts @@ -253,7 +253,7 @@ describe('verifyAuthenticationResponse', () => { ).rejects.toThrow('User not present during authentication'); }); - it('throws error if previous counter value is not less than in response', async () => { + it('throws error when response counter equals stored counter and monotonicity applies', async () => { await expect( verifyAuthenticationResponse({ response: assertionResponse, @@ -266,7 +266,27 @@ describe('verifyAuthenticationResponse', () => { }, requireUserVerification: false, }), - ).rejects.toThrow('Response counter value 144 was lower than expected 144'); + ).rejects.toThrow( + 'Response counter value 144 must be greater than stored counter 144', + ); + }); + + it('throws error when response counter is lower than stored counter', async () => { + await expect( + verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPID: EXPECTED_RP_ID, + credential: { + ...credential, + counter: 200, + }, + requireUserVerification: false, + }), + ).rejects.toThrow( + 'Response counter value 144 must be greater than stored counter 200', + ); }); it('does not compare counters if both are 0', async () => { diff --git a/packages/passkey-controller/src/webauthn/verify-authentication-response.ts b/packages/passkey-controller/src/webauthn/verify-authentication-response.ts index 029f12169e8..c7b5acd0bf3 100644 --- a/packages/passkey-controller/src/webauthn/verify-authentication-response.ts +++ b/packages/passkey-controller/src/webauthn/verify-authentication-response.ts @@ -199,7 +199,7 @@ export async function verifyAuthenticationResponse(opts: { counter <= credential.counter ) { throw new Error( - `Response counter value ${counter} was lower than expected ${credential.counter}`, + `Response counter value ${counter} must be greater than stored counter ${credential.counter}`, ); }