Skip to content

Commit d6bd865

Browse files
authored
Feat/TO-540: Add passkey controller (MetaMask#8422)
## Description Introduces **`@metamask/passkey-controller`**, a `BaseController`-backed package that orchestrates WebAuthn passkey enrollment and authentication for vault key protection: generating ceremony options, verifying authenticator responses, HKDF-based key derivation (PRF vs `userHandle`), AES-256-GCM wrapping of the vault encryption key, renewal flows for password change, and state/ceremony management (including concurrent ceremonies and lifecycle clears). Also wires the package into the monorepo (workspace/tsconfig, `CODEOWNERS`, `teams.json`, root README, lockfile). ## Notes for reviewers (SimpleWebAuthn) **Extension vs Core split** - The **browser extension UI** can depend on **`@simplewebauthn/browser`** for client-side helpers that run where `navigator.credentials` is available. - The **extension background / service worker** cannot take a normal dependency on **`@simplewebauthn/server`**: that package targets Node-oriented verification APIs and assumptions that do not fit the constrained extension background environment the same way. To keep **verification logic aligned** with SimpleWebAuthn while staying dependency-appropriate in Core, this package **inlines / ports** the relevant **server-side verification** behavior (registration and authentication response verification, signature verification, CBOR/WebAuthn parsing helpers) into `packages/passkey-controller/src/webauthn/` rather than adding `@simplewebauthn/server` as a runtime dependency. When reviewing, please treat those modules as **parity-sensitive**: changes should stay consistent with upstream SimpleWebAuthn semantics where we intentionally mirror them (see recent commits around verification parity). ## Changelog - `packages/passkey-controller/CHANGELOG.md` — follow monorepo changelog rules; ensure `yarn validate:changelog` passes before merge. ## Related issues Fixes: <!-- TO-540 / GitHub issue --> ## Testing - `yarn workspace @metamask/passkey-controller run test` - (Optional) `yarn workspace @metamask/passkey-controller run build` ## Manual testing (consumers) N/A for Core in isolation; extension integration should exercise registration, unlock, change-password / vault key renewal, wallet reset, and edge cases called out in the extension PR. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **High Risk** > High risk because it introduces new authentication/cryptography flows (WebAuthn verification, HKDF/AES-GCM key wrapping) and new persisted state that will gate vault unlock and key rotation behavior. > > **Overview** > Introduces a new **`@metamask/passkey-controller`** package implementing passkey-based vault key protection: option generation for registration/authentication ceremonies, verification of WebAuthn responses, HKDF-derived AES-256-GCM wrap/unwrap of the vault key, and a renewal flow for updating the protected vault key. > > Adds supporting utilities for ceremony state management (TTL/capacity, concurrency handling), key-derivation (PRF vs `userHandle`), encoding/crypto helpers, and an error model (`PasskeyControllerError` with stable `code`/`cause`/`context`). > > Wires the new package into the monorepo via `README.md` entries and `CODEOWNERS` ownership for the new controller and its release files, and includes initial docs/changelog plus comprehensive unit tests. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 7317ce4. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 9ceae96 commit d6bd865

47 files changed

Lines changed: 7576 additions & 25 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/CODEOWNERS

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@
102102

103103
## Web3Auth Team
104104
/packages/seedless-onboarding-controller @MetaMask/web3auth
105+
/packages/passkey-controller @MetaMask/web3auth
105106
/packages/shield-controller @MetaMask/web3auth
106107
/packages/subscription-controller @MetaMask/web3auth
107108
/packages/claims-controller @MetaMask/web3auth
@@ -168,6 +169,8 @@
168169
/packages/geolocation-controller/CHANGELOG.md @MetaMask/core-platform
169170
/packages/keyring-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform
170171
/packages/keyring-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform
172+
/packages/passkey-controller/package.json @MetaMask/web3auth @MetaMask/core-platform
173+
/packages/passkey-controller/CHANGELOG.md @MetaMask/web3auth @MetaMask/core-platform
171174
/packages/logging-controller/package.json @MetaMask/confirmations @MetaMask/core-platform
172175
/packages/logging-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform
173176
/packages/message-manager/package.json @MetaMask/confirmations @MetaMask/core-platform

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ Each package in this repository has its own README where you can find installati
7878
- [`@metamask/network-controller`](packages/network-controller)
7979
- [`@metamask/network-enablement-controller`](packages/network-enablement-controller)
8080
- [`@metamask/notification-services-controller`](packages/notification-services-controller)
81+
- [`@metamask/passkey-controller`](packages/passkey-controller)
8182
- [`@metamask/permission-controller`](packages/permission-controller)
8283
- [`@metamask/permission-log-controller`](packages/permission-log-controller)
8384
- [`@metamask/perps-controller`](packages/perps-controller)
@@ -168,6 +169,7 @@ linkStyle default opacity:0.5
168169
network_controller(["@metamask/network-controller"]);
169170
network_enablement_controller(["@metamask/network-enablement-controller"]);
170171
notification_services_controller(["@metamask/notification-services-controller"]);
172+
passkey_controller(["@metamask/passkey-controller"]);
171173
permission_controller(["@metamask/permission-controller"]);
172174
permission_log_controller(["@metamask/permission-log-controller"]);
173175
perps_controller(["@metamask/perps-controller"]);
@@ -423,6 +425,8 @@ linkStyle default opacity:0.5
423425
notification_services_controller --> keyring_controller;
424426
notification_services_controller --> messenger;
425427
notification_services_controller --> profile_sync_controller;
428+
passkey_controller --> base_controller;
429+
passkey_controller --> messenger;
426430
permission_controller --> approval_controller;
427431
permission_controller --> base_controller;
428432
permission_controller --> controller_utils;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## [Unreleased]
9+
10+
### Added
11+
12+
- 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.
13+
- `PasskeyControllerError` with stable `code`, optional `cause` / `context`, `toJSON`, and `toString`; `PasskeyControllerErrorCode`, `PasskeyControllerErrorMessage`, and `controllerName`. Replaces `PasskeyAuthenticationRejectedError`—use `PasskeyControllerError` and `code` for auth failures.
14+
- **BREAKING:** Operational error messages are prefixed with `PasskeyController - `; prefer `code` or `instanceof PasskeyControllerError` over matching raw strings.
15+
- `renewVaultKeyProtection` uses the same `vault_key_decryption_failed` code as `retrieveVaultKeyWithPasskey` when AES-GCM decrypt fails.
16+
- 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).
17+
- Debug logging (via `@metamask/utils`) for registration/authentication verification failures, missing ceremony state, vault decrypt failures, and vault key mismatch during renewal.
18+
19+
### Fixed
20+
21+
- 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.
22+
- 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).
23+
24+
[Unreleased]: https://github.com/MetaMask/core/
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2025 MetaMask
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# `@metamask/passkey-controller`
2+
3+
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.
4+
5+
## Installation
6+
7+
`yarn add @metamask/passkey-controller`
8+
9+
or
10+
11+
`npm install @metamask/passkey-controller`
12+
13+
## Overview
14+
15+
The controller follows a two-phase ceremony pattern for both enrollment and authentication:
16+
17+
1. **Generate options** — call a synchronous method that returns options JSON and records **in-flight ceremony** state (challenge-keyed; not a user login session).
18+
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).
19+
20+
### Key derivation strategies
21+
22+
The controller supports two key derivation methods, selected automatically during enrollment:
23+
24+
| Strategy | When used | Input key material |
25+
| -------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
26+
| **PRF** | Authenticator supports the [WebAuthn PRF extension](https://w3c.github.io/webauthn/#prf-extension) | PRF evaluation output |
27+
| **userHandle** | PRF is unavailable | Random `userHandle` generated during registration |
28+
29+
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.
30+
31+
## Usage
32+
33+
### Setting up the controller
34+
35+
```typescript
36+
import { PasskeyController } from '@metamask/passkey-controller';
37+
import type { PasskeyControllerMessenger } from '@metamask/passkey-controller';
38+
39+
const messenger: PasskeyControllerMessenger = /* create via root messenger */;
40+
41+
const controller = new PasskeyController({
42+
messenger,
43+
rpID: 'example.com',
44+
rpName: 'My Wallet',
45+
expectedOrigin: 'chrome-extension://abcdef1234567890',
46+
// Optional — both default to `rpName` when omitted.
47+
userName: 'My Wallet',
48+
userDisplayName: 'My Wallet',
49+
});
50+
```
51+
52+
### Passkey enrollment (registration)
53+
54+
```typescript
55+
// 1. Generate registration options (synchronous)
56+
const options = controller.generateRegistrationOptions();
57+
58+
// 2. Pass options to the browser WebAuthn API
59+
const response = await navigator.credentials.create({ publicKey: options });
60+
61+
// 3. Verify and protect the vault key
62+
await controller.protectVaultKeyWithPasskey({
63+
registrationResponse: response,
64+
vaultKey: myVaultEncryptionKey,
65+
});
66+
```
67+
68+
### Passkey unlock (authentication)
69+
70+
```typescript
71+
// 1. Generate authentication options (synchronous)
72+
const options = controller.generateAuthenticationOptions();
73+
74+
// 2. Pass options to the browser WebAuthn API
75+
const response = await navigator.credentials.get({ publicKey: options });
76+
77+
// 3. Verify and retrieve the vault key
78+
const vaultKey = await controller.retrieveVaultKeyWithPasskey(response);
79+
```
80+
81+
### Password change (vault key renewal)
82+
83+
```typescript
84+
const options = controller.generateAuthenticationOptions();
85+
const response = await navigator.credentials.get({ publicKey: options });
86+
87+
await controller.renewVaultKeyProtection({
88+
authenticationResponse: response,
89+
oldVaultKey: currentVaultKey,
90+
newVaultKey: newVaultKey,
91+
});
92+
```
93+
94+
### Checking enrollment and removing a passkey
95+
96+
```typescript
97+
controller.isPasskeyEnrolled(); // boolean
98+
99+
controller.removePasskey(); // user-facing unenroll; clears persisted passkey and in-flight ceremonies
100+
101+
controller.clearState(); // same persisted reset + clears in-flight ceremony state; use for app lifecycle (e.g. wallet reset)
102+
```
103+
104+
### Selectors
105+
106+
For Redux selectors and other code paths without access to the controller
107+
instance, use the exported selector(s):
108+
109+
```typescript
110+
import { passkeyControllerSelectors } from '@metamask/passkey-controller';
111+
112+
passkeyControllerSelectors.selectIsPasskeyEnrolled(state); // boolean
113+
```
114+
115+
### Errors
116+
117+
`PasskeyControllerError` is thrown for controller failures. Expected operational
118+
cases use a stable `code` from `PasskeyControllerErrorCode` (for example:
119+
`not_enrolled`, `no_registration_ceremony`, `authentication_verification_failed`,
120+
`missing_key_material`, `vault_key_decryption_failed`). Human-readable strings
121+
live on `PasskeyControllerErrorMessage`. Use `instanceof PasskeyControllerError`
122+
and a defined `error.code` to tell these apart from malformed WebAuthn payloads
123+
and other `Error` values. Thrown errors from the internal WebAuthn verify helpers
124+
are also surfaced as `PasskeyControllerError` with the same `registration_verification_failed`
125+
or `authentication_verification_failed` code and the original error as `cause`.
126+
`verifyPasskeyAuthentication` returns `false` only for
127+
those controller errors (with `code`) and rethrows everything else.
128+
129+
## API
130+
131+
### State
132+
133+
| Property | Type | Description |
134+
| --------------- | ----------------------- | --------------------------------------------------------------------------------------------- |
135+
| `passkeyRecord` | `PasskeyRecord \| null` | Enrolled passkey credential data and encrypted vault key. `null` when no passkey is enrolled. |
136+
137+
### Messenger actions
138+
139+
| Action | Handler |
140+
| ---------------------------- | ------------------------------------ |
141+
| `PasskeyController:getState` | Returns the current controller state |
142+
143+
For derived enrollment status outside of components that hold a controller
144+
reference, use `passkeyControllerSelectors.selectIsPasskeyEnrolled` (see
145+
[Selectors](#selectors)).
146+
147+
### Messenger events
148+
149+
| Event | Payload |
150+
| -------------------------------- | ------------------------------------------------------------ |
151+
| `PasskeyController:stateChanged` | Emitted when state changes (standard `BaseController` event) |
152+
153+
## Contributing
154+
155+
This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme).
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
const merge = require('deepmerge');
2+
const path = require('path');
3+
4+
const baseConfig = require('../../jest.config.packages');
5+
6+
const displayName = path.basename(__dirname);
7+
8+
module.exports = merge(baseConfig, {
9+
displayName,
10+
testEnvironment: '<rootDir>/jest.environment.js',
11+
coverageThreshold: {
12+
global: { branches: 100, functions: 100, lines: 100, statements: 100 },
13+
},
14+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
const { TestEnvironment } = require('jest-environment-node');
2+
3+
/**
4+
* Passkey orchestration uses the Web Crypto API (`crypto.getRandomValues`) in Node tests.
5+
*/
6+
class CustomTestEnvironment extends TestEnvironment {
7+
async setup() {
8+
await super.setup();
9+
if (typeof this.global.crypto === 'undefined') {
10+
// Only used for testing.
11+
// eslint-disable-next-line n/no-unsupported-features/node-builtins
12+
this.global.crypto = require('crypto').webcrypto;
13+
}
14+
}
15+
}
16+
17+
module.exports = CustomTestEnvironment;
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
{
2+
"name": "@metamask/passkey-controller",
3+
"version": "0.0.0",
4+
"description": "Controller and utilities for passkey-based wallet unlock",
5+
"keywords": [
6+
"Ethereum",
7+
"MetaMask"
8+
],
9+
"homepage": "https://github.com/MetaMask/core/tree/main/packages/passkey-controller#readme",
10+
"bugs": {
11+
"url": "https://github.com/MetaMask/core/issues"
12+
},
13+
"license": "MIT",
14+
"repository": {
15+
"type": "git",
16+
"url": "https://github.com/MetaMask/core.git"
17+
},
18+
"files": [
19+
"dist/"
20+
],
21+
"sideEffects": false,
22+
"main": "./dist/index.cjs",
23+
"types": "./dist/index.d.cts",
24+
"exports": {
25+
".": {
26+
"import": {
27+
"types": "./dist/index.d.mts",
28+
"default": "./dist/index.mjs"
29+
},
30+
"require": {
31+
"types": "./dist/index.d.cts",
32+
"default": "./dist/index.cjs"
33+
}
34+
},
35+
"./package.json": "./package.json"
36+
},
37+
"publishConfig": {
38+
"access": "public",
39+
"registry": "https://registry.npmjs.org/"
40+
},
41+
"scripts": {
42+
"build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references",
43+
"build:all": "ts-bridge --project tsconfig.build.json --verbose --clean",
44+
"build:docs": "typedoc",
45+
"changelog:update": "../../scripts/update-changelog.sh @metamask/passkey-controller",
46+
"changelog:validate": "../../scripts/validate-changelog.sh @metamask/passkey-controller",
47+
"publish:preview": "yarn npm publish --tag preview",
48+
"since-latest-release": "../../scripts/since-latest-release.sh",
49+
"test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter",
50+
"test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache",
51+
"test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose",
52+
"test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch"
53+
},
54+
"dependencies": {
55+
"@levischuck/tiny-cbor": "^0.3.3",
56+
"@metamask/base-controller": "^9.1.0",
57+
"@metamask/messenger": "^1.1.1",
58+
"@metamask/utils": "^11.9.0",
59+
"@noble/ciphers": "^1.3.0",
60+
"@noble/curves": "^1.9.2",
61+
"@noble/hashes": "^1.8.0"
62+
},
63+
"devDependencies": {
64+
"@metamask/auto-changelog": "^6.1.0",
65+
"@ts-bridge/cli": "^0.6.4",
66+
"@types/jest": "^29.5.14",
67+
"deepmerge": "^4.2.2",
68+
"jest": "^29.7.0",
69+
"jest-environment-node": "^29.7.0",
70+
"ts-jest": "^29.2.5",
71+
"typedoc": "^0.25.13",
72+
"typedoc-plugin-missing-exports": "^2.0.0",
73+
"typescript": "~5.3.3"
74+
},
75+
"engines": {
76+
"node": "^18.18 || >=20"
77+
}
78+
}

0 commit comments

Comments
 (0)