Skip to content

Commit bfe02ee

Browse files
AkaryatrhjiexiBjornGunnarssoncursoragent
authored
feat: KYC controller (#9615)
# `@metamask/kyc-controller` ## Overview `@metamask/kyc-controller` is a shared, **platform-agnostic KYC / identity-verification** package for MetaMask clients (mobile, extension, web). It centralizes all KYC orchestration logic in the `core` monorepo so every client consumes one implementation instead of maintaining its own. ## What's included ### Core modules - **`KycController`** (`src/KycController.ts`) — A `BaseController` that owns the entire identity flow: - The **flow state machine**: `idle → terms → session → check → auth → form → submit → done / error`. - The **Check/Auth frame message protocol** for communicating with MoonPay's hosted frames (`blocks.moonpay.com`) over dedicated channels (`ch_1`, `ch_2`, `ch_reset`). - **X25519 credential decryption** and key-pair generation. - **SumSub orchestration** through an injected `KycSumSubLauncher` adapter, keeping the controller SDK-free while each client injects its own launcher. - **Product-scoped auto-continuation**: an optional `product` (`ramps` | `card`) passed to `initialize` / `acceptTermsAndStartSession` is stored as `activeProduct`. Once authentication reaches the `form` phase, the controller **automatically** runs the KYC-required check and, when KYC is required, chains into the SumSub sub-flow — with no extra consumer calls. Without a `product`, the flow stops at `form` for manual control (`checkKycRequired` / `startSumSub`). - A per-product **"is KYC required" cache** and security-aware state metadata (session/access tokens are marked non-persisted and non-logged). - **`KycService`** (`src/KycService.ts`) — A data service that performs the **Universal KYC (UKYC) HTTP calls** via an injected `fetch`. It wraps requests with `createServicePolicy` for resilience, validates responses with `superstruct`, and sources the auth bearer token (from `profile-sync-controller`) and geolocation (from `geolocation-controller`) through the messenger. It also accepts an optional `baseUrl` to override the `env`-derived URL for local/staging APIs. ### Supporting modules - **`crypto.ts`** — X25519 key-pair generation and encrypted-credential decryption. - **`countryCodes.ts`** — ISO 3166-1 alpha-2 → alpha-3 country-code mapping. - **`selectors.ts`** — Memoized state selectors (`selectKycPhase`, `selectKycSumSub`, `selectIsKycRequiredForProduct`). - **`types.ts`** — Shared vendor-neutral types (`KycPhase`, `KycProduct`, `KycVendor`, `KycSumSubLauncher`, `KycDisclaimer`, etc.). - **`KycController-method-action-types.ts` / `KycService-method-action-types.ts`** — Messenger action type definitions for the controller and service. - **`index.ts`** — Explicit (non-barrel) exports defining the package's public API. ### Tests Comprehensive Jest test suites covering the controller, service, crypto, country codes, selectors, and the index/public API (`KycController.ts` at 100% coverage, including the new auto-continuation paths). ### Package scaffolding & monorepo wiring - Standard package files: `package.json`, `tsconfig.json`, `tsconfig.build.json`, `jest.config.js`, `typedoc.json`, `CHANGELOG.md`, `README.md`, and the `LICENSE`, `LICENSE.APACHE2`, and `LICENSE.MIT` files. - Registered in the root `tsconfig.json` / `tsconfig.build.json` project references, added to the root `README.md` package list, and reflected in `yarn.lock`. ## Public API The package explicitly exports: - **Controller:** `KycController`, `getDefaultKycControllerState`, `controllerName`, plus its messenger, options, state, action, and event types. `initialize` / `acceptTermsAndStartSession` accept an optional `product` to enable the automatic post-authentication continuation. - **Service:** `KycService`, `serviceName`, plus its messenger, options, params, response, action, and event types. - **Selectors:** `selectIsKycRequiredForProduct`, `selectKycPhase`, `selectKycSumSub`. - **Utilities:** `alpha2ToAlpha3`, `ALPHA2_TO_ALPHA3`, `decryptCredentials`, `generateKeyPair`, and related crypto/domain types. ## Note The SumSub sub-flow currently uses placeholders (`MOCK_JWT_TOKEN` and a hardcoded `vendorUserId`), which must be replaced with real UKYC-issued credentials before production use. ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **High Risk** > New identity/KYC surface handling access tokens, encryption keys, JWT attestation, and user storage; incorrect flow guards or crypto could block verification or mishandle secrets, and mock JWT remains for production. > > **Overview** > Adds **`@metamask/kyc-controller`**, a new shared package that orchestrates vendor-neutral KYC for **`ramps`** and **`card`**: **`KycController`** runs the phase machine (terms → MoonPay Check/Auth frames → KYC-required → SumSub), decrypts frame credentials (X25519/AES-GCM), and **`KycService`** calls the Universal KYC API with bearer auth, geolocation, retries, and **superstruct** validation. > > **Product-scoped flows** store `activeProduct` and, after authentication, automatically run the KYC-required check and chain into document verification when needed; **`reset()`** and generation/phase guards prevent stale async work and late frame messages from corrupting state. The SumSub path adds UKYC wrapping-key verification (JWKS **`jwtChain`**), **`local_user_secret`** via **User Storage**, read-only **`ukyc_capability_token`**, SDK launch through an injected **`KycSumSubLauncher`**, and post-SDK session-status polling. > > Monorepo updates assign **Universal KYC** ownership, wire the package into the dependency graph, relax ESLint for the dev **`mint:ukyc-token`** script, and document architecture in **`ARCHITECTURE.md`**. **Note:** **`startSumSub`** still uses a **`MOCK_JWT_TOKEN`** placeholder until real UKYC credentials are wired. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 5a29cbb. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Signed-off-by: Sébastien Van Eyck <sebastien.vaneyck@consensys.net> Co-authored-by: Jiexi Luan <jiexiluan@gmail.com> Co-authored-by: Bjorn <bjorn.gunnarsson@consensys.net> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent ecf4e9d commit bfe02ee

46 files changed

Lines changed: 9370 additions & 27 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
@@ -76,6 +76,9 @@
7676
## Product Safety Team
7777
/packages/phishing-controller @MetaMask/product-safety
7878

79+
## Universal KYC Team
80+
/packages/kyc-controller @MetaMask/universal-kyc
81+
7982
## Swaps-Bridge Team
8083
/packages/bridge-controller @MetaMask/swaps-engineers
8184
/packages/bridge-status-controller @MetaMask/swaps-engineers

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,12 @@ linkStyle default opacity:0.5
426426
keyring_controller --> base_controller;
427427
keyring_controller --> controller_utils;
428428
keyring_controller --> messenger;
429+
kyc_controller --> base_controller;
430+
kyc_controller --> base_data_service;
431+
kyc_controller --> controller_utils;
432+
kyc_controller --> geolocation_controller;
433+
kyc_controller --> messenger;
434+
kyc_controller --> profile_sync_controller;
429435
logging_controller --> base_controller;
430436
logging_controller --> messenger;
431437
message_manager --> base_controller;

codeowners.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,10 @@ function buildTeamSections(): CodeownersSection[] {
514514
title: 'Product Safety Team',
515515
rules: [buildRuleForPackage('phishing-controller')],
516516
},
517+
{
518+
title: 'Universal KYC Team',
519+
rules: [buildRuleForPackage('kyc-controller')],
520+
},
517521
{
518522
title: 'Swaps-Bridge Team',
519523
rules: [

eslint.config.mjs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,15 @@ const config = createConfig([
314314
'no-restricted-globals': 'off',
315315
},
316316
},
317+
{
318+
// The UKYC test-token minter is a dev-only Node CLI, so it may use Node
319+
// builtins and globals unlike the platform-agnostic package source.
320+
files: ['packages/kyc-controller/scripts/**/*.ts'],
321+
rules: {
322+
'import-x/no-nodejs-modules': 'off',
323+
'no-restricted-globals': 'off',
324+
},
325+
},
317326
{
318327
files: [
319328
'packages/wallet-cli/src/**/*.test.{js,ts}',

packages/kyc-controller/ARCHITECTURE.md

Lines changed: 663 additions & 0 deletions
Large diffs are not rendered by default.

packages/kyc-controller/CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12-
- Initial release of the `@metamask/kyc-controller` package for managing KYC / identity verification state across MetaMask clients ([#9781](https://github.com/MetaMask/core/pull/9781))
12+
- Initial release of the `@metamask/kyc-controller` package: a platform-agnostic controller and data service for orchestrating KYC / identity verification across MetaMask clients ([#9615](https://github.com/MetaMask/core/pull/9615))
13+
- `KycController` owns the end-to-end flow: the KYC state machine, the MoonPay Check/Auth hosted-frame message protocol, X25519 credential decryption, and SumSub orchestration through an injected `KycSumSubLauncher` adapter (keeping the controller SDK-free). It performs the authenticated Universal KYC (UKYC) HTTP calls (disclaimers, sessions, kyc-required, wrapping-key, JWKS, UKYC session/journey/status polling) with `superstruct` response validation and `createServicePolicy` resilience, sourcing the bearer token and geolocation through the messenger.
14+
- Passing an optional `product` (`ramps` or `card`) makes the controller automatically run the KYC-required check and chain into the SumSub sub-flow after authentication, with generation/phase guards so `reset()` and stale frame messages cannot corrupt state.
1315

1416
[Unreleased]: https://github.com/MetaMask/core/

packages/kyc-controller/README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# `@metamask/kyc-controller`
1+
# KYC Controller `@metamask/kyc-controller`
22

33
Shared KYC / identity verification controller used across MetaMask clients
44

@@ -10,6 +10,14 @@ or
1010

1111
`npm install @metamask/kyc-controller`
1212

13+
## Development
14+
15+
To rebuild the package automatically whenever you change a source file, run the `build:watch` script from core repository root folder:
16+
17+
`yarn workspace @metamask/kyc-controller run build:watch`
18+
19+
This watches `src/**/*.ts` and re-runs the build on each change (it also performs an initial build on start), which is useful when developing against a client that consumes this package locally.
20+
1321
## Contributing
1422

1523
This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme).

packages/kyc-controller/package.json

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,24 +42,45 @@
4242
"build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references",
4343
"build:all": "ts-bridge --project tsconfig.build.json --verbose --clean",
4444
"build:docs": "typedoc",
45+
"build:watch": "yarn build && chokidar 'src/**/*.ts' -c 'ts-bridge --project tsconfig.build.json --verbose --no-references' --initial",
4546
"changelog:update": "../../scripts/update-changelog.sh @metamask/kyc-controller",
4647
"changelog:validate": "../../scripts/validate-changelog.sh @metamask/kyc-controller",
4748
"lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts",
4849
"lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix",
49-
"messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --check",
50-
"messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --generate",
50+
"messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check",
51+
"messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate",
52+
"mint:ukyc-token": "tsx scripts/mint-ukyc-test-token.ts",
5153
"since-latest-release": "../../scripts/since-latest-release.sh",
5254
"test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter",
5355
"test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache",
5456
"test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose",
5557
"test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch"
5658
},
59+
"dependencies": {
60+
"@metamask/base-controller": "^9.1.0",
61+
"@metamask/base-data-service": "^0.1.3",
62+
"@metamask/controller-utils": "^12.3.0",
63+
"@metamask/geolocation-controller": "^1.0.0",
64+
"@metamask/messenger": "^2.0.0",
65+
"@metamask/profile-sync-controller": "^29.0.0",
66+
"@metamask/superstruct": "^3.4.1",
67+
"@metamask/utils": "^11.11.0",
68+
"@noble/ciphers": "^1.3.0",
69+
"@noble/curves": "^1.9.2",
70+
"@noble/hashes": "^1.8.0",
71+
"@scure/base": "^1.2.6",
72+
"@tanstack/query-core": "^4.43.0",
73+
"reselect": "^5.1.1",
74+
"tweetnacl": "^1.0.3"
75+
},
5776
"devDependencies": {
5877
"@metamask/auto-changelog": "^6.1.0",
5978
"@ts-bridge/cli": "^0.6.4",
6079
"@types/jest": "^30.0.0",
80+
"chokidar-cli": "^3.0.0",
6181
"deepmerge": "^4.2.2",
6282
"jest": "^30.4.2",
83+
"nock": "^13.3.1",
6384
"ts-jest": "^29.4.11",
6485
"tsx": "^4.20.5",
6586
"typedoc": "^0.25.13",
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* CLI to mint a UKYC `storage_access_token` for testing UKYC Storage.
3+
*
4+
* All real logic lives in the tested `mintUkycTestToken`; this is a thin
5+
* argument-parsing wrapper that prints the result as JSON.
6+
*
7+
* Usage (from the package root, via the `mint:ukyc-token` script):
8+
* yarn workspace @metamask/kyc-controller run mint:ukyc-token -- \
9+
* --operations read,write --expires-in 4h [--secret <hex>] \
10+
* [--presenter client|idos-relay] [--session-id <id>]
11+
*
12+
* Reuse the printed `localUserSecret` (pass it back via --secret) to keep the
13+
* same `storageId` and controlling key across runs.
14+
*/
15+
import process from 'node:process';
16+
17+
import type {
18+
UkycStorageOperation,
19+
UkycTokenPresenter,
20+
} from '../src/ukyc/storageAccessToken.js';
21+
import { mintUkycTestToken } from '../src/ukyc/testToken.js';
22+
import type { MintUkycTestTokenParams } from '../src/ukyc/testToken.js';
23+
24+
/**
25+
* Parses `--flag value` and `--flag=value` pairs into a map. Flags without a
26+
* following value are treated as booleans (`"true"`).
27+
*
28+
* @param argv - Raw CLI arguments (typically `process.argv.slice(2)`).
29+
* @returns The parsed flags keyed by name (without the leading `--`).
30+
*/
31+
function parseFlags(argv: string[]): Record<string, string> {
32+
const flags: Record<string, string> = {};
33+
let i = 0;
34+
while (i < argv.length) {
35+
const arg = argv[i];
36+
if (!arg.startsWith('--')) {
37+
i += 1;
38+
continue;
39+
}
40+
const body = arg.slice(2);
41+
const eq = body.indexOf('=');
42+
if (eq !== -1) {
43+
flags[body.slice(0, eq)] = body.slice(eq + 1);
44+
i += 1;
45+
continue;
46+
}
47+
const next = argv[i + 1];
48+
if (next !== undefined && !next.startsWith('--')) {
49+
flags[body] = next;
50+
i += 2;
51+
} else {
52+
flags[body] = 'true';
53+
i += 1;
54+
}
55+
}
56+
return flags;
57+
}
58+
59+
/**
60+
* Parses a duration like `4h`, `30m`, `90s`, or a bare number of seconds.
61+
*
62+
* @param value - The duration string.
63+
* @returns The duration in milliseconds.
64+
*/
65+
function parseDurationMs(value: string): number {
66+
const match = /^(\d+)(s|m|h|d)?$/u.exec(value);
67+
if (!match) {
68+
throw new Error(`invalid --expires-in duration: ${value}`);
69+
}
70+
const amount = Number(match[1]);
71+
const unitMs = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
72+
return amount * unitMs[(match[2] ?? 's') as keyof typeof unitMs];
73+
}
74+
75+
const flags = parseFlags(process.argv.slice(2));
76+
77+
const params: MintUkycTestTokenParams = {};
78+
79+
if (flags.secret) {
80+
params.localUserSecret = flags.secret;
81+
}
82+
if (flags.operations) {
83+
params.operations = flags.operations
84+
.split(',')
85+
.map((op) => op.trim()) as UkycStorageOperation[];
86+
}
87+
if (flags.presenter) {
88+
params.presenter = flags.presenter as UkycTokenPresenter;
89+
}
90+
if (flags['session-id']) {
91+
params.sessionId = flags['session-id'];
92+
}
93+
if (flags['issued-at']) {
94+
params.issuedAt = new Date(flags['issued-at']);
95+
}
96+
if (flags['expires-at']) {
97+
params.expiresAt = new Date(flags['expires-at']);
98+
} else if (flags['expires-in']) {
99+
const issuedAt = params.issuedAt ?? new Date();
100+
params.issuedAt = issuedAt;
101+
params.expiresAt = new Date(
102+
issuedAt.getTime() + parseDurationMs(flags['expires-in']),
103+
);
104+
}
105+
106+
const result = mintUkycTestToken(params);
107+
108+
console.log(JSON.stringify(result, null, 2));

0 commit comments

Comments
 (0)