Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions packages/passkey-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- `PasskeyController.clearState` — resets passkey enrollment and clears in-flight WebAuthn sessions (aligned with other MetaMask controllers' `clearState` naming for lifecycle resets such as wallet reset)
- `PasskeyController` — manages passkey-based vault key protection using WebAuthn, orchestrating the full passkey lifecycle:
- `generateRegistrationOptions` — produces WebAuthn credential creation options for passkey enrollment
- `protectVaultKeyWithPasskey` — verifies a registration response and encrypts the vault key with the new credential
- `generateAuthenticationOptions` — produces WebAuthn credential request options for passkey authentication
- `retrieveVaultKeyWithPasskey` — verifies an authentication response and recovers the vault encryption key
- `renewVaultKeyProtection` — re-encrypts the vault key for password-change flows without re-enrolling the passkey
- `removePasskey` — unenrolls the passkey and clears all stored key material
- `isPasskeyEnrolled` — returns whether a passkey is currently enrolled
- Adaptive key derivation with two strategies selected automatically during enrollment:
- **PRF** — uses the WebAuthn PRF extension output as HKDF input key material
- **userHandle** — falls back to a random userHandle when PRF is unavailable
- Self-contained WebAuthn verification (no Node.js server dependencies):
- `clientDataJSON` verification: `type`, `challenge`, `origin`
- `authenticatorData` verification: `rpIdHash` (SHA-256 comparison), flags (`up`, `uv`), counter monotonicity
- Signature verification against stored credential public key using `@noble/curves` (EC2/EdDSA) and Web Crypto API (RSA fallback)
- Attestation format support: `none` and `packed` self-attestation
- AES-256-GCM encryption utilities for vault key wrapping with HKDF-SHA256 key derivation
- Exported types: `PasskeyControllerState`, `PasskeyControllerMessenger`, `PasskeyControllerGetStateAction`, `PasskeyControllerIsPasskeyEnrolledAction`, `PasskeyControllerActions`, `PasskeyControllerStateChangeEvent`, `PasskeyControllerEvents`
- Self-contained WebAuthn types: `PasskeyRegistrationOptions`, `PasskeyRegistrationResponse`, `PasskeyAuthenticationOptions`, `PasskeyAuthenticationResponse`
- COSE constant enums: `COSEALG`, `COSEKEYS`, `COSEKTY`, `COSECRV`

[Unreleased]: https://github.com/MetaMask/core/
21 changes: 21 additions & 0 deletions packages/passkey-controller/LICENSE
Original file line number Diff line number Diff line change
@@ -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.
124 changes: 124 additions & 0 deletions packages/passkey-controller/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# `@metamask/passkey-controller`

Manages passkey-based vault key protection using [WebAuthn](https://www.w3.org/TR/webauthn-3/). Orchestrates the full passkey lifecycle: generating WebAuthn ceremony options, verifying authenticator responses, and protecting/retrieving the vault encryption key via AES-256-GCM wrapping with HKDF-derived keys.

## Installation

`yarn add @metamask/passkey-controller`

or

`npm install @metamask/passkey-controller`

## Overview

The controller follows a two-phase ceremony pattern for both enrollment and authentication:

1. **Generate options** — call a synchronous method that returns options JSON and creates an in-memory session.
2. **Verify response** — pass the authenticator's response back to the controller, which verifies the WebAuthn signature and performs the cryptographic operation (protect or retrieve the vault key).

### Key derivation strategies

The controller supports two key derivation methods, selected automatically during enrollment:

| Strategy | When used | Input key material |
|---|---|---|
| **PRF** | Authenticator supports the [WebAuthn PRF extension](https://w3c.github.io/webauthn/#prf-extension) | PRF evaluation output |
| **userHandle** | PRF is unavailable | Random `userHandle` generated during registration |

Both strategies feed the input key material through **HKDF-SHA256** with the credential ID as salt and a fixed info string to produce the 32-byte AES-256 wrapping key.

## Usage

### Setting up the controller

```typescript
import { PasskeyController } from '@metamask/passkey-controller';
import type { PasskeyControllerMessenger } from '@metamask/passkey-controller';

const messenger: PasskeyControllerMessenger = /* create via root messenger */;

const controller = new PasskeyController({
messenger,
rpID: 'example.com',
rpName: 'My Wallet',
expectedOrigin: 'chrome-extension://abcdef1234567890',
});
```

### Passkey enrollment (registration)

```typescript
// 1. Generate registration options (synchronous)
const options = controller.generateRegistrationOptions();

// 2. Pass options to the browser WebAuthn API
const response = await navigator.credentials.create({ publicKey: options });

// 3. Verify and protect the vault key
await controller.protectVaultKeyWithPasskey({
registrationResponse: response,
vaultKey: myVaultEncryptionKey,
});
```

### Passkey unlock (authentication)

```typescript
// 1. Generate authentication options (synchronous)
const options = controller.generateAuthenticationOptions();

// 2. Pass options to the browser WebAuthn API
const response = await navigator.credentials.get({ publicKey: options });

// 3. Verify and retrieve the vault key
const vaultKey = await controller.retrieveVaultKeyWithPasskey(response);
```

### Password change (vault key renewal)

```typescript
const options = controller.generateAuthenticationOptions();
const response = await navigator.credentials.get({ publicKey: options });

await controller.renewVaultKeyProtection({
authenticationResponse: response,
oldVaultKey: currentVaultKey,
newVaultKey: newVaultKey,
});
```

### Checking enrollment and removing a passkey

```typescript
controller.isPasskeyEnrolled(); // boolean

controller.removePasskey(); // user-facing unenroll

controller.clearState(); // same persisted reset + session drop; use for app lifecycle (e.g. wallet reset)
```

## API

### State

| Property | Type | Description |
|---|---|---|
| `passkeyRecord` | `PasskeyRecord \| null` | Enrolled passkey credential data and encrypted vault key. `null` when no passkey is enrolled. |

### Messenger actions

| Action | Handler |
|---|---|
| `PasskeyController:getState` | Returns the current controller state |
| `PasskeyController:isPasskeyEnrolled` | Returns whether a passkey is currently enrolled |

### Messenger events

| Event | Payload |
|---|---|
| `PasskeyController:stateChange` | Emitted when state changes (standard `BaseController` event) |

## Contributing

This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme).
14 changes: 14 additions & 0 deletions packages/passkey-controller/jest.config.js
Original file line number Diff line number Diff line change
@@ -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: '<rootDir>/jest.environment.js',
coverageThreshold: {
global: { branches: 90, functions: 100, lines: 98, statements: 98 },
},
});
17 changes: 17 additions & 0 deletions packages/passkey-controller/jest.environment.js
Original file line number Diff line number Diff line change
@@ -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;
78 changes: 78 additions & 0 deletions packages/passkey-controller/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
{
"name": "@metamask/passkey-controller",
"version": "0.0.0",
"description": "Controller and utilities for passkey-based wallet unlock",
"keywords": [
"MetaMask",
"Ethereum"
],
"homepage": "https://github.com/MetaMask/core/tree/main/packages/passkey-controller#readme",
"bugs": {
"url": "https://github.com/MetaMask/core/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/MetaMask/core.git"
},
"license": "MIT",
"sideEffects": false,
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./package.json": "./package.json"
},
"main": "./dist/index.cjs",
"types": "./dist/index.d.cts",
"files": [
"dist/"
],
"scripts": {
"build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references",
"build:all": "ts-bridge --project tsconfig.build.json --verbose --clean",
"build:docs": "typedoc",
"changelog:update": "../../scripts/update-changelog.sh @metamask/passkey-controller",
"changelog:validate": "../../scripts/validate-changelog.sh @metamask/passkey-controller",
"publish:preview": "yarn npm publish --tag preview",
"since-latest-release": "../../scripts/since-latest-release.sh",
"test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter",
"test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache",
"test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose",
"test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch"
},
"dependencies": {
"@levischuck/tiny-cbor": "^0.2.2",
"@metamask/base-controller": "^9.0.0",
"@metamask/messenger": "^0.3.0",
"@metamask/utils": "^11.9.0",
"@noble/ciphers": "^1.3.0",
"@noble/curves": "^1.8.0",
"@noble/hashes": "^1.8.0"
},
"devDependencies": {
"@metamask/auto-changelog": "^3.4.4",
"@ts-bridge/cli": "^0.6.4",
"@types/jest": "^27.5.2",
"deepmerge": "^4.2.2",
"jest": "^27.5.1",
"jest-environment-node": "^27.5.1",
"ts-jest": "^27.1.5",
"typedoc": "^0.24.8",
"typedoc-plugin-missing-exports": "^2.0.0",
"typescript": "~5.3.3"
},
"engines": {
"node": "^18.18 || >=20"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}
Loading