diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1d6305d889c..97852002a37 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -76,6 +76,9 @@ ## Product Safety Team /packages/phishing-controller @MetaMask/product-safety +## Universal KYC Team +/packages/kyc-controller @MetaMask/universal-kyc + ## Swaps-Bridge Team /packages/bridge-controller @MetaMask/swaps-engineers /packages/bridge-status-controller @MetaMask/swaps-engineers diff --git a/README.md b/README.md index 55139eccac1..47555a1d465 100644 --- a/README.md +++ b/README.md @@ -426,6 +426,12 @@ linkStyle default opacity:0.5 keyring_controller --> base_controller; keyring_controller --> controller_utils; keyring_controller --> messenger; + kyc_controller --> base_controller; + kyc_controller --> base_data_service; + kyc_controller --> controller_utils; + kyc_controller --> geolocation_controller; + kyc_controller --> messenger; + kyc_controller --> profile_sync_controller; logging_controller --> base_controller; logging_controller --> messenger; message_manager --> base_controller; diff --git a/codeowners.ts b/codeowners.ts index ba3c00c2aef..5c4218cba36 100644 --- a/codeowners.ts +++ b/codeowners.ts @@ -514,6 +514,10 @@ function buildTeamSections(): CodeownersSection[] { title: 'Product Safety Team', rules: [buildRuleForPackage('phishing-controller')], }, + { + title: 'Universal KYC Team', + rules: [buildRuleForPackage('kyc-controller')], + }, { title: 'Swaps-Bridge Team', rules: [ diff --git a/eslint.config.mjs b/eslint.config.mjs index faf5e8039aa..4275fa3d871 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -314,6 +314,15 @@ const config = createConfig([ 'no-restricted-globals': 'off', }, }, + { + // The UKYC test-token minter is a dev-only Node CLI, so it may use Node + // builtins and globals unlike the platform-agnostic package source. + files: ['packages/kyc-controller/scripts/**/*.ts'], + rules: { + 'import-x/no-nodejs-modules': 'off', + 'no-restricted-globals': 'off', + }, + }, { files: [ 'packages/wallet-cli/src/**/*.test.{js,ts}', diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md new file mode 100644 index 00000000000..265a538fbcf --- /dev/null +++ b/packages/kyc-controller/ARCHITECTURE.md @@ -0,0 +1,663 @@ +## Architecture + +`@metamask/kyc-controller` is a shared, **platform-agnostic** package that owns +the end-to-end KYC / identity-verification flow used across MetaMask clients +(mobile, extension, web). It hides the vendor implementation (currently +**MoonPay** for identity + **SumSub** for document verification) behind a +vendor-neutral, per-product surface consumed by features such as **ramps** and +**card**. + +This document explains: + +- The package's internal building blocks and responsibilities. +- How the pieces communicate (messenger actions, injected adapters). +- The identity flow as a state machine and an end-to-end sequence. +- The encrypted frame message protocol and crypto. +- How the **metamask-mobile** client wires everything together on the client + side. + +--- + +### 1. Design principles + +The package is built around a few deliberate constraints: + +| Principle | How it shows up in the code | +| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Vendor-neutral surface** | Consumers deal with `KycProduct` (`'ramps' \| 'card'`) and a phase machine, never with MoonPay/SumSub specifics. `KycVendor` is internal. | +| **Platform-agnostic core** | No React, no `Buffer`/`atob`, no native SDK imports. Crypto uses `@noble/*` + `@scure/base`. WebView/iframe presentation and the SumSub SDK are **injected** by each client. | +| **Controller owns orchestration; clients own presentation** | `KycController` owns all state, HTTP orchestration, crypto and the frame protocol. Clients only render frames, forward raw messages, and present the SumSub SDK. | +| **Stateless service** | `KycService` performs HTTP only; it holds no state and derives auth/geolocation from other controllers via the messenger. | +| **Everything through the messenger** | Both classes register their public methods as messenger actions, and reach external capabilities (auth token, geolocation) via delegated actions. | + +--- + +### 2. Component overview + +The package splits cleanly into a **stateful orchestrator** (`KycController`), a +**stateless HTTP client** (`KycService`), and supporting modules (crypto, +selectors, types). + +```mermaid +graph TB + subgraph pkg["@metamask/kyc-controller"] + direction TB + Controller["KycController
(BaseController)
state + orchestration + frame protocol"] + Service["KycService
(stateless)
HTTP + response validation"] + Crypto["crypto.ts
X25519 ECDH + AES-256-GCM"] + Selectors["selectors.ts
memoized reselect selectors"] + Types["types.ts
KycPhase, KycProduct,
KycSumSubLauncher, ..."] + Country["countryCodes.ts
alpha-2 → alpha-3"] + end + + subgraph deps["External MetaMask dependencies"] + Base["@metamask/base-controller"] + Msgr["@metamask/messenger"] + CU["@metamask/controller-utils
createServicePolicy, HttpError"] + Geo["GeolocationController"] + Auth["AuthenticationController
(profile-sync)"] + end + + subgraph vendor["Vendor backends (HTTP / frames)"] + UKYC["Universal KYC API
kyc-api.cx.metamask.io"] + Frames["MoonPay frames
blocks.moonpay.com"] + SumSubSDK["SumSub SDK
(native / web)"] + end + + Controller -->|"decryptCredentials()"| Crypto + Controller -->|"messenger.call(KycService:*)"| Service + Controller -.->|"injected launcher"| SumSubSDK + Controller -->|"builds frame URLs
handles frame messages"| Frames + + Service -->|"createServicePolicy / HttpError"| CU + Service -->|"messenger.call(GeolocationController:getGeolocation)"| Geo + Service -->|"messenger.call(AuthenticationController:getBearerToken)"| Auth + Service -->|"fetch()"| UKYC + + Controller --- Base + Controller --- Msgr + Service --- Msgr + Selectors -.->|"read"| Controller +``` + +#### 2.1 `KycController` + +- Extends `BaseController<'KycController', KycControllerState, KycControllerMessenger>`. +- Holds **all flow state** (see [§3](#3-state-shape)). +- Owns an ephemeral **X25519 keypair** (`#keypair`) generated at construction — + never persisted, used only for the frame key exchange. +- Registers its public methods as messenger actions via + `registerMethodActionHandlers`. +- Calls `KycService` exclusively **through the messenger** (`KycService:*` + actions), never a direct reference. +- Delegates SumSub SDK presentation to an injected `sumsubLauncher` + (`KycSumSubLauncher`). +- When the flow is scoped to a product (passed to `initialize` / + `acceptTermsAndStartSession` and stored as `activeProduct`), automatically + runs the KYC-required check once authenticated and chains into document + verification when KYC is required — no extra consumer calls needed. + +Exposed messenger actions (`MESSENGER_EXPOSED_METHODS`): + +`initialize`, `loadDisclaimers`, `acceptTermsAndStartSession`, +`clearSavedTerms`, `handleFrameMessage`, `buildCheckFrameUrl`, +`buildAuthFrameUrl`, `buildResetFrameUrl`, `checkKycRequired`, `getKycStatus`, +`startSumSub`, `reset`. + +#### 2.2 `KycService` + +- **Stateless**, platform-agnostic HTTP client for the Universal KYC (UKYC) + backend. +- Base URL derived from `env` (`production` / `development`) or an explicit + `baseUrl` override. +- Every request is wrapped in a **service policy** (`createServicePolicy`) for + retries/circuit-breaking, and carries a **bearer token** obtained from + `AuthenticationController:getBearerToken`. +- Every response is validated with **superstruct** before being returned; + malformed responses throw a descriptive error. +- Resolves the customer's country from `GeolocationController:getGeolocation` + and maps alpha-2 → alpha-3. + +Exposed messenger actions (`MESSENGER_EXPOSED_METHODS`): + +`getGeoCountry`, `fetchDisclaimers`, `createSession`, `checkKycRequired`, +`createUkycSession`, `createJourney`. + +Endpoints: + +| Method | HTTP | Endpoint | Purpose | +| ------------------- | ------ | --------------------------------------- | ----------------------------------------------------------------------- | +| `getGeoCountry` | — | (geolocation action) | Resolve alpha-3 country | +| `fetchDisclaimers` | `GET` | `/vendors/moonpay/disclaimers?country=` | Terms to accept | +| `createSession` | `POST` | `/vendors/moonpay/sessions` | Create vendor session | +| `checkKycRequired` | `POST` | `/vendors/moonpay/kyc-required` | Is KYC required? (normalizes `required` → `kycRequired`) | +| `createUkycSession` | `POST` | `/sessions` | Start SumSub sub-flow (wrapped key + read-only `ukyc_capability_token`) | +| `createJourney` | `POST` | `/sessions/{id}/journey` | Create verification journey → applicant token | + +### 2.3 `crypto.ts` + +Implements the Check/Auth frame credential decryption: + +1. Client generates an X25519 keypair; the public key (hex) is added to the + frame URL. +2. The frame returns `{ ephemeralPublicKey, iv|nonce, ciphertext }`. +3. Client derives `shared = X25519(ourPriv, theirEphemeralPub)`, then + `key = HKDF-SHA256(shared, 32 bytes)`, then AES-256-GCM decrypts the + ciphertext (which includes the 16-byte tag). IV must be 12 bytes. + +It tolerates envelopes delivered as an object, a JSON string, or base64(JSON), +and hex-or-base64 binary fields. + +#### 2.4 `selectors.ts` + +Memoized `reselect` selectors over `KycControllerState`: +`selectKycPhase`, `selectKycSumSub`, and the parametric +`selectIsKycRequiredForProduct(product)`. + +--- + +### 3. State shape + +```mermaid +classDiagram + class KycControllerState { + +KycPhase phase + +string statusMessage + +string error + +string email + +string termsAcceptedAt [persisted] + +string[] acceptedDisclaimerIds [persisted] + +KycDisclaimer[] disclaimers + +string disclaimersError + +string geoCountry + +string sessionToken [secret] + +string accessToken [secret] + +string moonpayCustomerId + +KycProduct activeProduct + +Record kycRequiredByProduct [persisted] + +string lastCheckedAt [persisted] + +SumSubState sumsub + } + class SumSubState { + +KycSumSubStatus status + +Json result + +string sessionId + +string applicantAccessToken + } + KycControllerState --> SumSubState : sumsub +``` + +> Note: nullable fields (`error`, `email`, `sessionToken`, …) are typed as +> `T | null` in the source; `Record` is `Partial>`. +> Types are simplified above for diagram readability. + +State metadata highlights (`kycControllerMetadata`): + +- **Persisted** (`persist: true`): `termsAcceptedAt`, `acceptedDisclaimerIds`, + `kycRequiredByProduct`, `lastCheckedAt`. These survive restarts so the flow + can skip already-accepted terms and reuse cached results. +- **Secrets, never persisted / never logged**: `sessionToken`, `accessToken`, + `moonpayCustomerId`, `email`, `disclaimers`, and the whole `sumsub` sub-tree. +- Additional non-state secrets kept **off** the state object entirely: the + X25519 private key (`#keypair`) and the Auth-frame client token + (`#authClientToken`). + +--- + +### 4. The identity flow (phase state machine) + +`KycPhase` models the linear identity flow. Each transition is driven by a +controller method or an incoming frame message. + +```mermaid +stateDiagram-v2 + [*] --> idle + idle --> terms : initialize() (no saved terms) + idle --> session : initialize() (saved terms + email) + + terms --> session : acceptTermsAndStartSession() + session --> check : createSession() ok + session --> terms : createSession() fails
(clears saved terms, activeProduct + stale tokens) + + check --> form : Check frame → active (already authenticated) + check --> auth : Check frame → connectionRequired (needs OTP) + check --> terms : Check frame → termsAcceptanceRequired + + auth --> form : Auth frame → active (OTP verified) + auth --> terms : Auth frame → termsAcceptanceRequired + + form --> submit : checkKycRequired()
(auto when a product is set) + submit --> done : kyc-required response ok + submit --> error : request failed + + check --> error : unexpected status / decrypt failure + auth --> error : unexpected status + done --> [*] + error --> idle : reset() + done --> idle : reset() +``` + +> When the flow is scoped to a product (a `product` is passed to `initialize` +> or `acceptTermsAndStartSession`), reaching `form` **automatically** runs the +> KYC-required check (`form → submit → done`) with no user interaction, and — if +> KYC is required — automatically launches the SumSub document-verification +> sub-flow (see [§7](#7-sumsub-sub-flow)). When no product is set the flow stops +> at `form` and the consumer drives `checkKycRequired` / `startSumSub` manually. + +> **`initialize` never tears down an active flow.** If `phase` is already one of +> the in-progress phases (`session`, `check`, `auth`, `form`, `submit`), a +> repeat `initialize` is a **no-op** — it will not create a new session, clear +> tokens, or reset `activeProduct`. Call `reset()` first to start over. + +> **`reset()` is callable from any phase and supersedes in-flight work.** In +> addition to returning `phase` to `idle` (and clearing tokens, `activeProduct`, +> and the `sumsub` sub-tree), `reset()` bumps an internal flow generation so any +> still-pending async step (geolocation, disclaimers, session creation, the +> KYC-required check, or the SumSub sub-flow) discards its result instead of +> writing it onto the now-idle controller. + +Phase meanings (from `types.ts`): + +| Phase | Meaning | +| --------- | ----------------------------------------------------------------------------------------------------------- | +| `idle` | Nothing started. | +| `terms` | Waiting for the customer to accept vendor terms. | +| `session` | Creating the vendor session. | +| `check` | Running the **invisible** connection-check frame. | +| `auth` | Running the **visible** authentication (email OTP) frame. | +| `form` | Authenticated. Auto-runs the KYC-required check when a product is set; otherwise waits for the consumer. | +| `submit` | Submitting the KYC-required check. | +| `done` | Complete — see `kycRequiredByProduct` / `sumsub`. Document verification auto-launches when KYC is required. | +| `error` | Halted — see `error`. | + +--- + +### 5. End-to-end sequence + +This sequence shows the full happy path including the two frames and the SumSub +hand-off. The **client transport** (WebView on mobile, iframe on web) is +generic — it only forwards raw frame messages to `handleFrameMessage` and posts +back any returned `reply`. + +```mermaid +sequenceDiagram + autonumber + actor User + participant UI as Client UI + transport
(WebView/iframe) + participant Ctrl as KycController + participant Svc as KycService + participant Geo as GeolocationController + participant API as UKYC API + participant Frame as MoonPay Check/Auth frame + participant Launcher as SumSub launcher (injected) + + User->>Ctrl: initialize({ email, product }) + Ctrl->>Svc: getGeoCountry() + Svc->>Geo: getGeolocation() + Note over Svc: map alpha-2 → alpha-3 locally + Ctrl->>Svc: fetchDisclaimers({ country }) + Svc->>API: GET /disclaimers + Ctrl-->>UI: phase = terms (+ disclaimers) + + User->>Ctrl: acceptTermsAndStartSession({ email }) + Ctrl->>Svc: createSession({ email, termsAcceptedAt, disclaimerIds }) + Svc->>API: POST /sessions + Ctrl-->>UI: phase = check (+ sessionToken) + + UI->>Ctrl: buildCheckFrameUrl() + Ctrl-->>UI: URL (sessionToken + publicKey) + UI->>Frame: load Check frame (invisible) + Frame-->>UI: handshake + UI->>Ctrl: handleFrameMessage(handshake) + Ctrl-->>UI: reply = ack + UI->>Frame: post ack + Frame-->>UI: complete (status + encrypted credentials) + UI->>Ctrl: handleFrameMessage(complete) + Note over Ctrl: decryptCredentials() → accessToken / clientToken + + alt Check → connectionRequired + Ctrl-->>UI: phase = auth + UI->>Frame: load Auth frame (visible, OTP) + Frame-->>UI: complete (active + credentials) + UI->>Ctrl: handleFrameMessage(complete) + end + + Ctrl-->>UI: phase = form (accessToken set) + + Note over Ctrl: activeProduct set at initialize →
continue automatically (no user action) + Ctrl->>Svc: checkKycRequired({ accessToken, country, capabilities }) + Svc->>API: POST /kyc-required + Ctrl-->>UI: phase = done (kycRequiredByProduct[product]) + + opt kycRequired === true → auto-launch document verification + Ctrl->>Svc: createUkycSession({ jwtToken, vendorMetadata, wrappedEncryptionKey, ukycCapabilityToken }) + Svc->>API: POST /sessions + Ctrl->>Svc: createJourney(sessionId) + Svc->>API: POST /sessions/{id}/journey + Ctrl->>Launcher: launch({ applicantAccessToken, onTokenExpiration, onStatusChange }) + Launcher-->>Ctrl: SDK result + Ctrl-->>UI: sumsub.status = complete (+ result) + end +``` + +> The KYC-required check and the document-verification launch after `form` are +> driven by the controller itself, not the user — the flow captures the +> `product` at `initialize` and continues automatically. If `initialize` is +> called without a `product`, the flow stops at `form` and the consumer triggers +> `checkKycRequired` (and later `startSumSub`) explicitly. + +--- + +### 6. Frame message protocol & crypto + +The Check, Auth and Reset frames all speak a small `postMessage` protocol. +`KycController.handleFrameMessage` implements the identity portion; the client +transport is responsible only for delivering messages and injecting replies. + +```mermaid +sequenceDiagram + autonumber + participant Frame as MoonPay frame + participant UI as Client transport + participant Ctrl as KycController + + Frame->>UI: { kind: "handshake", meta:{channelId} } + UI->>Ctrl: handleFrameMessage({ message }) + Ctrl-->>UI: { reply: { version:2, meta:{channelId}, kind:"ack" } } + UI->>Frame: postMessage(ack) + + Frame->>UI: { kind:"complete", meta:{channelId},
payload:{ status, credentials, customer } } + UI->>Ctrl: handleFrameMessage({ message }) + Note over Ctrl: 1. phase guard: only honor ch_1 in `check`,
ch_2 in `auth` — else drop the message
2. store customer.id (moonpayCustomerId)
3. decryptCredentials(envelope, privKey)
4. route by channelId (ch_1 Check / ch_2 Auth) + Ctrl->>Ctrl: apply outcome → next phase +``` + +Channels: `ch_1` = Check, `ch_2` = Auth, `ch_reset` = Reset. + +> **Phase-guarded intake.** A `complete` is only processed when the flow is +> actually waiting on that frame — `ch_1` while `phase === 'check'`, `ch_2` +> while `phase === 'auth'`. Because both outcome handlers advance `phase` to +> `form` synchronously, a stale, duplicate, or post-`reset()` `complete` +> (delivered once the flow has moved on) is dropped before any state is touched, +> so it cannot resurrect tokens, re-store `customer.id`, or rewind `phase`. +> Frame messages are external input and are not covered by the `#generation` +> guard used for the controller's own async steps, so this boundary check is how +> late frame posts are neutralized. + +Credential decryption (`crypto.ts`): + +```mermaid +graph LR + A["envelope
{ ephemeralPublicKey, iv|nonce, ciphertext }"] --> B["X25519 ECDH
shared = f(ourPriv, theirPub)"] + B --> C["HKDF-SHA256
key (32 bytes)"] + C --> D["AES-256-GCM decrypt
(iv = 12 bytes)"] + D --> E["JSON credentials
{ accessToken?, clientToken? }"] +``` + +Check-frame outcomes (`#handleCheckOutcome`): + +- `active` + `accessToken` → phase `form` (already authenticated). +- `connectionRequired` + `clientToken` → store `#authClientToken`, phase `auth`. +- `termsAcceptanceRequired` → clear saved terms, phase `terms`. +- anything else → `error`. + +Auth-frame outcomes (`#handleAuthOutcome`): + +- `active` + `accessToken` → phase `form`. +- `termsAcceptanceRequired` → clear saved terms, phase `terms`. +- anything else → `error`. + +--- + +### 7. SumSub sub-flow + +The document-verification sub-flow tracks its own status independently of the +identity `phase`, and delegates the actual SDK presentation to the injected +launcher. + +```mermaid +stateDiagram-v2 + [*] --> idle + idle --> creatingSession : startSumSub() + creatingSession --> fetchingToken : createUkycSession() ok + creatingSession --> vendorProcessing : createUkycSession() kycStatus=approved, finalStatus=pending + fetchingToken --> launching : createJourney() ok + launching --> inProgress : onStatusChange (non-Completed) + launching --> complete : onStatusChange = Completed + inProgress --> complete : onStatusChange = Completed + launching --> failed : resolves without a Completed status + inProgress --> failed : resolves without a Completed status + creatingSession --> failed : error + fetchingToken --> failed : error + launching --> failed : launcher unavailable / error +``` + +> **Already processing on the vendor.** A user who already finished the journey +> can return to a session the relay has approved (`kycStatus: approved`) while +> the vendor is still finalizing its decision (`finalStatus: pending`). When +> session creation reports this, the sub-flow stops at `vendorProcessing` +> (setting `statusMessage`) instead of launching the SDK, so an already-approved +> applicant is not asked to verify again. + +> **Completion is status-driven, not resolution-driven.** A resolved `launch` +> is only recorded as `complete` when the SDK reported the `Completed` status +> via `onStatusChange` at least once. If `launch` resolves without ever having +> reported `Completed` (e.g. the applicant abandoned the flow, or a non-success +> outcome), the controller records `failed` — so consumers never mistake an +> unfinished flow for a verified one. + +The `KycSumSubLauncher` interface (injected per client): + +```ts +type KycSumSubLauncher = { + isAvailable(): boolean; + launch(params: KycSumSubLaunchParams): Promise>; +}; +``` + +`launch` receives `applicantAccessToken`, an `onTokenExpiration` callback (the +controller re-runs `createJourney` to refresh — but **refuses to refresh +after a `reset()`**, throwing instead so a still-open SDK cannot keep an +orphaned UKYC session alive), and an `onStatusChange` callback that the +controller maps into `sumsub.status`. + +--- + +### 8. Messenger wiring + +Both classes are messenger-driven. The controller depends on the service's +actions; the service depends on auth + geolocation actions from other +controllers. + +```mermaid +graph LR + subgraph CtrlMsgr["KycControllerMessenger"] + C_own["Own actions:
KycController:getState + 12 methods"] + C_ext["Allowed (delegated):
KycService:*"] + end + subgraph SvcMsgr["KycServiceMessenger"] + S_own["Own actions:
KycService: 6 methods"] + S_ext["Allowed (delegated):
AuthenticationController:getBearerToken
GeolocationController:getGeolocation"] + end + + C_ext -.delegates.-> S_own + S_ext -.delegates.-> Auth["AuthenticationController"] + S_ext -.delegates.-> Geo["GeolocationController"] +``` + +- `KycController` emits `KycController:stateChange` and exposes + `KycController:getState` plus its method actions. +- `KycController`'s `AllowedActions` = `KycServiceMethodActions` — it can call + the service. +- `KycService`'s `AllowedActions` = the auth bearer-token and geolocation + actions. + +--- + +### 9. Client-side usage (metamask-mobile) + +The mobile app is a reference consumer. It wires the controller/service into the +Engine, injects a React Native SumSub launcher, bridges WebView frame messages, +and reads state through Redux selectors. The **package stays free of any of +this** — all React/native/WebView code lives in the app. + +```mermaid +graph TB + subgraph app["metamask-mobile"] + direction TB + subgraph engine["Engine wiring"] + CInit["kyc-controller-init.ts
new KycController({ messenger, state, sumsubLauncher })"] + SInit["kyc-service-init.ts
new KycService({ fetch, env, messenger, baseUrl })"] + CMsgr["kyc-controller-messenger.ts
delegates KycService:*"] + SMsgr["kyc-service-messenger.ts
delegates Auth + Geolocation"] + Launcher["reactNativeSumSubLauncher.ts
lazy-loads @sumsub/react-native-mobilesdk-module"] + end + subgraph ui["UI layer"] + Hook["useKycFlow.ts
binds controller ↔ React"] + Frame["MoonpayFrame + useMoonpayFrame
WebView postMessage bridge"] + Reset["useMoonpayReset.ts
Reset frame"] + Demo["MoonpayDemo / SumSubDemo / KYCDemo
screens"] + end + subgraph redux["Redux"] + Sel["selectors/kycController.ts
wraps core selectors"] + end + end + + subgraph core["@metamask/kyc-controller"] + KC["KycController"] + KS["KycService"] + end + + CInit --> KC + SInit --> KS + CInit --> Launcher + Launcher -. injected .-> KC + CMsgr --> KC + SMsgr --> KS + + Hook -->|"Engine.context.KycController.*"| KC + Hook -->|"useSelector"| Sel + Sel -->|"state.engine.backgroundState.KycController"| KC + Frame -->|"raw frame message"| Hook + Hook -->|"handleFrameMessage()"| KC + Demo --> Hook + Demo --> Frame + Demo --> Reset +``` + +#### 9.1 Engine wiring + +- **`kyc-controller-init.ts`** constructs `KycController` with the persisted + state slice and injects `reactNativeSumSubLauncher`. +- **`kyc-service-init.ts`** constructs `KycService` with the global `fetch`, an + `env` derived from `isProduction()`, and (currently) a dev `baseUrl` override. +- **`kyc-controller-messenger.ts`** delegates the six `KycService:*` actions to + the controller's messenger. +- **`kyc-service-messenger.ts`** delegates + `AuthenticationController:getBearerToken` and + `GeolocationController:getGeolocation` to the service's messenger. + +#### 9.2 SumSub launcher adapter + +`reactNativeSumSubLauncher` implements `KycSumSubLauncher`: + +- `isAvailable()` checks for the native module (`NativeModules.SNSMobileSDKModule`). +- `launch()` **lazily imports** `@sumsub/react-native-mobilesdk-module` (so + merely wiring the controller never loads the native module — important for + Jest / Expo Go), initializes the SDK with the applicant token, and forwards + `onStatusChanged` / token-expiration callbacks back to the controller. + +#### 9.3 React binding — `useKycFlow` + +A thin hook that: + +- Reads controller state from Redux via the `selectors/kycController.ts` + selectors. +- Forwards user intents to controller actions through + `Engine.context.KycController.*` (`initialize`, `acceptTermsAndStartSession`, + `checkKycRequired`, `startSumSub`, `clearSavedTerms`, `reset`). +- Builds frame URLs on demand (`buildCheckFrameUrl` / `buildAuthFrameUrl`) as + the phase changes. +- Bridges WebView frame messages into `handleFrameMessage` and posts back the + returned `reply`. +- Keeps view-only concerns (email input, debug log, frame visibility) in local + React state. + +#### 9.4 WebView transport — `useMoonpayFrame` / `MoonpayFrame` + +- Injects a `postMessage` bridge into the frame that forwards the frame's + outbound messages to React Native via `window.ReactNativeWebView.postMessage`. +- **Validates the origin** (`https://blocks.moonpay.com`) before handing a + message to the controller. +- Implements `reply()` by dispatching a `MessageEvent` back into the WebView on + both `document` and `window` (platform quirk between iOS WKWebView and Android + System WebView). +- The Check frame is rendered **invisible** (1×1, opacity 0) unless the user + toggles it in the debug panel; the Auth frame is rendered visibly for OTP. + +#### 9.5 Redux selectors + +`selectors/kycController.ts` wraps the package's core selectors and reads the +slice at `state.engine.backgroundState.KycController`, exposing app-friendly +selectors (`selectKycPhase`, `selectKycSumSub`, +`selectIsKycRequiredForProduct(product)`, plus per-field selectors). + +--- + +### 10. Boundaries & responsibilities summary + +```mermaid +graph LR + subgraph shared["Shared package (platform-agnostic)"] + A1["Flow orchestration + state"] + A2["HTTP + response validation"] + A3["Crypto (X25519 / AES-GCM)"] + A4["Frame message protocol"] + A5["Selectors + vendor-neutral types"] + end + subgraph client["Client (per platform)"] + B1["Engine/DI wiring"] + B2["WebView / iframe transport"] + B3["SumSub SDK launcher"] + B4["Auth token + geolocation providers"] + B5["UI + Redux binding"] + end + shared -. injected adapters .- client +``` + +| Concern | Owner | +| ------------------------------------ | ------------------------------------------- | +| Flow phase machine & state | `KycController` (shared) | +| UKYC HTTP + validation + retries | `KycService` (shared) | +| Credential decryption / key exchange | `crypto.ts` (shared) | +| Frame message semantics | `KycController.handleFrameMessage` (shared) | +| Frame **transport** (WebView/iframe) | Client | +| SumSub SDK presentation | Client (via `KycSumSubLauncher`) | +| Auth bearer token / geolocation | Other controllers (via messenger) | +| Persistence of state | Client (base-controller persistence) | + +--- + +### Appendix — key source files + +| File | Responsibility | +| ---------------------- | ----------------------------------------------------- | +| `src/KycController.ts` | Stateful orchestrator, phase machine, frame protocol. | +| `src/KycService.ts` | Stateless UKYC HTTP client + superstruct validation. | +| `src/crypto.ts` | X25519 ECDH + AES-256-GCM credential decryption. | +| `src/selectors.ts` | Memoized selectors over controller state. | +| `src/types.ts` | `KycPhase`, `KycProduct`, `KycSumSubLauncher`, etc. | +| `src/countryCodes.ts` | ISO alpha-2 → alpha-3 mapping. | +| `src/index.ts` | Public exports (no barrel wildcards). | + +Reference client (metamask-mobile): + +| File | Responsibility | +| -------------------------------------------------------------- | --------------------------------------- | +| `app/core/Engine/controllers/kyc/kyc-controller-init.ts` | Construct controller + inject launcher. | +| `app/core/Engine/controllers/kyc/kyc-service-init.ts` | Construct service. | +| `app/core/Engine/controllers/kyc/reactNativeSumSubLauncher.ts` | Native SumSub adapter. | +| `app/core/Engine/messengers/kyc/*.ts` | Messenger delegation. | +| `app/components/Views/MoonpayDemo/useKycFlow.ts` | React ↔ controller binding. | +| `app/components/Views/MoonpayDemo/useMoonpayFrame.ts` | WebView postMessage bridge. | +| `app/selectors/kycController.ts` | Redux selectors. | diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 3790c256428..a6c6828a594 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -10,5 +10,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - 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)) +- `KycController:registerMoneyAccountWallet`, an address-only action that resolves the MoonPay customer, signs a Monad Money Account ownership message, and registers the wallet through the MetaMask proxy ([#9847](https://github.com/MetaMask/core/pull/9847)) +- Internal wallet registration service and state machine support for `409` disambiguation, transient-failure reconciliation, UTC date rollover, and typed failures ([#9847](https://github.com/MetaMask/core/pull/9847)) +- Add `KycController` and `KycService` for managing KYC / identity verification state across MetaMask clients ([#9615](https://github.com/MetaMask/core/pull/9615)) + - `KycController` (`BaseController`) owns the flow state machine, the Check/Auth frame message protocol, X25519 credential decryption, and SumSub orchestration via an injected `KycSumSubLauncher` adapter. + - `KycService` extends `BaseDataService` and performs the Universal KYC (UKYC) HTTP calls via an injected `fetch`, sourcing the auth bearer token and geolocation through the messenger. + - Exposes a vendor-neutral, per-product surface (`ramps`, `card`) plus reselect selectors. + - Add automatic post-authentication continuation to `KycController` + - Add optional `baseUrl` option to `KycService` constructor that overrides the base URL derived from `env`, enabling clients to target a custom (e.g. local or staging) KYC API + - Add UKYC session-status polling to `KycController` + - Add handling in `KycController.startSumSub` for applicants already being processed by the vendor [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/kyc-controller/README.md b/packages/kyc-controller/README.md index e182b37a067..32ee194a139 100644 --- a/packages/kyc-controller/README.md +++ b/packages/kyc-controller/README.md @@ -1,4 +1,4 @@ -# `@metamask/kyc-controller` +# KYC Controller `@metamask/kyc-controller` Shared KYC / identity verification controller used across MetaMask clients @@ -10,6 +10,14 @@ or `npm install @metamask/kyc-controller` +## Development + +To rebuild the package automatically whenever you change a source file, run the `build:watch` script from core repository root folder: + +`yarn workspace @metamask/kyc-controller run build:watch` + +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. + ## Contributing -This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). \ No newline at end of file diff --git a/packages/kyc-controller/package.json b/packages/kyc-controller/package.json index 32c279f8a06..22967e56612 100644 --- a/packages/kyc-controller/package.json +++ b/packages/kyc-controller/package.json @@ -42,24 +42,46 @@ "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", "build:docs": "typedoc", + "build:watch": "yarn build && chokidar 'src/**/*.ts' -c 'ts-bridge --project tsconfig.build.json --verbose --no-references' --initial", "changelog:update": "../../scripts/update-changelog.sh @metamask/kyc-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/kyc-controller", "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", - "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --check", - "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --generate", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "mint:ukyc-token": "tsx scripts/mint-ukyc-test-token.ts", "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.1.0", + "@metamask/base-data-service": "^0.1.3", + "@metamask/controller-utils": "^12.3.0", + "@metamask/geolocation-controller": "^1.0.0", + "@metamask/keyring-controller": "^27.1.1", + "@metamask/messenger": "^2.0.0", + "@metamask/profile-sync-controller": "^29.0.0", + "@metamask/superstruct": "^3.4.1", + "@metamask/utils": "^11.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.2", + "@noble/hashes": "^1.8.0", + "@scure/base": "^1.2.6", + "@tanstack/query-core": "^4.43.0", + "reselect": "^5.1.1", + "tweetnacl": "^1.0.3" + }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^30.0.0", + "chokidar-cli": "^3.0.0", "deepmerge": "^4.2.2", "jest": "^30.4.2", + "nock": "^13.3.1", "ts-jest": "^29.4.11", "tsx": "^4.20.5", "typedoc": "^0.25.13", diff --git a/packages/kyc-controller/scripts/mint-ukyc-test-token.ts b/packages/kyc-controller/scripts/mint-ukyc-test-token.ts new file mode 100644 index 00000000000..82b51801acd --- /dev/null +++ b/packages/kyc-controller/scripts/mint-ukyc-test-token.ts @@ -0,0 +1,106 @@ +/** + * CLI to mint a UKYC `storage_access_token` for testing UKYC Storage. + * + * All real logic lives in the tested `mintUkycTestToken`; this is a thin + * argument-parsing wrapper that prints the result as JSON. + * + * Usage (from the package root, via the `mint:ukyc-token` script): + * yarn workspace @metamask/kyc-controller run mint:ukyc-token -- \ + * --operations read,write --expires-in 4h [--secret ] \ + * [--presenter client|idos-relay] [--session-id ] + * + * Reuse the printed `localUserSecret` (pass it back via --secret) to keep the + * same `storageId` and controlling key across runs. + */ +import process from 'node:process'; + +import type { + UkycStorageOperation, + UkycTokenPresenter, +} from '../src/ukyc/storageAccessToken.js'; +import { mintUkycTestToken } from '../src/ukyc/testToken.js'; +import type { MintUkycTestTokenParams } from '../src/ukyc/testToken.js'; + +/** + * Parses `--flag value` and `--flag=value` pairs into a map. Flags without a + * following value are treated as booleans (`"true"`). + * + * @param argv - Raw CLI arguments (typically `process.argv.slice(2)`). + * @returns The parsed flags keyed by name (without the leading `--`). + */ +function parseFlags(argv: string[]): Record { + const flags: Record = {}; + let i = 0; + while (i < argv.length) { + const arg = argv[i]; + if (!arg.startsWith('--')) { + i += 1; + continue; + } + const body = arg.slice(2); + const eq = body.indexOf('='); + if (eq !== -1) { + flags[body.slice(0, eq)] = body.slice(eq + 1); + i += 1; + continue; + } + const next = argv[i + 1]; + if (next !== undefined && !next.startsWith('--')) { + flags[body] = next; + i += 2; + } else { + flags[body] = 'true'; + i += 1; + } + } + return flags; +} + +/** + * Parses a duration like `4h`, `30m`, `90s`, or a bare number of seconds. + * + * @param value - The duration string. + * @returns The duration in milliseconds. + */ +function parseDurationMs(value: string): number { + const match = /^(\d+)(s|m|h|d)?$/u.exec(value); + if (!match) { + throw new Error(`invalid --expires-in duration: ${value}`); + } + const amount = Number(match[1]); + const unitMs = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }; + return amount * unitMs[(match[2] ?? 's') as keyof typeof unitMs]; +} + +const flags = parseFlags(process.argv.slice(2)); + +const params: MintUkycTestTokenParams = {}; + +if (flags.secret) { + params.localUserSecret = flags.secret; +} +if (flags.operations) { + params.operations = flags.operations + .split(',') + .map((op) => op.trim()) as UkycStorageOperation[]; +} +if (flags.presenter) { + params.presenter = flags.presenter as UkycTokenPresenter; +} +if (flags['session-id']) { + params.sessionId = flags['session-id']; +} +if (flags['issued-at']) { + params.issuedAt = new Date(flags['issued-at']); +} +if (flags['expires-at']) { + params.expiresAt = new Date(flags['expires-at']); +} else if (flags['expires-in']) { + const issuedAt = params.issuedAt ?? new Date(); + params.issuedAt = issuedAt; + params.expiresAt = new Date(issuedAt.getTime() + parseDurationMs(flags['expires-in'])); +} + +const result = mintUkycTestToken(params); + +console.log(JSON.stringify(result, null, 2)); diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts new file mode 100644 index 00000000000..7f27b8c202e --- /dev/null +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -0,0 +1,214 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { KycController } from './KycController.js'; + +/** + * Resolves persisted terms + geolocation, and auto-creates a session when + * terms are already accepted and an email is available. + * + * @param params - Optional parameters. + * @param params.email - The account email to associate with the session. + * @param params.product - The consuming feature the flow runs for. When + * provided, the controller automatically runs the KYC-required check once + * authentication completes (and chains into document verification when KYC + * is required). When omitted, the flow stops at `form` and the consumer must + * call `checkKycRequired` manually. + */ +export type KycControllerInitializeAction = { + type: `KycController:initialize`; + handler: KycController['initialize']; +}; + +/** + * Loads the disclaimers for the resolved (or provided) country. + * + * @param params - Optional parameters. + * @param params.country - ISO 3166-1 alpha-3 country code override. + */ +export type KycControllerLoadDisclaimersAction = { + type: `KycController:loadDisclaimers`; + handler: KycController['loadDisclaimers']; +}; + +/** + * Captures terms acceptance for the currently loaded disclaimers and creates + * a session. + * + * @param params - Optional parameters. + * @param params.email - The account email to associate with the session. + * @param params.product - The consuming feature the flow runs for. See + * {@link initialize} for how the product drives the automatic post + * authentication continuation. + */ +export type KycControllerAcceptTermsAndStartSessionAction = { + type: `KycController:acceptTermsAndStartSession`; + handler: KycController['acceptTermsAndStartSession']; +}; + +/** + * Clears the persisted terms acceptance. + */ +export type KycControllerClearSavedTermsAction = { + type: `KycController:clearSavedTerms`; + handler: KycController['clearSavedTerms']; +}; + +/** + * Handles a message posted by a Check/Auth frame and advances the flow. + * + * The transport-agnostic caller (WebView on mobile, iframe on web) forwards + * the raw message and injects the returned `reply` back into the frame. + * + * @param params - The parameters. + * @param params.message - The raw message posted by the frame. + * @returns An object whose optional `reply` should be posted back. + */ +export type KycControllerHandleFrameMessageAction = { + type: `KycController:handleFrameMessage`; + handler: KycController['handleFrameMessage']; +}; + +/** + * Builds the Check-frame URL, or `null` when no session exists yet. + * + * @returns The Check-frame URL or `null`. + */ +export type KycControllerBuildCheckFrameUrlAction = { + type: `KycController:buildCheckFrameUrl`; + handler: KycController['buildCheckFrameUrl']; +}; + +/** + * Builds the Auth-frame URL, or `null` when no client token is available. + * + * @returns The Auth-frame URL or `null`. + */ +export type KycControllerBuildAuthFrameUrlAction = { + type: `KycController:buildAuthFrameUrl`; + handler: KycController['buildAuthFrameUrl']; +}; + +/** + * Builds the Reset-frame URL. + * + * @returns The Reset-frame URL. + */ +export type KycControllerBuildResetFrameUrlAction = { + type: `KycController:buildResetFrameUrl`; + handler: KycController['buildResetFrameUrl']; +}; + +/** + * Checks whether KYC is required for a product and caches the result. + * + * @param params - The parameters. + * @param params.product - The consuming feature. + * @param params.country - Optional alpha-3 country override. + * @returns Whether KYC is required. + */ +export type KycControllerCheckKycRequiredAction = { + type: `KycController:checkKycRequired`; + handler: KycController['checkKycRequired']; +}; + +/** + * Reads the cached "is KYC required" result for a product. + * + * @param params - The parameters. + * @param params.product - The consuming feature. + * @returns The cached value, or `undefined` if not yet checked. + */ +export type KycControllerGetKycStatusAction = { + type: `KycController:getKycStatus`; + handler: KycController['getKycStatus']; +}; + +/** + * Runs the SumSub document-verification sub-flow end to end: + * + * 1. requests a per-session wrapping key from the UKYC backend; + * 2. verifies its `jwtChain` against the Fractal JWKS and confirms the + * attested session server public key; + * 3. derives the `data_encryption_key` from the wallet's UKYC + * `local_user_secret` and wraps it for the session server; + * 4. mints a client-signed, read-only `ukyc_capability_token` and creates + * the UKYC session (handing over the wrapped key and the token); + * 5. fetches the SumSub applicant access token; and + * 6. presents the SDK via the injected launcher. + * + * If session creation reports the applicant is already approved on the relay + * while the vendor is still finalizing (`kycStatus: approved`, + * `finalStatus: pending`), the sub-flow stops at step 4 with a + * `vendorProcessing` status and a message rather than launching the SDK. + * + * @param params - Optional parameters. + * @param params.locale - BCP-47 locale for the SDK UI. + * @param params.debug - Enables SDK debug logging. + * @returns The SDK result. + */ +export type KycControllerStartSumSubAction = { + type: `KycController:startSumSub`; + handler: KycController['startSumSub']; +}; + +/** + * Fetches the current UKYC session status for the active sub-flow and records + * it on state. Useful for a one-off refresh outside the automatic polling + * loop that {@link startSumSub} runs. + * + * @returns The fetched session status. + * @throws If there is no active SumSub session to query. + */ +export type KycControllerGetSessionStatusAction = { + type: `KycController:getSessionStatus`; + handler: KycController['getSessionStatus']; +}; + +/** + * Registers a Money Account wallet with MoonPay Iron. + * + * Consumers provide only the Monad address. The controller reuses the Iron + * customer id captured from MoonPay's hosted frame when available, otherwise + * it resolves the id from the authenticated MetaMask profile via KycService. + * Message construction, signing, submission, and ambiguous-write + * reconciliation stay internal to KYC. + * + * @param params - Money Account wallet registration parameters. + * @param params.address - Monad Money Account address. + * @returns The successful registration state. + */ +export type KycControllerRegisterMoneyAccountWalletAction = { + type: `KycController:registerMoneyAccountWallet`; + handler: KycController['registerMoneyAccountWallet']; +}; + +/** + * Resets the flow to idle, clearing session tokens and sub-flow state while + * preserving persisted terms acceptance and the per-product cache. + */ +export type KycControllerResetAction = { + type: `KycController:reset`; + handler: KycController['reset']; +}; + +/** + * Union of all KycController action types. + */ +export type KycControllerMethodActions = + | KycControllerInitializeAction + | KycControllerLoadDisclaimersAction + | KycControllerAcceptTermsAndStartSessionAction + | KycControllerClearSavedTermsAction + | KycControllerHandleFrameMessageAction + | KycControllerBuildCheckFrameUrlAction + | KycControllerBuildAuthFrameUrlAction + | KycControllerBuildResetFrameUrlAction + | KycControllerCheckKycRequiredAction + | KycControllerGetKycStatusAction + | KycControllerStartSumSubAction + | KycControllerGetSessionStatusAction + | KycControllerRegisterMoneyAccountWalletAction + | KycControllerResetAction; diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts new file mode 100644 index 00000000000..2f2fbaf2458 --- /dev/null +++ b/packages/kyc-controller/src/KycController.test.ts @@ -0,0 +1,2198 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import { gcm } from '@noble/ciphers/aes'; +import { x25519 } from '@noble/curves/ed25519'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha2'; +import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils'; + +import { KycController } from './KycController.js'; +import type { KycControllerMessenger } from './KycController.js'; +import type { KycSumSubLauncher } from './types.js'; +import { verifyJwtChain } from './ukyc/jwtChain.js'; +import { wrapEncryptionKey } from './ukyc/wrapEncryptionKey.js'; +import { WalletRegistrationError } from './wallet-registration-service.js'; + +// `verifyJwtChain` (JWKS attestation) and `wrapEncryptionKey` (X25519 sealing) +// need a real signed chain / valid keys, so they are stubbed here; the rest of +// the UKYC layer (local-user-secret storage adapter, client-material +// derivation) runs for real so the controller's messenger wiring is exercised. +// Return values are (re)configured per test in `withController` because the +// shared jest config enables `resetMocks`. +jest.mock('./ukyc/jwtChain', () => { + const actual = jest.requireActual('./ukyc/jwtChain'); + return { + ...actual, + verifyJwtChain: jest.fn(), + }; +}); +jest.mock('./ukyc/wrapEncryptionKey', () => { + const actual = jest.requireActual('./ukyc/wrapEncryptionKey'); + return { + ...actual, + wrapEncryptionKey: jest.fn(), + }; +}); + +const mockVerifyJwtChain = verifyJwtChain as jest.MockedFunction< + typeof verifyJwtChain +>; +const mockWrapEncryptionKey = wrapEncryptionKey as jest.MockedFunction< + typeof wrapEncryptionKey +>; + +/** + * Builds an encrypted envelope for a recipient's X25519 public key. + * + * @param publicKey - The recipient's public key bytes. + * @param credentials - The plaintext credentials to encrypt. + * @returns The encrypted envelope. + */ +function makeEnvelope( + publicKey: Uint8Array, + credentials: Record, +): { ephemeralPublicKey: string; iv: string; ciphertext: string } { + const ephemeralPrivate = x25519.utils.randomSecretKey(); + const ephemeralPublic = x25519.getPublicKey(ephemeralPrivate); + const shared = x25519.getSharedSecret(ephemeralPrivate, publicKey); + const key = hkdf(sha256, shared, undefined, undefined, 32); + const iv = new Uint8Array(12).fill(7); + const ciphertext = gcm(key, iv).encrypt( + utf8ToBytes(JSON.stringify(credentials)), + ); + return { + ephemeralPublicKey: bytesToHex(ephemeralPublic), + iv: bytesToHex(iv), + ciphertext: bytesToHex(ciphertext), + }; +} + +/** + * Extracts the controller's ephemeral public key from the Check-frame URL and + * builds a decryptable credentials envelope for it. + * + * @param controller - The controller under test (must have a session token). + * @param credentials - The plaintext credentials to encrypt. + * @returns The encrypted envelope. + */ +function envelopeFor( + controller: KycController, + credentials: Record, +): { ephemeralPublicKey: string; iv: string; ciphertext: string } { + const url = controller.buildCheckFrameUrl(); + const publicKeyHex = new URL(url as string).searchParams.get( + 'publicKey', + ) as string; + return makeEnvelope(hexToBytes(publicKeyHex), credentials); +} + +describe('KycController', () => { + describe('constructor', () => { + it('accepts initial state merged over defaults', async () => { + await withController( + { options: { state: { phase: 'form' } } }, + ({ controller }) => { + expect(controller.state.phase).toBe('form'); + expect(controller.state.sumsub.status).toBe('idle'); + }, + ); + }); + }); + + describe('initialize', () => { + it('auto-creates a session when terms and email are present', async () => { + await withController( + { + options: { + state: { termsAcceptedAt: 't', acceptedDisclaimerIds: ['1'] }, + }, + }, + async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + handlers.createSession.mockResolvedValue({ sessionToken: 'sess' }); + + await controller.initialize({ email: 'a@b.co' }); + + expect(controller.state.geoCountry).toBe('USA'); + expect(controller.state.sessionToken).toBe('sess'); + expect(controller.state.phase).toBe('check'); + }, + ); + }); + + it('falls back to the terms phase and loads disclaimers when geo fails and no terms exist', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getGeoCountry.mockRejectedValue(new Error('geo down')); + + await controller.initialize(); + + expect(controller.state.phase).toBe('terms'); + expect(controller.state.disclaimersError).toMatch(/Failed to load/u); + }); + }); + + it('captures the active product for the automatic post-auth continuation', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.initialize({ product: 'card' }); + + expect(controller.state.activeProduct).toBe('card'); + }); + }); + + it('clears a stale active product when re-initialized without one', async () => { + await withController( + { options: { state: { activeProduct: 'card' } } }, + async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.initialize({ email: 'a@b.co' }); + + expect(controller.state.activeProduct).toBeNull(); + }, + ); + }); + + it('does not restart an in-progress session flow', async () => { + await withController( + { + options: { + state: { + phase: 'check', + email: 'a@b.co', + sessionToken: 'live-session', + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['1'], + activeProduct: 'ramps', + }, + }, + }, + async ({ controller, handlers }) => { + await controller.initialize({ email: 'other@b.co', product: 'card' }); + + // A repeat initialize mid-flow must be a no-op: no new session, no + // token/phase teardown, and no clobbering of the active product. + expect(handlers.createSession).not.toHaveBeenCalled(); + expect(handlers.getGeoCountry).not.toHaveBeenCalled(); + expect(controller.state.phase).toBe('check'); + expect(controller.state.sessionToken).toBe('live-session'); + expect(controller.state.activeProduct).toBe('ramps'); + expect(controller.state.email).toBe('a@b.co'); + }, + ); + }); + + it('stays on terms when terms exist but no email is available', async () => { + await withController( + { + options: { + state: { termsAcceptedAt: 't', acceptedDisclaimerIds: ['1'] }, + }, + }, + async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.initialize(); + + expect(controller.state.phase).toBe('terms'); + }, + ); + }); + }); + + describe('loadDisclaimers', () => { + it('loads disclaimers for a provided country', async () => { + await withController(async ({ controller, handlers }) => { + const disclaimers = [{ id: '1', display_name: 'T', url: 'u' }]; + handlers.fetchDisclaimers.mockResolvedValue(disclaimers); + + await controller.loadDisclaimers({ country: 'USA' }); + + expect(controller.state.disclaimers).toStrictEqual(disclaimers); + expect(handlers.getGeoCountry).not.toHaveBeenCalled(); + }); + }); + + it('caches the provided country override in geoCountry', async () => { + await withController(async ({ controller, handlers }) => { + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.loadDisclaimers({ country: 'USA' }); + + expect(controller.state.geoCountry).toBe('USA'); + }); + }); + + it('lets a later checkKycRequired reuse the overridden country without an override', async () => { + await withController( + { options: { state: { accessToken: 'a' } } }, + async ({ controller, handlers }) => { + handlers.fetchDisclaimers.mockResolvedValue([]); + handlers.checkKycRequired.mockResolvedValue({ kycRequired: true }); + + await controller.loadDisclaimers({ country: 'USA' }); + await controller.checkKycRequired({ product: 'ramps' }); + + expect(handlers.getGeoCountry).not.toHaveBeenCalled(); + expect(handlers.checkKycRequired).toHaveBeenCalledWith({ + accessToken: 'a', + country: 'USA', + capabilities: [{ product: 'ramps' }], + }); + expect(controller.state.error).toBeNull(); + }, + ); + }); + + it('uses the cached geoCountry when no country is provided', async () => { + await withController( + { options: { state: { geoCountry: 'USA' } } }, + async ({ controller, handlers }) => { + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.loadDisclaimers(); + + expect(handlers.getGeoCountry).not.toHaveBeenCalled(); + expect(handlers.fetchDisclaimers).toHaveBeenCalledWith({ + country: 'USA', + }); + }, + ); + }); + + it('resolves the country when neither param nor cache is available', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('FRA'); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.loadDisclaimers(); + + expect(controller.state.geoCountry).toBe('FRA'); + expect(handlers.fetchDisclaimers).toHaveBeenCalledWith({ + country: 'FRA', + }); + }); + }); + + it('records an error when loading fails', async () => { + await withController(async ({ controller, handlers }) => { + handlers.fetchDisclaimers.mockRejectedValue(new Error('boom')); + + await controller.loadDisclaimers({ country: 'USA' }); + + expect(controller.state.disclaimersError).toMatch(/boom/u); + }); + }); + }); + + describe('acceptTermsAndStartSession', () => { + it('captures terms and creates a session', async () => { + await withController( + { + options: { + state: { disclaimers: [{ id: '1', display_name: 'T', url: 'u' }] }, + }, + }, + async ({ controller, handlers }) => { + handlers.createSession.mockResolvedValue({ sessionToken: 'sess' }); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + product: 'ramps', + }); + + expect(controller.state.acceptedDisclaimerIds).toStrictEqual(['1']); + expect(controller.state.termsAcceptedAt).not.toBeNull(); + expect(controller.state.activeProduct).toBe('ramps'); + expect(controller.state.phase).toBe('check'); + }, + ); + }); + + it('clears stale auth tokens when a new session is created', async () => { + await withController( + { + options: { + state: { + phase: 'check', + email: 'a@b.co', + sessionToken: 'old-session', + accessToken: 'stale-access', + disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.createSession.mockResolvedValue({ + sessionToken: 'new-session', + }); + + // Establish an auth-frame client token from a prior authentication. + const envelope = envelopeFor(controller, { + clientToken: 'old-client', + }); + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'connectionRequired', credentials: envelope }, + }, + }); + expect(controller.buildAuthFrameUrl()).toContain( + 'clientToken=old-client', + ); + + // Creating a new session must invalidate the carried-over auth. + await controller.acceptTermsAndStartSession(); + + expect(controller.state.accessToken).toBeNull(); + expect(controller.buildAuthFrameUrl()).toBeNull(); + expect(controller.state.sessionToken).toBe('new-session'); + }, + ); + }); + + it('clears the old session token while a new session is being created', async () => { + await withController( + { + options: { + state: { + email: 'a@b.co', + sessionToken: 'old-session', + disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + let releaseSession: (value: { + sessionToken: string; + }) => void = () => { + // no-op placeholder until the deferred promise is wired up + }; + handlers.createSession.mockReturnValue( + new Promise<{ sessionToken: string }>((resolve) => { + releaseSession = resolve; + }), + ); + + const pending = controller.acceptTermsAndStartSession(); + + // While the request is in flight (phase `session`) the stale token + // must already be gone so no Check frame URL can be built for it. + expect(controller.state.phase).toBe('session'); + expect(controller.state.sessionToken).toBeNull(); + expect(controller.buildCheckFrameUrl()).toBeNull(); + + releaseSession({ sessionToken: 'new-session' }); + await pending; + + expect(controller.state.sessionToken).toBe('new-session'); + expect(controller.buildCheckFrameUrl()).toContain( + 'sessionToken=new-session', + ); + }, + ); + }); + + it('reverts to terms when session creation fails', async () => { + await withController( + { + options: { + state: { + email: 'a@b.co', + sessionToken: 'old-session', + disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.createSession.mockRejectedValue(new Error('nope')); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.acceptTermsAndStartSession(); + + expect(controller.state.phase).toBe('terms'); + expect(controller.state.termsAcceptedAt).toBeNull(); + expect(controller.state.error).toMatch(/Session creation failed/u); + // A failed creation must not leave the old session token behind, so + // the Check frame cannot be built against an invalid session. + expect(controller.state.sessionToken).toBeNull(); + expect(controller.buildCheckFrameUrl()).toBeNull(); + }, + ); + }); + + it('leaves the controller idle when reset() runs before session creation fails', async () => { + await withController( + { + options: { + state: { + email: 'a@b.co', + sessionToken: 'old-session', + disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + let rejectSession: (reason: Error) => void = () => { + // no-op placeholder until the deferred promise is wired up + }; + handlers.createSession.mockReturnValue( + new Promise<{ sessionToken: string }>((_resolve, reject) => { + rejectSession = reject; + }), + ); + + const pending = controller.acceptTermsAndStartSession(); + + // Reset while the create request is in flight, then let it fail. The + // superseded flow must not force the now-idle controller back to + // `terms` or re-run disclaimer loading. + controller.reset(); + rejectSession(new Error('nope')); + await pending; + + expect(controller.state.phase).toBe('idle'); + expect(controller.state.error).toBeNull(); + expect(handlers.fetchDisclaimers).not.toHaveBeenCalled(); + }, + ); + }); + + it('clears the active product when session creation fails', async () => { + await withController( + { + options: { + state: { + email: 'a@b.co', + activeProduct: 'card', + disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.createSession.mockRejectedValue(new Error('nope')); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.acceptTermsAndStartSession({ product: 'ramps' }); + + // The failed flow must not leave a lingering product behind that a + // later product-less `acceptTermsAndStartSession` would auto-run. + expect(controller.state.phase).toBe('terms'); + expect(controller.state.activeProduct).toBeNull(); + }, + ); + }); + + it('fails when no email is available', async () => { + await withController( + { + options: { + state: { disclaimers: [{ id: '1', display_name: 'T', url: 'u' }] }, + }, + }, + async ({ controller }) => { + await controller.acceptTermsAndStartSession(); + + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toMatch(/Missing email/u); + }, + ); + }); + + it('fails when no disclaimers were accepted', async () => { + await withController(async ({ controller }) => { + await controller.acceptTermsAndStartSession({ email: 'a@b.co' }); + + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toMatch(/Missing terms acceptance/u); + }); + }); + }); + + describe('clearSavedTerms', () => { + it('clears persisted terms', async () => { + await withController( + { + options: { + state: { termsAcceptedAt: 't', acceptedDisclaimerIds: ['1'] }, + }, + }, + ({ controller }) => { + controller.clearSavedTerms(); + expect(controller.state.termsAcceptedAt).toBeNull(); + expect(controller.state.acceptedDisclaimerIds).toStrictEqual([]); + }, + ); + }); + }); + + describe('handleFrameMessage', () => { + it('acks a handshake', async () => { + await withController(async ({ controller }) => { + const result = await controller.handleFrameMessage({ + message: { kind: 'handshake', meta: { channelId: 'ch_1' } }, + }); + expect(result).toStrictEqual({ + reply: { version: 2, meta: { channelId: 'ch_1' }, kind: 'ack' }, + }); + }); + }); + + it('ignores undefined and non-complete messages', async () => { + await withController(async ({ controller }) => { + expect( + await controller.handleFrameMessage({ message: undefined }), + ).toStrictEqual({}); + expect( + await controller.handleFrameMessage({ message: { kind: 'other' } }), + ).toStrictEqual({}); + }); + }); + + it('captures the customer id and ignores a status-less complete message', async () => { + await withController( + { options: { state: { phase: 'check' } } }, + async ({ controller }) => { + const result = await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { customer: { id: 'cust-1' } }, + }, + }); + expect(result).toStrictEqual({}); + expect(controller.state.moonpayCustomerId).toBe('cust-1'); + }, + ); + }); + + it('ignores messages on an unknown channel', async () => { + await withController(async ({ controller }) => { + const result = await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_unknown' }, + payload: { status: 'active' }, + }, + }); + expect(result).toStrictEqual({}); + }); + }); + + it('ignores a stale completion for a frame the flow is no longer waiting on', async () => { + // Phase `done` (e.g. after a completed flow or a `reset()` that returns + // to an idle phase) means the Check frame is no longer active; a late or + // duplicate `ch_1` completion must not resurrect tokens or rewind phase. + await withController( + { options: { state: { phase: 'done', sessionToken: 'tok' } } }, + async ({ controller }) => { + const envelope = envelopeFor(controller, { accessToken: 'access-1' }); + const result = await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { + status: 'active', + credentials: envelope, + customer: { id: 'cust-late' }, + }, + }, + }); + expect(result).toStrictEqual({}); + expect(controller.state.phase).toBe('done'); + expect(controller.state.accessToken).toBeNull(); + expect(controller.state.moonpayCustomerId).toBeNull(); + }, + ); + }); + + it('fails when credential decryption throws', async () => { + await withController( + { options: { state: { phase: 'check', sessionToken: 'tok' } } }, + async ({ controller }) => { + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials: 'not-decryptable' }, + }, + }); + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toMatch(/Failed to decrypt/u); + }, + ); + }); + + describe('check frame', () => { + it('moves to form on an active status with an access token', async () => { + await withController( + { options: { state: { phase: 'check', sessionToken: 'tok' } } }, + async ({ controller }) => { + const envelope = envelopeFor(controller, { + accessToken: 'access-1', + }); + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials: envelope }, + }, + }); + expect(controller.state.phase).toBe('form'); + expect(controller.state.accessToken).toBe('access-1'); + }, + ); + }); + + it('moves to auth on connectionRequired and enables the auth frame URL', async () => { + await withController( + { options: { state: { phase: 'check', sessionToken: 'tok' } } }, + async ({ controller }) => { + const envelope = envelopeFor(controller, { + clientToken: 'client-1', + }); + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { + status: 'connectionRequired', + credentials: envelope, + }, + }, + }); + expect(controller.state.phase).toBe('auth'); + expect(controller.buildAuthFrameUrl()).toContain( + 'clientToken=client-1', + ); + }, + ); + }); + + it('requires re-acceptance on termsAcceptanceRequired', async () => { + await withController( + { + options: { + state: { + phase: 'check', + sessionToken: 'tok', + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['1'], + }, + }, + }, + async ({ controller }) => { + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'termsAcceptanceRequired' }, + }, + }); + expect(controller.state.phase).toBe('terms'); + expect(controller.state.termsAcceptedAt).toBeNull(); + }, + ); + }); + + it('fails on an unexpected status', async () => { + await withController( + { options: { state: { phase: 'check', sessionToken: 'tok' } } }, + async ({ controller }) => { + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'failed' }, + }, + }); + expect(controller.state.phase).toBe('error'); + }, + ); + }); + }); + + describe('auth frame', () => { + it('moves to form on an active status with an access token', async () => { + await withController( + { options: { state: { phase: 'auth', sessionToken: 'tok' } } }, + async ({ controller }) => { + const envelope = envelopeFor(controller, { + accessToken: 'access-2', + }); + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_2' }, + payload: { status: 'active', credentials: envelope }, + }, + }); + expect(controller.state.phase).toBe('form'); + expect(controller.state.accessToken).toBe('access-2'); + }, + ); + }); + + it('requires re-acceptance on termsAcceptanceRequired', async () => { + await withController( + { options: { state: { phase: 'auth' } } }, + async ({ controller }) => { + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_2' }, + payload: { status: 'termsAcceptanceRequired' }, + }, + }); + expect(controller.state.phase).toBe('terms'); + }, + ); + }); + + it('fails on an unexpected status', async () => { + await withController( + { options: { state: { phase: 'auth' } } }, + async ({ controller }) => { + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_2' }, + payload: { status: 'unavailable' }, + }, + }); + expect(controller.state.phase).toBe('error'); + }, + ); + }); + }); + }); + + describe('automatic post-authentication continuation', () => { + it('stays at form and does not run the check when no product is set', async () => { + await withController( + { + options: { + state: { phase: 'check', sessionToken: 'tok', geoCountry: 'USA' }, + }, + }, + async ({ controller, handlers }) => { + const envelope = envelopeFor(controller, { accessToken: 'access-1' }); + + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials: envelope }, + }, + }); + + expect(controller.state.phase).toBe('form'); + expect(handlers.checkKycRequired).not.toHaveBeenCalled(); + }, + ); + }); + + it('auto-runs the KYC check on reaching form and stops at done when KYC is not required', async () => { + await withController( + { + options: { + state: { + phase: 'check', + sessionToken: 'tok', + activeProduct: 'ramps', + geoCountry: 'USA', + }, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.checkKycRequired.mockResolvedValue({ kycRequired: false }); + const envelope = envelopeFor(controller, { accessToken: 'access-1' }); + + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials: envelope }, + }, + }); + + expect(handlers.checkKycRequired).toHaveBeenCalledWith({ + accessToken: 'access-1', + country: 'USA', + capabilities: [{ product: 'ramps' }], + }); + expect(controller.state.kycRequiredByProduct.ramps).toBe(false); + expect(controller.state.phase).toBe('done'); + expect(launcher.launch).not.toHaveBeenCalled(); + }, + ); + }); + + it('auto-chains into document verification when KYC is required (via the auth frame)', async () => { + await withController( + { + options: { + state: { + phase: 'auth', + sessionToken: 'tok', + activeProduct: 'card', + geoCountry: 'FRA', + }, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.checkKycRequired.mockResolvedValue({ kycRequired: true }); + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + const envelope = envelopeFor(controller, { accessToken: 'access-2' }); + + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_2' }, + payload: { status: 'active', credentials: envelope }, + }, + }); + + expect(controller.state.kycRequiredByProduct.card).toBe(true); + expect(launcher.launch).toHaveBeenCalledTimes(1); + expect(controller.state.sumsub.status).toBe('complete'); + }, + ); + }); + + it('records a failed sub-flow without throwing when verification is required but the SDK is unavailable', async () => { + await withController( + { + options: { + state: { + phase: 'check', + sessionToken: 'tok', + activeProduct: 'ramps', + geoCountry: 'USA', + }, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.checkKycRequired.mockResolvedValue({ kycRequired: true }); + launcher.isAvailable.mockReturnValue(false); + const envelope = envelopeFor(controller, { accessToken: 'access-1' }); + + const result = await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials: envelope }, + }, + }); + + expect(result).toStrictEqual({}); + expect(controller.state.sumsub.status).toBe('failed'); + }, + ); + }); + + it('ignores a duplicate completion while a prior continuation is in flight', async () => { + await withController( + { + options: { + state: { + phase: 'auth', + sessionToken: 'tok', + activeProduct: 'card', + geoCountry: 'FRA', + }, + }, + }, + async ({ controller, handlers, launcher }) => { + // Hold the KYC-required check open so the first continuation is still + // in flight when the second (duplicate) completion arrives. The first + // completion moves `phase` to `form` synchronously, so the duplicate + // is dropped by the frame-phase guard before it can re-run the check. + let releaseCheck: (value: { kycRequired: boolean }) => void = () => { + // no-op placeholder until the deferred promise is wired up + }; + handlers.checkKycRequired.mockReturnValue( + new Promise<{ kycRequired: boolean }>((resolve) => { + releaseCheck = resolve; + }), + ); + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + const envelope = envelopeFor(controller, { accessToken: 'access-1' }); + const message = { + kind: 'complete', + meta: { channelId: 'ch_2' }, + payload: { status: 'active', credentials: envelope }, + }; + + const first = controller.handleFrameMessage({ message }); + const second = controller.handleFrameMessage({ message }); + + releaseCheck({ kycRequired: true }); + await Promise.all([first, second]); + + expect(handlers.checkKycRequired).toHaveBeenCalledTimes(1); + expect(launcher.launch).toHaveBeenCalledTimes(1); + expect(controller.state.sumsub.status).toBe('complete'); + }, + ); + }); + + it('allows a fresh flow to continue after a reset interrupts an in-flight continuation', async () => { + await withController( + { + options: { + state: { + phase: 'check', + email: 'a@b.co', + sessionToken: 'tok', + activeProduct: 'ramps', + geoCountry: 'USA', + // Persisted terms so a post-reset `initialize` auto-recreates the + // session (reaching phase `check`) for the second completion. + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['1'], + }, + }, + }, + async ({ controller, handlers }) => { + // The keypair is stable across reset, so both envelopes can be built + // up front while the session token (used only to derive the public + // key here) is still present. + const envelope1 = envelopeFor(controller, { + accessToken: 'access-1', + }); + const envelope2 = envelopeFor(controller, { + accessToken: 'access-2', + }); + const messageFor = ( + credentials: unknown, + ): { + kind: string; + meta: { channelId: string }; + payload: { status: string; credentials: unknown }; + } => ({ + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials }, + }); + + // Hold the first continuation open so a reset can land while it is + // still in flight. + let releaseCheck: (value: { kycRequired: boolean }) => void = () => { + // no-op placeholder until the deferred promise is wired up + }; + handlers.checkKycRequired.mockReturnValueOnce( + new Promise<{ kycRequired: boolean }>((resolve) => { + releaseCheck = resolve; + }), + ); + + const first = controller.handleFrameMessage({ + message: messageFor(envelope1), + }); + + // Reset while the continuation is awaiting the check. Its result is + // discarded by the generation guard (the check belongs to the + // superseded generation) rather than written onto the idle flow. + controller.reset(); + releaseCheck({ kycRequired: false }); + await first; + + // Re-establish a product-scoped flow (auto-creates a session and + // returns to phase `check`) and confirm the next completion continues + // again rather than being blocked forever by a stuck guard. + await controller.initialize({ product: 'ramps' }); + handlers.checkKycRequired.mockResolvedValue({ kycRequired: false }); + await controller.handleFrameMessage({ + message: messageFor(envelope2), + }); + + expect(handlers.checkKycRequired).toHaveBeenCalledTimes(2); + }, + ); + }); + + it('does not launch verification when the auto-run check fails', async () => { + await withController( + { + options: { + state: { + phase: 'check', + sessionToken: 'tok', + activeProduct: 'ramps', + geoCountry: 'USA', + }, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.checkKycRequired.mockRejectedValue(new Error('down')); + const envelope = envelopeFor(controller, { accessToken: 'access-1' }); + + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials: envelope }, + }, + }); + + expect(controller.state.phase).toBe('error'); + expect(launcher.launch).not.toHaveBeenCalled(); + }, + ); + }); + }); + + describe('frame URL builders', () => { + it('returns null for the check frame without a session', async () => { + await withController(({ controller }) => { + expect(controller.buildCheckFrameUrl()).toBeNull(); + }); + }); + + it('builds the check frame URL with a session', async () => { + await withController( + { options: { state: { sessionToken: 'tok' } } }, + ({ controller }) => { + const url = controller.buildCheckFrameUrl() as string; + expect(url).toContain('sessionToken=tok'); + expect(url).toContain('channelId=ch_1'); + expect(url).toContain('skipKyc=true'); + }, + ); + }); + + it('returns null for the auth frame without a client token', async () => { + await withController(({ controller }) => { + expect(controller.buildAuthFrameUrl()).toBeNull(); + }); + }); + + it('builds the reset frame URL', async () => { + await withController(({ controller }) => { + expect(controller.buildResetFrameUrl()).toContain('channelId=ch_reset'); + }); + }); + }); + + describe('checkKycRequired', () => { + it('fails without an access token', async () => { + await withController(async ({ controller }) => { + expect(await controller.checkKycRequired({ product: 'ramps' })).toBe( + false, + ); + expect(controller.state.error).toMatch(/Missing accessToken/u); + }); + }); + + it('fails without a country', async () => { + await withController( + { options: { state: { accessToken: 'a' } } }, + async ({ controller }) => { + expect(await controller.checkKycRequired({ product: 'ramps' })).toBe( + false, + ); + expect(controller.state.error).toMatch(/Missing country/u); + }, + ); + }); + + it('caches the result on success (cached country)', async () => { + await withController( + { options: { state: { accessToken: 'a', geoCountry: 'USA' } } }, + async ({ controller, handlers }) => { + handlers.checkKycRequired.mockResolvedValue({ kycRequired: true }); + + expect(await controller.checkKycRequired({ product: 'ramps' })).toBe( + true, + ); + expect(controller.state.kycRequiredByProduct.ramps).toBe(true); + expect(controller.state.phase).toBe('done'); + }, + ); + }); + + it('accepts a country override', async () => { + await withController( + { options: { state: { accessToken: 'a' } } }, + async ({ controller, handlers }) => { + handlers.checkKycRequired.mockResolvedValue({ kycRequired: false }); + + await controller.checkKycRequired({ + product: 'card', + country: 'FRA', + }); + + expect(handlers.checkKycRequired).toHaveBeenCalledWith({ + accessToken: 'a', + country: 'FRA', + capabilities: [{ product: 'card' }], + }); + }, + ); + }); + + it('fails when the service throws', async () => { + await withController( + { options: { state: { accessToken: 'a', geoCountry: 'USA' } } }, + async ({ controller, handlers }) => { + handlers.checkKycRequired.mockRejectedValue(new Error('down')); + + expect(await controller.checkKycRequired({ product: 'ramps' })).toBe( + false, + ); + expect(controller.state.error).toMatch(/KYC check failed/u); + }, + ); + }); + + it('discards a successful result when reset() runs while the check is in flight', async () => { + await withController( + { options: { state: { accessToken: 'a', geoCountry: 'USA' } } }, + async ({ controller, handlers }) => { + handlers.checkKycRequired.mockImplementation(async () => { + // Simulate a reset() landing while the HTTP call is in flight. + controller.reset(); + return { kycRequired: true }; + }); + + const result = await controller.checkKycRequired({ + product: 'ramps', + }); + + expect(result).toBe(false); + expect(controller.state.phase).toBe('idle'); + expect(controller.state.kycRequiredByProduct.ramps).toBeUndefined(); + expect(controller.state.lastCheckedAt).toBeNull(); + }, + ); + }); + + it('discards an error when reset() runs while the check is in flight', async () => { + await withController( + { options: { state: { accessToken: 'a', geoCountry: 'USA' } } }, + async ({ controller, handlers }) => { + handlers.checkKycRequired.mockImplementation(async () => { + controller.reset(); + throw new Error('down'); + }); + + const result = await controller.checkKycRequired({ + product: 'ramps', + }); + + expect(result).toBe(false); + expect(controller.state.phase).toBe('idle'); + expect(controller.state.error).toBeNull(); + }, + ); + }); + }); + + describe('getKycStatus', () => { + it('returns the cached value or undefined', async () => { + await withController( + { options: { state: { kycRequiredByProduct: { ramps: true } } } }, + ({ controller }) => { + expect(controller.getKycStatus({ product: 'ramps' })).toBe(true); + expect(controller.getKycStatus({ product: 'card' })).toBeUndefined(); + }, + ); + }); + }); + + describe('startSumSub', () => { + it('throws and marks failed when the SDK is unavailable', async () => { + await withController(async ({ controller, launcher }) => { + launcher.isAvailable.mockReturnValue(false); + + await expect(controller.startSumSub()).rejects.toThrow( + /not available/u, + ); + expect(controller.state.sumsub.status).toBe('failed'); + }); + }); + + it('runs the full sub-flow and completes', async () => { + await withController(async ({ controller, handlers, launcher }) => { + launcher.launch.mockImplementation( + async ({ onStatusChange, onTokenExpiration }) => { + onStatusChange?.('idle', 'InProgress'); + onStatusChange?.('InProgress', 'Completed'); + await onTokenExpiration(); + return { ok: true }; + }, + ); + + const result = await controller.startSumSub({ + locale: 'fr', + debug: true, + }); + + expect(result).toStrictEqual({ ok: true }); + expect(controller.state.sumsub.status).toBe('complete'); + expect(controller.state.sumsub.applicantAccessToken).toBe('aat'); + // The wrapped key and a read-only capability token are handed over + // once at session creation. + expect(handlers.createUkycSession).toHaveBeenCalledWith( + expect.objectContaining({ + wrappedEncryptionKey: expect.objectContaining({ + sessionId: 'wk', + encryptedKey: 'enc', + }), + ukycCapabilityToken: expect.objectContaining({ + payload: expect.objectContaining({ + operations: ['read'], + presenter: 'client', + }), + signature: expect.any(String), + }), + }), + ); + // onTokenExpiration re-fetches the applicant access token. + expect(handlers.createJourney).toHaveBeenCalledTimes(2); + }); + }); + + it('stops with a vendorProcessing status when the relay approved but the vendor is still pending', async () => { + await withController(async ({ controller, handlers, launcher }) => { + // The applicant already finished the journey: the relay reports + // `approved` while the vendor is still finalizing (`pending`). + handlers.createUkycSession.mockResolvedValue({ + sessionId: 'sid', + kycStatus: 'approved', + finalStatus: 'pending', + }); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({ + kycStatus: 'approved', + finalStatus: 'pending', + }); + expect(controller.state.sumsub.status).toBe('vendorProcessing'); + expect(controller.state.sumsub.sessionId).toBe('sid'); + expect(controller.state.statusMessage).toMatch( + /being processed by the vendor/u, + ); + // The SDK is never launched and no journey is created for an + // already-approved applicant. + expect(handlers.createJourney).not.toHaveBeenCalled(); + expect(launcher.launch).not.toHaveBeenCalled(); + }); + }); + + it('continues the flow when approved and the vendor is not pending', async () => { + await withController(async ({ controller, handlers, launcher }) => { + // A terminal vendor status (not `pending`) must not short-circuit. + handlers.createUkycSession.mockResolvedValue({ + sessionId: 'sid', + kycStatus: 'approved', + finalStatus: 'approved', + }); + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + handlers.getSessionStatus.mockResolvedValue(sessionStatus('approved')); + + await controller.startSumSub(); + + expect(handlers.createJourney).toHaveBeenCalled(); + expect(launcher.launch).toHaveBeenCalled(); + }); + }); + + it('does not write vendorProcessing state when reset() runs while creating the session', async () => { + await withController(async ({ controller, handlers, launcher }) => { + handlers.createUkycSession.mockImplementation(async () => { + controller.reset(); + return { + sessionId: 'sid', + kycStatus: 'approved', + finalStatus: 'pending', + }; + }); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({}); + expect(controller.state.sumsub.status).toBe('idle'); + expect(controller.state.sumsub.sessionId).toBeNull(); + expect(launcher.launch).not.toHaveBeenCalled(); + }); + }); + + it('aborts when the attested session server public key does not match', async () => { + await withController(async ({ controller, handlers, launcher }) => { + handlers.getWrappingKey.mockResolvedValue({ + id: 'wk', + jwtChain: 'jwt.chain.sig', + sessionServerPublicKey: { kty: 'OKP', crv: 'X25519', x: 'tampered' }, + }); + + const result = await controller.startSumSub(); + + expect(result).toMatchObject({ + error: expect.stringContaining( + 'sessionServerPublicKey does not match', + ), + }); + expect(controller.state.sumsub.status).toBe('failed'); + expect(launcher.launch).not.toHaveBeenCalled(); + }); + }); + + it('defaults locale and debug when no params are given', async () => { + await withController(async ({ controller, launcher }) => { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + + await controller.startSumSub(); + + expect(launcher.launch).toHaveBeenCalledWith( + expect.objectContaining({ locale: 'en', debug: false }), + ); + expect(controller.state.sumsub.status).toBe('complete'); + }); + }); + + it('marks failed when launch resolves without a Completed status', async () => { + await withController(async ({ controller, launcher }) => { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + // The applicant abandons the flow: the SDK reports progress but never + // a Completed status, yet `launch` still resolves. + onStatusChange?.('idle', 'InProgress'); + return { ok: false }; + }); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({ ok: false }); + expect(controller.state.sumsub.status).toBe('failed'); + expect(controller.state.sumsub.result).toStrictEqual({ ok: false }); + }); + }); + + it('marks failed and returns the error when a step throws', async () => { + await withController(async ({ controller, handlers }) => { + handlers.createUkycSession.mockRejectedValue(new Error('ukyc down')); + + const result = await controller.startSumSub(); + + expect(result).toMatchObject({ + error: expect.stringContaining('ukyc down'), + }); + expect(controller.state.sumsub.status).toBe('failed'); + }); + }); + + it('aborts without launching the SDK when reset() runs while in flight', async () => { + await withController(async ({ controller, handlers, launcher }) => { + // Simulate a reset() landing while the UKYC session is being created. + handlers.createUkycSession.mockImplementation(async () => { + controller.reset(); + return { + sessionId: 'sid', + }; + }); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({}); + expect(launcher.launch).not.toHaveBeenCalled(); + // The interrupted step must not write stale sub-flow state. + expect(controller.state.sumsub.status).toBe('idle'); + expect(controller.state.sumsub.sessionId).toBeNull(); + expect(controller.state.phase).toBe('idle'); + }); + }); + + it('aborts without launching the SDK when reset() runs just before launch', async () => { + await withController(async ({ controller, handlers, launcher }) => { + // A reset() lands during the final token exchange, i.e. after the + // session is prepared but before the SDK is presented. + handlers.createJourney.mockImplementation(async () => { + controller.reset(); + return { status: 'ok', applicantAccessToken: 'aat' }; + }); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({}); + // The SDK must not be opened on a flow that was reset to idle, and the + // `launching` status must not be written. + expect(launcher.launch).not.toHaveBeenCalled(); + expect(controller.state.sumsub.status).toBe('idle'); + expect(controller.state.sumsub.applicantAccessToken).toBeNull(); + expect(controller.state.phase).toBe('idle'); + }); + }); + + it('refuses to refresh the token via onTokenExpiration after a reset', async () => { + await withController(async ({ controller, handlers, launcher }) => { + let refreshError: unknown; + launcher.launch.mockImplementation(async ({ onTokenExpiration }) => { + // The SDK stays open across a reset, then asks for a fresh token. + controller.reset(); + // Only the initial createJourney (session setup) should + // have run. + const callsBeforeRefresh = handlers.createJourney.mock.calls.length; + try { + await onTokenExpiration(); + } catch (error) { + refreshError = error; + } + // The refresh must not hit the stale UKYC session. + expect(handlers.createJourney.mock.calls).toHaveLength( + callsBeforeRefresh, + ); + return { ok: true }; + }); + + await controller.startSumSub(); + + expect(refreshError).toBeInstanceOf(Error); + expect((refreshError as Error).message).toMatch(/flow was reset/u); + }); + }); + + it('suppresses status and terminal writes when reset() runs during the SDK launch', async () => { + await withController(async ({ controller, launcher }) => { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + // First status arrives on the active flow, then a reset() lands and + // a later status + the resolved result must not resurrect state. + onStatusChange?.('idle', 'InProgress'); + controller.reset(); + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({ ok: true }); + expect(controller.state.sumsub.status).toBe('idle'); + expect(controller.state.sumsub.result).toBeNull(); + expect(controller.state.phase).toBe('idle'); + }); + }); + }); + + describe('session status polling', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + /** + * Makes the launcher report a successful SDK completion so the sub-flow + * proceeds into session-status polling. + * + * @param launcher - The mocked launcher. + */ + function completeSdk(launcher: Launcher): void { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + } + + it('polls the session status after completion and completes on an approved status', async () => { + await withController(async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + handlers.getSessionStatus.mockResolvedValue(sessionStatus('approved')); + + await controller.startSumSub(); + + expect(handlers.getSessionStatus).toHaveBeenCalledWith({ + sessionId: 'sid', + }); + expect(controller.state.sumsub.status).toBe('complete'); + expect(controller.state.sumsub.sessionStatus).toStrictEqual( + sessionStatus('approved'), + ); + }); + }); + + it('maps a rejected terminal status to a failed sub-flow', async () => { + await withController(async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + handlers.getSessionStatus.mockResolvedValue(sessionStatus('rejected')); + + await controller.startSumSub(); + + expect(controller.state.sumsub.status).toBe('failed'); + expect(controller.state.sumsub.sessionStatus).toStrictEqual( + sessionStatus('rejected'), + ); + }); + }); + + it('treats SDK completion as final when the UKYC session has no id to poll', async () => { + await withController(async ({ controller, handlers, launcher }) => { + // A session created without an id leaves nothing to poll against. + handlers.createUkycSession.mockResolvedValue({ sessionId: '' }); + completeSdk(launcher); + + await controller.startSumSub(); + + expect(handlers.getSessionStatus).not.toHaveBeenCalled(); + expect(controller.state.sumsub.status).toBe('complete'); + }); + }); + + it('does not poll when the SDK did not report completion', async () => { + await withController(async ({ controller, handlers, launcher }) => { + // The applicant abandons the flow: `launch` resolves without ever + // reporting a Completed status. + launcher.launch.mockResolvedValue({ ok: false }); + + await controller.startSumSub(); + + expect(controller.state.sumsub.status).toBe('failed'); + expect(handlers.getSessionStatus).not.toHaveBeenCalled(); + }); + }); + + it('keeps polling on a transient error, preserving the last good status', async () => { + jest.useFakeTimers(); + await withController( + { options: { sessionStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + handlers.getSessionStatus + .mockResolvedValueOnce(sessionStatus('pending')) + .mockRejectedValueOnce(new Error('network blip')) + .mockResolvedValueOnce(sessionStatus('approved')); + + await controller.startSumSub(); + + // First poll: non-terminal, keeps polling. + expect(controller.state.sumsub.status).toBe('polling'); + expect(controller.state.sumsub.sessionStatus).toStrictEqual( + sessionStatus('pending'), + ); + + // Second poll fails transiently: the last good status is preserved + // and the loop keeps going. + await jest.advanceTimersByTimeAsync(1000); + expect(controller.state.sumsub.status).toBe('polling'); + expect(controller.state.sumsub.sessionStatus).toStrictEqual( + sessionStatus('pending'), + ); + + // Third poll reaches a terminal status. + await jest.advanceTimersByTimeAsync(1000); + expect(controller.state.sumsub.status).toBe('complete'); + expect(controller.state.sumsub.sessionStatus).toStrictEqual( + sessionStatus('approved'), + ); + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(3); + }, + ); + }); + + it('stops polling once a terminal status is reached', async () => { + jest.useFakeTimers(); + await withController( + { options: { sessionStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + handlers.getSessionStatus.mockResolvedValue( + sessionStatus('approved'), + ); + + await controller.startSumSub(); + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); + + // No further polls after a terminal status. + await jest.advanceTimersByTimeAsync(5000); + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('stops polling when reset() is called', async () => { + jest.useFakeTimers(); + await withController( + { options: { sessionStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + handlers.getSessionStatus.mockResolvedValue(sessionStatus('pending')); + + await controller.startSumSub(); + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); + + controller.reset(); + await jest.advanceTimersByTimeAsync(5000); + + // The scheduled poll was cancelled by reset(). + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); + expect(controller.state.sumsub.status).toBe('idle'); + }, + ); + }); + + it('discards a poll result when reset() runs while the request is in flight', async () => { + await withController(async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + // Simulate a reset() landing while the status request is in flight. + handlers.getSessionStatus.mockImplementation(async () => { + controller.reset(); + return sessionStatus('approved'); + }); + + await controller.startSumSub(); + + expect(controller.state.sumsub.status).toBe('idle'); + expect(controller.state.sumsub.sessionStatus).toBeNull(); + }); + }); + + it('supersedes a prior polling loop when a new sub-flow starts', async () => { + jest.useFakeTimers(); + await withController( + { options: { sessionStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + // First sub-flow polls a never-terminal status. + handlers.getSessionStatus.mockResolvedValue(sessionStatus('pending')); + + await controller.startSumSub(); + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); + + // A second sub-flow reaches a terminal status on its first poll and + // must cancel the first loop's scheduled poll. + handlers.getSessionStatus.mockResolvedValue( + sessionStatus('approved'), + ); + await controller.startSumSub(); + expect(controller.state.sumsub.status).toBe('complete'); + + const callsAfterSecondFlow = + handlers.getSessionStatus.mock.calls.length; + await jest.advanceTimersByTimeAsync(5000); + + // No stray polls from the superseded first loop. + expect(handlers.getSessionStatus).toHaveBeenCalledTimes( + callsAfterSecondFlow, + ); + }, + ); + }); + }); + + describe('getSessionStatus', () => { + it('fetches and records the session status on demand', async () => { + await withController( + { + options: { + state: { + sumsub: { + status: 'complete', + result: null, + sessionId: 'sid', + applicantAccessToken: null, + sessionStatus: null, + }, + }, + }, + }, + async ({ controller, handlers }) => { + handlers.getSessionStatus.mockResolvedValue( + sessionStatus('approved'), + ); + + const result = await controller.getSessionStatus(); + + expect(handlers.getSessionStatus).toHaveBeenCalledWith({ + sessionId: 'sid', + }); + expect(result).toStrictEqual(sessionStatus('approved')); + expect(controller.state.sumsub.sessionStatus).toStrictEqual( + sessionStatus('approved'), + ); + }, + ); + }); + + it('throws when there is no active SumSub session', async () => { + await withController(async ({ controller }) => { + await expect(controller.getSessionStatus()).rejects.toThrow( + /no active SumSub session/u, + ); + }); + }); + }); + + describe('registerMoneyAccountWallet', () => { + const registration = { + id: 'wallet-1', + address: '0xabc', + blockchain: 'Monad' as const, + disabled: false, + isSelf: true, + }; + + it('returns an existing active registration without signing', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getWalletRegistrationStatus.mockResolvedValue({ + type: 'active', + registration, + }); + + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toStrictEqual({ + type: 'alreadyRegistered', + registration, + }); + expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); + }); + }); + + it('returns an existing disabled registration without signing', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getWalletRegistrationStatus.mockResolvedValue({ + type: 'disabled', + registration: { ...registration, disabled: true }, + }); + + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toMatchObject({ type: 'registeredDisabled' }); + expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); + }); + }); + + it('prefers the customer id captured from the MoonPay frame', async () => { + await withController( + { options: { state: { moonpayCustomerId: 'frame-customer' } } }, + async ({ controller, handlers }) => { + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toMatchObject({ type: 'registered' }); + + expect(handlers.getMoonpayCustomerId).not.toHaveBeenCalled(); + expect(handlers.signPersonalMessage).toHaveBeenCalledWith({ + data: expect.stringContaining('as customer frame-customer.'), + from: '0xabc', + }); + expect(handlers.registerSelfHostedWallet).toHaveBeenCalledWith( + expect.objectContaining({ + address: '0xabc', + customerId: 'frame-customer', + signature: '0xsig', + }), + ); + }, + ); + }); + + it('falls back to resolving the customer id from the proxy', async () => { + await withController(async ({ controller, handlers }) => { + await controller.registerMoneyAccountWallet({ address: '0xabc' }); + + expect(handlers.getMoonpayCustomerId).toHaveBeenCalledTimes(1); + expect(handlers.registerSelfHostedWallet).toHaveBeenCalledWith( + expect.objectContaining({ + customerId: 'iron-customer-fallback', + }), + ); + }); + }); + + it('reconciles an ambiguous conflict as already registered', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getWalletRegistrationStatus + .mockResolvedValueOnce({ type: 'absent' }) + .mockResolvedValueOnce({ type: 'active', registration }); + handlers.registerSelfHostedWallet.mockRejectedValue( + new WalletRegistrationError('conflict', { httpStatus: 409 }), + ); + + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toStrictEqual({ + type: 'alreadyRegistered', + registration, + }); + }); + }); + + it('rethrows a transient failure when reconciliation remains absent', async () => { + await withController(async ({ controller, handlers }) => { + const error = new WalletRegistrationError('transient', { + httpStatus: 502, + }); + handlers.registerSelfHostedWallet.mockRejectedValue(error); + + await expect( + controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).rejects.toBe(error); + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledTimes(4); + expect(handlers.registerSelfHostedWallet).toHaveBeenCalledTimes(3); + }); + }); + + it('rebuilds and re-signs after a UTC date rollover', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-08-12T23:59:59.999Z')); + try { + await withController(async ({ controller, handlers }) => { + handlers.registerSelfHostedWallet + .mockImplementationOnce(async () => { + jest.setSystemTime(new Date('2026-08-13T00:00:00.000Z')); + throw new WalletRegistrationError('validation', { + httpStatus: 400, + }); + }) + .mockResolvedValueOnce({ + type: 'registered', + registration, + }); + + await controller.registerMoneyAccountWallet({ address: '0xabc' }); + + expect(handlers.signPersonalMessage).toHaveBeenCalledTimes(2); + expect(handlers.signPersonalMessage.mock.calls[0][0].data).toContain( + 'signed on 12/08/2026', + ); + expect(handlers.signPersonalMessage.mock.calls[1][0].data).toContain( + 'signed on 13/08/2026', + ); + }); + } finally { + jest.useRealTimers(); + } + }); + + it.each([ + new WalletRegistrationError('validation', { httpStatus: 400 }), + new WalletRegistrationError('rateLimited', { httpStatus: 429 }), + new WalletRegistrationError('unauthorized', { httpStatus: 401 }), + new Error('unexpected'), + ])('rethrows terminal registration failure %#', async (error) => { + await withController(async ({ controller, handlers }) => { + handlers.registerSelfHostedWallet.mockRejectedValue(error); + + await expect( + controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).rejects.toBe(error); + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledTimes(1); + }); + }); + + it('rethrows an initial lookup failure without signing', async () => { + await withController(async ({ controller, handlers }) => { + const error = new Error('lookup failed'); + handlers.getWalletRegistrationStatus.mockRejectedValue(error); + + await expect( + controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).rejects.toBe(error); + expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); + }); + }); + + it('rethrows a signing failure without submitting', async () => { + await withController(async ({ controller, handlers }) => { + const error = new Error('signing failed'); + handlers.signPersonalMessage.mockRejectedValue(error); + + await expect( + controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).rejects.toBe(error); + expect(handlers.registerSelfHostedWallet).not.toHaveBeenCalled(); + }); + }); + }); + + describe('reset', () => { + it('clears session state but preserves persisted terms', async () => { + await withController( + { + options: { + state: { + phase: 'form', + sessionToken: 'tok', + accessToken: 'a', + activeProduct: 'ramps', + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['1'], + kycRequiredByProduct: { ramps: true }, + }, + }, + }, + ({ controller }) => { + controller.reset(); + expect(controller.state.phase).toBe('idle'); + expect(controller.state.sessionToken).toBeNull(); + expect(controller.state.accessToken).toBeNull(); + expect(controller.state.activeProduct).toBeNull(); + expect(controller.state.termsAcceptedAt).toBe('t'); + expect(controller.state.kycRequiredByProduct.ramps).toBe(true); + }, + ); + }); + }); + + describe('messenger actions', () => { + it('exposes methods as messenger actions', async () => { + await withController(({ rootMessenger }) => { + expect( + rootMessenger.call('KycController:buildResetFrameUrl'), + ).toContain('ch_reset'); + }); + }); + }); +}); + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +type ServiceHandlers = { + getGeoCountry: jest.Mock; + fetchDisclaimers: jest.Mock; + createSession: jest.Mock; + checkKycRequired: jest.Mock; + getWrappingKey: jest.Mock; + fetchJwks: jest.Mock; + createUkycSession: jest.Mock; + createJourney: jest.Mock; + getSessionStatus: jest.Mock; + getMoonpayCustomerId: jest.Mock; + getWalletRegistrationStatus: jest.Mock; + registerSelfHostedWallet: jest.Mock; + signPersonalMessage: jest.Mock; + performGetStorage: jest.Mock; + performSetStorage: jest.Mock; +}; + +type Launcher = { + isAvailable: jest.Mock; + launch: jest.Mock; +}; + +type WithControllerCallback = (payload: { + controller: KycController; + rootMessenger: RootMessenger; + handlers: ServiceHandlers; + launcher: Launcher; +}) => Promise | ReturnValue; + +type WithControllerOptions = { + options: Partial[0]>; +}; + +const SERVICE_ACTIONS = [ + 'KycService:getGeoCountry', + 'KycService:fetchDisclaimers', + 'KycService:createSession', + 'KycService:checkKycRequired', + 'KycService:getWrappingKey', + 'KycService:fetchJwks', + 'KycService:createUkycSession', + 'KycService:createJourney', + 'KycService:getSessionStatus', + 'KycService:getMoonpayCustomerId', + 'KycService:getWalletRegistrationStatus', + 'KycService:registerSelfHostedWallet', + 'KeyringController:signPersonalMessage', + 'UserStorageController:performGetStorage', + 'UserStorageController:performSetStorage', +] as const; + +/** + * Builds a UKYC session status payload with a given `finalStatus`. + * + * @param finalStatus - The overall session status. + * @returns A complete session status object. + */ +function sessionStatus(finalStatus: string): { + finalStatus: string; + externalUserId: string; + kycStatus: string; + vendor: string; + vendorStatus: string; +} { + return { + finalStatus, + externalUserId: 'ext-1', + kycStatus: finalStatus, + vendor: 'sumsub', + vendorStatus: finalStatus, + }; +} + +/** + * Wraps a test with a fully-wired controller, mocked service handlers, and a + * mocked SumSub launcher. + * + * @param args - Either a callback, or an options bag and a callback. + * @returns The callback's return value. + */ +function withController( + ...args: + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback] +): ReturnValue | Promise { + const [{ options = {} }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + captureException: jest.fn(), + }); + const messenger: KycControllerMessenger = new Messenger({ + namespace: 'KycController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: SERVICE_ACTIONS, + events: [], + messenger, + }); + + const handlers: ServiceHandlers = { + getGeoCountry: jest.fn().mockResolvedValue('USA'), + fetchDisclaimers: jest.fn().mockResolvedValue([]), + createSession: jest.fn().mockResolvedValue({ sessionToken: 'sess' }), + checkKycRequired: jest.fn().mockResolvedValue({ kycRequired: false }), + getWrappingKey: jest.fn().mockResolvedValue({ + id: 'wk', + jwtChain: 'jwt.chain.sig', + // Matches the `sessionServerPublicKeyX` returned by the mocked + // `verifyJwtChain`, so the attestation check passes. + sessionServerPublicKey: { kty: 'OKP', crv: 'X25519', x: 'spk-x' }, + }), + fetchJwks: jest.fn().mockResolvedValue({ keys: [] }), + createUkycSession: jest.fn().mockResolvedValue({ + sessionId: 'sid', + }), + createJourney: jest + .fn() + .mockResolvedValue({ status: 'ok', applicantAccessToken: 'aat' }), + getSessionStatus: jest.fn().mockResolvedValue(sessionStatus('approved')), + getMoonpayCustomerId: jest.fn().mockResolvedValue('iron-customer-fallback'), + getWalletRegistrationStatus: jest + .fn() + .mockResolvedValue({ type: 'absent' }), + registerSelfHostedWallet: jest.fn().mockResolvedValue({ + type: 'registered', + registration: { + id: 'wallet-1', + address: '0xabc', + blockchain: 'Monad', + disabled: false, + isSelf: true, + }, + }), + signPersonalMessage: jest.fn().mockResolvedValue('0xsig'), + performGetStorage: jest.fn().mockResolvedValue(null), + performSetStorage: jest.fn().mockResolvedValue(undefined), + }; + rootMessenger.registerActionHandler( + 'KycService:getGeoCountry', + handlers.getGeoCountry, + ); + rootMessenger.registerActionHandler( + 'KycService:fetchDisclaimers', + handlers.fetchDisclaimers, + ); + rootMessenger.registerActionHandler( + 'KycService:createSession', + handlers.createSession, + ); + rootMessenger.registerActionHandler( + 'KycService:checkKycRequired', + handlers.checkKycRequired, + ); + rootMessenger.registerActionHandler( + 'KycService:getWrappingKey', + handlers.getWrappingKey, + ); + rootMessenger.registerActionHandler( + 'KycService:fetchJwks', + handlers.fetchJwks, + ); + rootMessenger.registerActionHandler( + 'KycService:createUkycSession', + handlers.createUkycSession, + ); + rootMessenger.registerActionHandler( + 'KycService:createJourney', + handlers.createJourney, + ); + rootMessenger.registerActionHandler( + 'KycService:getSessionStatus', + handlers.getSessionStatus, + ); + rootMessenger.registerActionHandler( + 'KycService:getMoonpayCustomerId', + handlers.getMoonpayCustomerId, + ); + rootMessenger.registerActionHandler( + 'KycService:getWalletRegistrationStatus', + handlers.getWalletRegistrationStatus, + ); + rootMessenger.registerActionHandler( + 'KycService:registerSelfHostedWallet', + handlers.registerSelfHostedWallet, + ); + rootMessenger.registerActionHandler( + 'KeyringController:signPersonalMessage', + handlers.signPersonalMessage, + ); + rootMessenger.registerActionHandler( + 'UserStorageController:performGetStorage', + handlers.performGetStorage, + ); + rootMessenger.registerActionHandler( + 'UserStorageController:performSetStorage', + handlers.performSetStorage, + ); + + // Configure the mocked UKYC crypto for this test (reset before each test by + // the shared jest config). + mockVerifyJwtChain.mockReturnValue({ + sessionServerPublicKeyX: 'spk-x', + nonce: 'n', + }); + mockWrapEncryptionKey.mockReturnValue({ + encryptedKey: 'enc', + nonce: 'nonce', + }); + + const launcher: Launcher = { + isAvailable: jest.fn().mockReturnValue(true), + launch: jest.fn().mockResolvedValue({ ok: true }), + }; + + const controller = new KycController({ + messenger, + sumsubLauncher: launcher as unknown as KycSumSubLauncher, + ...options, + }); + + return testFunction({ controller, rootMessenger, handlers, launcher }); +} diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts new file mode 100644 index 00000000000..39a9e9e7acf --- /dev/null +++ b/packages/kyc-controller/src/KycController.ts @@ -0,0 +1,1665 @@ +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { KeyringControllerSignPersonalMessageAction } from '@metamask/keyring-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { + UserStorageControllerPerformGetStorageAction, + UserStorageControllerPerformSetStorageAction, +} from '@metamask/profile-sync-controller/user-storage'; +import type { Hex, Json } from '@metamask/utils'; +import { x25519 } from '@noble/curves/ed25519'; + +import { decryptCredentials, generateKeyPair } from './crypto.js'; +import type { EncryptedCredentialsEnvelope, X25519KeyPair } from './crypto.js'; +import { toBase64Url } from './encoding.js'; +import type { KycControllerMethodActions } from './KycController-method-action-types.js'; +import type { KycServiceMethodActions } from './KycService-method-action-types.js'; +import { buildOwnershipMessage } from './ownership-message.js'; +import type { + KycDisclaimer, + KycPhase, + KycProduct, + KycSessionStatus, + KycSumSubLauncher, + KycSumSubStatus, +} from './types.js'; +import { deriveClientMaterial } from './ukyc/deriveClientMaterial.js'; +import { verifyJwtChain } from './ukyc/jwtChain.js'; +import { getOrCreateLocalUserSecret } from './ukyc/localUserSecret.js'; +import type { UkycLocalUserSecretStore } from './ukyc/localUserSecret.js'; +import { signStorageAccessToken } from './ukyc/storageAccessToken.js'; +import { wrapEncryptionKey } from './ukyc/wrapEncryptionKey.js'; +import { + createInitialState, + transition as transitionWalletRegistration, +} from './wallet-registration-machine.js'; +import type { + RegistrationStatus, + SelfHostedRegistration, +} from './wallet-registration-service.js'; +import { WalletRegistrationError } from './wallet-registration-service.js'; + +// === GENERAL === + +export const controllerName = 'KycController'; + +const FRAMES_BASE_URL = 'https://blocks.moonpay.com/platform/v1'; +const CHANNEL_CHECK = 'ch_1'; +const CHANNEL_AUTH = 'ch_2'; +const CHANNEL_RESET = 'ch_reset'; + +// Placeholder credentials for the SumSub sub-flow. These are demo values that +// must be replaced with real UKYC-issued material before production use. +const MOCK_JWT_TOKEN = 'mock-jwt-token'; + +// Lifetime of the read-only `ukyc_capability_token` minted when creating a +// UKYC session. The storage-and-auth spec requires the token's `expires_at` to +// cover the KYC session's expected lifetime — including the provider journey — +// rather than a fixed short window, so this is a session-scoped window. +const UKYC_CAPABILITY_TOKEN_TTL_MS = 4 * 60 * 60 * 1000; + +// The SumSub SDK status that signals the applicant finished the flow +// successfully. Any other resolution (abandonment, failure, or a non-success +// outcome) must not be recorded as `complete`. +const SUMSUB_COMPLETED_STATUS = 'Completed'; + +// Phases that represent an active vendor-session flow (tokens issued and/or +// Check/Auth frames in progress). A repeat `initialize` while in one of these +// must not restart the session and disrupt the in-flight flow. +const IN_PROGRESS_PHASES: KycPhase[] = [ + 'session', + 'check', + 'auth', + 'form', + 'submit', +]; + +// How often to poll the UKYC session status after the SumSub SDK completes, +// until a terminal status is reached. Overridable via the constructor. +const DEFAULT_SESSION_STATUS_POLL_INTERVAL_MS = 15_000; + +// UKYC status values. `kycStatus` (the relay-side decision) and `finalStatus` +// (the vendor-side outcome) draw from the same vocabulary, so they are defined +// once here and composed into the sets/checks below rather than repeated as +// literals. +const KYC_STATUSES = { + approved: 'approved', + completed: 'completed', + rejected: 'rejected', + failed: 'failed', + blocked: 'blocked', + pending: 'pending', +} as const; + +// `finalStatus` values that end the polling loop. Anything else (e.g. +// `KYC_STATUSES.pending`) keeps polling. +const TERMINAL_SESSION_STATUSES: ReadonlySet = new Set([ + KYC_STATUSES.approved, + KYC_STATUSES.completed, + KYC_STATUSES.rejected, + KYC_STATUSES.failed, + KYC_STATUSES.blocked, +]); + +// Terminal `finalStatus` values that represent a successful verification. Any +// other terminal status resolves the sub-flow to `failed`. +const SUCCESSFUL_SESSION_STATUSES: ReadonlySet = new Set([ + KYC_STATUSES.approved, + KYC_STATUSES.completed, +]); + +// Session creation can report that the applicant is already approved on the +// relay (`kycStatus === KYC_STATUSES.approved`) while the vendor is still +// finalizing its decision (`finalStatus === KYC_STATUSES.pending`, a +// non-terminal status). In that case there is nothing left for the applicant +// to do, so the sub-flow stops before launching the SDK and surfaces this +// message. +const VENDOR_PROCESSING_MESSAGE = + 'Your KYC has been submitted and is being processed by the vendor.'; + +// === STATE === + +/** + * Describes the shape of the state object for {@link KycController}. + */ +export type KycControllerState = { + /** Current phase of the identity flow. */ + phase: KycPhase; + /** Human-readable status message for the current phase. */ + statusMessage: string; + /** The current error message, or `null`. */ + error: string | null; + + /** Email associated with the session (sourced from the account). */ + email: string | null; + + /** ISO-8601 timestamp of the customer's terms acceptance (persisted). */ + termsAcceptedAt: string | null; + /** IDs of the disclaimers the customer accepted (persisted). */ + acceptedDisclaimerIds: string[]; + + /** Disclaimers fetched for the current country. */ + disclaimers: KycDisclaimer[]; + /** Error encountered while loading disclaimers, or `null`. */ + disclaimersError: string | null; + + /** Resolved ISO 3166-1 alpha-3 country code. */ + geoCountry: string | null; + + /** Vendor session token (not persisted, not logged). */ + sessionToken: string | null; + /** Vendor access token (not persisted, not logged). */ + accessToken: string | null; + /** Vendor customer id, used for the SumSub hand-off. */ + moonpayCustomerId: string | null; + + /** + * The product the current flow is running for. Captured at `initialize` + * (or `acceptTermsAndStartSession`) and used to automatically run the + * KYC-required check once authentication completes. `null` outside a + * product-scoped flow (in which case the flow stops at `form` and the + * consumer drives the check manually). + */ + activeProduct: KycProduct | null; + + /** Cached "is KYC required" result per product (persisted). */ + kycRequiredByProduct: Partial>; + /** ISO-8601 timestamp of the last KYC-required check (persisted). */ + lastCheckedAt: string | null; + + /** SumSub document-verification sub-flow state. */ + sumsub: { + status: KycSumSubStatus; + result: Json | null; + sessionId: string | null; + applicantAccessToken: string | null; + /** + * The latest UKYC session status, populated while polling after the SDK + * completes. `null` until the first successful poll. + */ + sessionStatus: KycSessionStatus | null; + }; +}; + +const kycControllerMetadata = { + phase: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, + statusMessage: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, + error: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, + email: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + }, + termsAcceptedAt: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: false, + }, + acceptedDisclaimerIds: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: false, + }, + disclaimers: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: true, + }, + disclaimersError: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, + geoCountry: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, + sessionToken: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + }, + accessToken: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + }, + moonpayCustomerId: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + }, + activeProduct: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, + kycRequiredByProduct: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: true, + }, + lastCheckedAt: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: false, + }, + sumsub: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: true, + }, +} satisfies StateMetadata; + +/** + * Constructs the default {@link KycController} state. + * + * @returns The default state. + */ +export function getDefaultKycControllerState(): KycControllerState { + return { + phase: 'idle', + statusMessage: '', + error: null, + email: null, + termsAcceptedAt: null, + acceptedDisclaimerIds: [], + disclaimers: [], + disclaimersError: null, + geoCountry: null, + sessionToken: null, + accessToken: null, + moonpayCustomerId: null, + activeProduct: null, + kycRequiredByProduct: {}, + lastCheckedAt: null, + sumsub: { + status: 'idle', + result: null, + sessionId: null, + applicantAccessToken: null, + sessionStatus: null, + }, + }; +} + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = [ + 'initialize', + 'loadDisclaimers', + 'acceptTermsAndStartSession', + 'clearSavedTerms', + 'handleFrameMessage', + 'buildCheckFrameUrl', + 'buildAuthFrameUrl', + 'buildResetFrameUrl', + 'checkKycRequired', + 'getKycStatus', + 'startSumSub', + 'getSessionStatus', + 'registerMoneyAccountWallet', + 'reset', +] as const; + +export type KycControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + KycControllerState +>; + +export type KycControllerActions = + | KycControllerGetStateAction + | KycControllerMethodActions; + +type AllowedActions = + | KycServiceMethodActions + | KeyringControllerSignPersonalMessageAction + | UserStorageControllerPerformGetStorageAction + | UserStorageControllerPerformSetStorageAction; + +export type KycControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + KycControllerState +>; + +export type KycControllerEvents = KycControllerStateChangeEvent; + +type AllowedEvents = never; + +export type KycControllerMessenger = Messenger< + typeof controllerName, + KycControllerActions | AllowedActions, + KycControllerEvents | AllowedEvents +>; + +/** + * Options for constructing a {@link KycController}. + */ +export type KycControllerOptions = { + messenger: KycControllerMessenger; + state?: Partial; + /** + * Platform adapter that presents the SumSub SDK. Injected by each client so + * the controller stays platform-agnostic. + */ + sumsubLauncher: KycSumSubLauncher; + /** + * How often, in milliseconds, to poll the UKYC session status after the + * SumSub SDK completes. Defaults to + * {@link DEFAULT_SESSION_STATUS_POLL_INTERVAL_MS}. + */ + sessionStatusPollIntervalMs?: number; +}; + +export type MoneyAccountWalletRegistrationResult = + | { + type: 'registered' | 'alreadyRegistered'; + registration: SelfHostedRegistration; + } + | { + type: 'registeredDisabled'; + registration: SelfHostedRegistration; + }; + +/** + * The shape of a message posted by a Check/Auth frame. + */ +type FrameMessage = { + meta?: { channelId?: string }; + kind?: string; + payload?: { + status?: + | 'active' + | 'connectionRequired' + | 'termsAcceptanceRequired' + | 'pending' + | 'unavailable' + | 'failed'; + credentials?: EncryptedCredentialsEnvelope | string; + customer?: { id?: string }; + }; +}; + +// === CONTROLLER DEFINITION === + +/** + * `KycController` orchestrates the vendor-backed KYC / identity-verification + * flow (MoonPay identity + SumSub documents) behind a vendor-neutral, per + * product surface used by ramps and card. It owns all state, HTTP + * orchestration (via `KycService`), crypto, and the frame message protocol; + * platform-specific presentation (WebView/iframe, SumSub SDK) is injected. + */ +export class KycController extends BaseController< + typeof controllerName, + KycControllerState, + KycControllerMessenger +> { + readonly #sumsubLauncher: KycSumSubLauncher; + + /** Ephemeral X25519 keypair for the frame key exchange (never persisted). */ + readonly #keypair: X25519KeyPair; + + /** Auth-frame client token, kept out of state. */ + #authClientToken: string | null = null; + + /** + * Monotonic flow generation. Incremented by {@link reset} so in-flight async + * work (e.g. the KYC-required check) can detect that it was superseded and + * avoid writing stale results onto a reset controller. + */ + #generation = 0; + + /** Interval, in milliseconds, between session-status polls. */ + readonly #sessionStatusPollIntervalMs: number; + + /** Handle for the scheduled next session-status poll, or `null`. */ + #pollTimer: ReturnType | null = null; + + /** + * Monotonic polling token. Bumped by {@link #stopPolling} (called on reset, a + * new sub-flow, and once a terminal status is reached) so an in-flight poll + * `tick` can detect it was superseded and neither write state nor schedule a + * follow-up. This closes the gap where clearing the timer alone would still + * let an already-awaiting request finish and reschedule. + */ + #pollToken = 0; + + /** + * Constructs a new {@link KycController}. + * + * @param options - The constructor options. + * @param options.messenger - The messenger suited for this controller. + * @param options.state - Partial initial state; merged over defaults. + * @param options.sumsubLauncher - The platform SumSub launcher adapter. + * @param options.sessionStatusPollIntervalMs - How often to poll the UKYC + * session status after the SumSub SDK completes. + */ + constructor({ + messenger, + state, + sumsubLauncher, + sessionStatusPollIntervalMs = DEFAULT_SESSION_STATUS_POLL_INTERVAL_MS, + }: KycControllerOptions) { + super({ + messenger, + metadata: kycControllerMetadata, + name: controllerName, + state: { ...getDefaultKycControllerState(), ...state }, + }); + + this.#sumsubLauncher = sumsubLauncher; + this.#sessionStatusPollIntervalMs = sessionStatusPollIntervalMs; + this.#keypair = generateKeyPair(); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Builds an adapter over `UserStorageController` that the platform-agnostic + * `getOrCreateLocalUserSecret` helper uses to persist/load the UKYC + * `local_user_secret`. + * + * @returns The Encrypted User Storage adapter. + */ + #localUserSecretStore(): UkycLocalUserSecretStore { + return { + get: async ( + path: string, + entropySourceId?: string, + ): Promise => + this.messenger.call( + 'UserStorageController:performGetStorage', + path as `${string}.${string}`, + entropySourceId, + ), + set: async ( + path: string, + value: string, + entropySourceId?: string, + ): Promise => + this.messenger.call( + 'UserStorageController:performSetStorage', + path as `${string}.${string}`, + value, + entropySourceId, + ), + }; + } + + /** + * Resolves persisted terms + geolocation, and auto-creates a session when + * terms are already accepted and an email is available. + * + * @param params - Optional parameters. + * @param params.email - The account email to associate with the session. + * @param params.product - The consuming feature the flow runs for. When + * provided, the controller automatically runs the KYC-required check once + * authentication completes (and chains into document verification when KYC + * is required). When omitted, the flow stops at `form` and the consumer must + * call `checkKycRequired` manually. + */ + async initialize(params?: { + email?: string; + product?: KycProduct; + }): Promise { + // A repeat `initialize` while a session flow is already in progress must + // not tear it down: creating a new vendor session clears the tokens and + // forces `phase` back through `session`/`check`, breaking an in-flight + // Check/Auth frame flow. Leave the active flow untouched and let the + // consumer drive it (or call `reset` first to start over). + if (IN_PROGRESS_PHASES.includes(this.state.phase)) { + return; + } + + // `initialize` starts a fresh flow, so `activeProduct` is always reset to + // this call's product (or `null`). Otherwise a prior run's product could + // linger and cause `#continueAfterAuthentication` to auto-run the check / + // sub-flow when the caller intended the manual (product-less) flow. + this.#applyUpdate((state) => { + if (params?.email) { + state.email = params.email; + } + state.activeProduct = params?.product ?? null; + }); + + // Capture the flow generation so a `reset()` landing while the async + // geolocation / session steps below are in flight cannot write results + // onto an idle controller. + const generation = this.#generation; + + // Resolve country for display; non-blocking. + try { + const country = await this.messenger.call('KycService:getGeoCountry'); + this.#updateIfCurrent(generation, (state) => { + state.geoCountry = country; + }); + } catch { + // Ignore; disclaimers loading will surface a country error if needed. + } + + const hasTerms = + Boolean(this.state.termsAcceptedAt) && + this.state.acceptedDisclaimerIds.length > 0; + + if (hasTerms && this.state.email) { + await this.#createSession(); + return; + } + + this.#applyUpdate((state) => { + state.phase = 'terms'; + }); + await this.loadDisclaimers(); + } + + /** + * Loads the disclaimers for the resolved (or provided) country. + * + * @param params - Optional parameters. + * @param params.country - ISO 3166-1 alpha-3 country code override. + */ + async loadDisclaimers(params?: { country?: string }): Promise { + // Capture the flow generation so a `reset()` landing while the geo / + // disclaimers requests are in flight cannot write results onto an idle + // controller. + const generation = this.#generation; + try { + const country = + params?.country ?? + this.state.geoCountry ?? + (await this.messenger.call('KycService:getGeoCountry')); + if (country !== this.state.geoCountry) { + this.#updateIfCurrent(generation, (state) => { + state.geoCountry = country; + }); + } + const disclaimers = await this.messenger.call( + 'KycService:fetchDisclaimers', + { country }, + ); + this.#updateIfCurrent(generation, (state) => { + state.disclaimers = disclaimers; + state.disclaimersError = null; + }); + } catch (error) { + this.#updateIfCurrent(generation, (state) => { + state.disclaimersError = `Failed to load disclaimers: ${String(error)}`; + }); + } + } + + /** + * Captures terms acceptance for the currently loaded disclaimers and creates + * a session. + * + * @param params - Optional parameters. + * @param params.email - The account email to associate with the session. + * @param params.product - The consuming feature the flow runs for. See + * {@link initialize} for how the product drives the automatic post + * authentication continuation. + */ + async acceptTermsAndStartSession(params?: { + email?: string; + product?: KycProduct; + }): Promise { + const termsAcceptedAt = new Date().toISOString(); + const disclaimerIds = this.state.disclaimers.map( + (disclaimer) => disclaimer.id, + ); + this.#applyUpdate((state) => { + if (params?.email) { + state.email = params.email; + } + if (params?.product) { + state.activeProduct = params.product; + } + state.termsAcceptedAt = termsAcceptedAt; + state.acceptedDisclaimerIds = disclaimerIds; + }); + await this.#createSession(); + } + + /** + * Creates a vendor session from the currently stored terms + email. + */ + async #createSession(): Promise { + const { email, termsAcceptedAt, acceptedDisclaimerIds } = this.state; + if (!email) { + this.#fail('Missing email for session creation.'); + return; + } + if (!termsAcceptedAt || acceptedDisclaimerIds.length === 0) { + this.#fail('Missing terms acceptance for session creation.'); + return; + } + + // A new session invalidates any authentication carried over from a prior + // session. Clear the stale session token, access token, and auth-frame + // client token so `buildCheckFrameUrl` cannot return a URL bound to an old + // (or, on failure, invalid) session token, `buildAuthFrameUrl` cannot + // return a URL tied to an old client token, and `checkKycRequired` cannot + // run with an access token from an earlier authentication. The Check/Auth + // frames re-populate these for the new session. Because `sessionToken` is + // cleared here and only re-set on success, a failed creation leaves it + // `null` rather than resurrecting the previous session. + // Capture the flow generation so a `reset()` landing while the create + // request is in flight cannot resurrect a session (success) or overwrite + // the now-idle controller (failure). The synchronous update below runs + // before any `await`, so it needs no guard. + const generation = this.#generation; + this.#authClientToken = null; + this.#applyUpdate((state) => { + state.error = null; + state.phase = 'session'; + state.statusMessage = 'Creating session...'; + state.sessionToken = null; + state.accessToken = null; + }); + + try { + const { sessionToken } = await this.messenger.call( + 'KycService:createSession', + { email, termsAcceptedAt, disclaimerIds: acceptedDisclaimerIds }, + ); + this.#updateIfCurrent(generation, (state) => { + state.sessionToken = sessionToken; + state.phase = 'check'; + state.statusMessage = 'Authenticating via Check frame...'; + }); + } catch (error) { + console.error('Session creation failed:', error); + // A reset() superseded this flow while the request was in flight; leave + // the idle controller alone rather than forcing it back to `terms`. + if (this.#generation !== generation) { + return; + } + // Invalidate the stored acceptance so the customer can retry. Also clear + // `activeProduct` so a later `acceptTermsAndStartSession` that omits a + // product cannot auto-run the KYC check / SumSub chain for this failed + // flow's product — matching how `initialize` starts from a clean product. + this.#applyUpdate((state) => { + this.#clearAcceptedTerms(state); + state.activeProduct = null; + state.error = `Session creation failed: ${String(error)}`; + state.statusMessage = + 'Session creation failed — accept the terms to try again.'; + state.phase = 'terms'; + }); + await this.loadDisclaimers(); + } + } + + /** + * Clears the persisted terms acceptance. + */ + clearSavedTerms(): void { + this.#applyUpdate((state) => { + this.#clearAcceptedTerms(state); + }); + } + + /** + * Clears the stored terms acceptance on the given draft state. Shared by the + * paths that must invalidate acceptance — explicit clear, vendor terms + * update, and session-creation failure — so they stay in sync. This is a + * targeted invalidation and, unlike {@link reset}, deliberately leaves the + * rest of the flow (geolocation, disclaimers, phase) untouched. + * + * @param state - The state to mutate. + */ + #clearAcceptedTerms(state: KycControllerState): void { + state.termsAcceptedAt = null; + state.acceptedDisclaimerIds = []; + } + + /** + * Handles a message posted by a Check/Auth frame and advances the flow. + * + * The transport-agnostic caller (WebView on mobile, iframe on web) forwards + * the raw message and injects the returned `reply` back into the frame. + * + * @param params - The parameters. + * @param params.message - The raw message posted by the frame. + * @returns An object whose optional `reply` should be posted back. + */ + async handleFrameMessage(params: { + message: unknown; + }): Promise<{ reply?: unknown }> { + const payload = params.message as FrameMessage | undefined; + + if (!payload) { + return {}; + } + + if (payload.kind === 'handshake') { + const channelId = payload.meta?.channelId; + return { reply: { version: 2, meta: { channelId }, kind: 'ack' } }; + } + + if (payload.kind !== 'complete') { + return {}; + } + + const channelId = payload.meta?.channelId; + + // Only honor a Check/Auth `complete` for the frame the flow is currently + // waiting on. This drops stale or duplicate messages — e.g. a late post + // after `reset()` (phase `idle`) or after the flow already advanced past + // this frame — so they cannot resurrect tokens or rewind `phase` on a + // controller that has moved on. Frame messages are external input and, + // unlike the async steps, are not covered by the `#generation` guard. + let expectedPhase: KycPhase | null = null; + if (channelId === CHANNEL_CHECK) { + expectedPhase = 'check'; + } else if (channelId === CHANNEL_AUTH) { + expectedPhase = 'auth'; + } + if (!expectedPhase || this.state.phase !== expectedPhase) { + return {}; + } + + const status = payload.payload?.status; + const credsEnvelope = payload.payload?.credentials; + + const customerId = payload.payload?.customer?.id ?? null; + if (customerId) { + this.#applyUpdate((state) => { + state.moonpayCustomerId = customerId; + }); + } + + if (!status) { + return {}; + } + + let accessToken: string | undefined; + let clientToken: string | undefined; + if (credsEnvelope) { + try { + const { credentials } = decryptCredentials( + credsEnvelope, + this.#keypair.privateKey, + ); + accessToken = credentials.accessToken; + clientToken = credentials.clientToken; + } catch (error) { + this.#fail(`Failed to decrypt frame credentials: ${String(error)}`); + return {}; + } + } + + if (channelId === CHANNEL_CHECK) { + await this.#handleCheckOutcome(status, accessToken, clientToken); + return {}; + } + + // channelId === CHANNEL_AUTH, guaranteed by the expectedPhase guard above. + await this.#handleAuthOutcome(status, accessToken); + return {}; + } + + /** + * Applies a Check-frame outcome. + * + * @param status - The frame status. + * @param accessToken - The decrypted access token, if any. + * @param clientToken - The decrypted client token, if any. + */ + async #handleCheckOutcome( + status: NonNullable['status'], + accessToken?: string, + clientToken?: string, + ): Promise { + if (status === 'active' && accessToken) { + this.#applyUpdate((state) => { + state.accessToken = accessToken; + state.phase = 'form'; + state.statusMessage = 'Already authenticated. Review to submit.'; + }); + await this.#continueAfterAuthentication(); + return; + } + if (status === 'connectionRequired' && clientToken) { + this.#authClientToken = clientToken; + this.#applyUpdate((state) => { + state.phase = 'auth'; + state.statusMessage = 'Verify your email via OTP in the Auth frame.'; + }); + return; + } + if (status === 'termsAcceptanceRequired') { + this.#requireTermsReacceptance(); + return; + } + this.#fail(`Check frame returned status: ${status}`); + } + + /** + * Applies an Auth-frame outcome. + * + * @param status - The frame status. + * @param accessToken - The decrypted access token, if any. + */ + async #handleAuthOutcome( + status: NonNullable['status'], + accessToken?: string, + ): Promise { + if (status === 'active' && accessToken) { + this.#applyUpdate((state) => { + state.accessToken = accessToken; + state.phase = 'form'; + state.statusMessage = 'Authenticated. Review to submit.'; + }); + await this.#continueAfterAuthentication(); + return; + } + if (status === 'termsAcceptanceRequired') { + this.#requireTermsReacceptance(); + return; + } + this.#fail(`Auth frame returned status: ${status}`); + } + + /** + * Continues the flow once authentication has completed (phase `form`). + * + * When the flow is scoped to a product (see {@link initialize}), the + * KYC-required check runs automatically, and — when KYC is required — the + * document-verification sub-flow is launched. When no product is set, this is + * a no-op and the flow stays at `form` for the consumer to drive manually. + * + * Errors are already recorded on state by `checkKycRequired` (`error` + * phase) and `startSumSub` (`sumsub.status = 'failed'`); this method swallows + * them so it can be awaited safely from the frame-message handler. + */ + async #continueAfterAuthentication(): Promise { + const product = this.state.activeProduct; + if (!product) { + return; + } + + // Re-entry protection lives at the frame boundary: `handleFrameMessage` + // only honors a Check/Auth `complete` while `phase` matches, and both + // outcome handlers move `phase` to `form` before awaiting this method. A + // duplicate or late `complete` therefore lands after the phase moved on and + // is dropped before it can start a second continuation. Any writes here are + // additionally guarded by `#generation` (see `checkKycRequired` / + // `startSumSub`) so a `reset()` mid-continuation cannot corrupt state. + const kycRequired = await this.checkKycRequired({ product }); + if (!kycRequired) { + return; + } + + try { + await this.startSumSub(); + } catch { + // `startSumSub` already records `sumsub.status = 'failed'`; swallow the + // rethrown error (e.g. SDK unavailable) so the awaited continuation + // resolves cleanly rather than surfacing as an unhandled rejection. + } + } + + /** + * Invalidates stored terms and returns to the terms phase. + */ + #requireTermsReacceptance(): void { + this.#applyUpdate((state) => { + this.#clearAcceptedTerms(state); + state.phase = 'terms'; + state.statusMessage = + 'The vendor updated its Terms of Use — please re-accept.'; + }); + } + + /** + * Builds the Check-frame URL, or `null` when no session exists yet. + * + * @returns The Check-frame URL or `null`. + */ + buildCheckFrameUrl(): string | null { + if (!this.state.sessionToken) { + return null; + } + const url = new URL(`${FRAMES_BASE_URL}/check-connection`); + url.searchParams.set('sessionToken', this.state.sessionToken); + url.searchParams.set('publicKey', this.#keypair.publicKeyHex); + url.searchParams.set('channelId', CHANNEL_CHECK); + url.searchParams.set('skipKyc', 'true'); + return url.toString(); + } + + /** + * Builds the Auth-frame URL, or `null` when no client token is available. + * + * @returns The Auth-frame URL or `null`. + */ + buildAuthFrameUrl(): string | null { + if (!this.#authClientToken) { + return null; + } + const url = new URL(`${FRAMES_BASE_URL}/auth`); + url.searchParams.set('clientToken', this.#authClientToken); + url.searchParams.set('publicKey', this.#keypair.publicKeyHex); + url.searchParams.set('channelId', CHANNEL_AUTH); + return url.toString(); + } + + /** + * Builds the Reset-frame URL. + * + * @returns The Reset-frame URL. + */ + buildResetFrameUrl(): string { + const url = new URL(`${FRAMES_BASE_URL}/reset`); + url.searchParams.set('channelId', CHANNEL_RESET); + return url.toString(); + } + + /** + * Checks whether KYC is required for a product and caches the result. + * + * @param params - The parameters. + * @param params.product - The consuming feature. + * @param params.country - Optional alpha-3 country override. + * @returns Whether KYC is required. + */ + async checkKycRequired(params: { + product: KycProduct; + country?: string; + }): Promise { + const { accessToken } = this.state; + if (!accessToken) { + this.#fail('Missing accessToken — repeat the authentication step.'); + return false; + } + const country = params.country ?? this.state.geoCountry; + if (!country) { + this.#fail('Missing country for KYC-required check.'); + return false; + } + + // Capture the flow generation so we can detect a `reset()` that happens + // while the HTTP call is in flight and avoid writing stale results. + const generation = this.#generation; + + this.#applyUpdate((state) => { + state.phase = 'submit'; + state.statusMessage = 'Checking KYC status...'; + }); + + try { + const { kycRequired } = await this.messenger.call( + 'KycService:checkKycRequired', + { accessToken, country, capabilities: [{ product: params.product }] }, + ); + // The flow was reset while the check was in flight; discard the result + // rather than resurrecting a done/cached state on an idle controller. + const applied = this.#updateIfCurrent(generation, (state) => { + state.kycRequiredByProduct[params.product] = kycRequired; + state.lastCheckedAt = new Date().toISOString(); + state.phase = 'done'; + state.statusMessage = 'KYC check complete.'; + }); + if (!applied) { + return false; + } + return kycRequired; + } catch (error) { + if (this.#generation !== generation) { + return false; + } + this.#fail(`KYC check failed: ${String(error)}`); + return false; + } + } + + /** + * Reads the cached "is KYC required" result for a product. + * + * @param params - The parameters. + * @param params.product - The consuming feature. + * @returns The cached value, or `undefined` if not yet checked. + */ + getKycStatus(params: { product: KycProduct }): boolean | undefined { + return this.state.kycRequiredByProduct[params.product]; + } + + /** + * Runs the SumSub document-verification sub-flow end to end: + * + * 1. requests a per-session wrapping key from the UKYC backend; + * 2. verifies its `jwtChain` against the Fractal JWKS and confirms the + * attested session server public key; + * 3. derives the `data_encryption_key` from the wallet's UKYC + * `local_user_secret` and wraps it for the session server; + * 4. mints a client-signed, read-only `ukyc_capability_token` and creates + * the UKYC session (handing over the wrapped key and the token); + * 5. fetches the SumSub applicant access token; and + * 6. presents the SDK via the injected launcher. + * + * If session creation reports the applicant is already approved on the relay + * while the vendor is still finalizing (`kycStatus: approved`, + * `finalStatus: pending`), the sub-flow stops at step 4 with a + * `vendorProcessing` status and a message rather than launching the SDK. + * + * @param params - Optional parameters. + * @param params.locale - BCP-47 locale for the SDK UI. + * @param params.debug - Enables SDK debug logging. + * @returns The SDK result. + */ + async startSumSub(params?: { + locale?: string; + debug?: boolean; + }): Promise> { + // A new sub-flow supersedes any polling still running from a prior run. + this.#stopPolling(); + + if (!this.#sumsubLauncher.isAvailable()) { + const error = 'SumSub SDK is not available in this runtime.'; + this.#applyUpdate((state) => { + state.sumsub.status = 'failed'; + state.sumsub.result = { error }; + }); + throw new Error(error); + } + + // Capture the flow generation so each async step can detect a `reset()` + // that lands mid-flight and avoid writing stale sub-flow state (or, worse, + // presenting the SDK) on a controller that is now idle. + const generation = this.#generation; + + try { + this.#applyUpdate((state) => { + state.sumsub.status = 'creatingSession'; + state.sumsub.result = null; + state.sumsub.sessionStatus = null; + }); + + const jwtToken = MOCK_JWT_TOKEN; + + // Establish a per-session X25519 keypair and exchange our public half for + // the server's wrapping key. The private half stays on the device and is + // used to derive the shared secret that seals the data_encryption_key. + const sessionClientPrivateKey = x25519.utils.randomSecretKey(); + const sessionClientPublicKey = x25519.getPublicKey( + sessionClientPrivateKey, + ); + const wrappingKey = await this.messenger.call( + 'KycService:getWrappingKey', + { sessionClientPublicKey: toBase64Url(sessionClientPublicKey) }, + ); + + // Verify the jwtChain against Fractal's JWKS, then confirm the returned + // sessionServerPublicKey matches the value attested inside the verified + // JWT payload before trusting it for key wrapping. + const { keys } = await this.messenger.call('KycService:fetchJwks'); + const jwtChainPayload = verifyJwtChain(keys, wrappingKey.jwtChain); + if ( + jwtChainPayload.sessionServerPublicKeyX !== + wrappingKey.sessionServerPublicKey.x + ) { + throw new Error( + 'sessionServerPublicKey does not match the verified jwtChain payload (sessionServerPublicKeyX).', + ); + } + + // Derive the data_encryption_key from the local_user_secret and wrap it + // for the session server. Only the wrapped (encrypted) key ever leaves + // the device. + const localUserSecret = await getOrCreateLocalUserSecret( + this.#localUserSecretStore(), + ); + const clientMaterial = deriveClientMaterial(localUserSecret); + const wrappedEncryptionKey = { + sessionId: wrappingKey.id, + ...wrapEncryptionKey( + sessionClientPrivateKey, + wrappingKey.sessionServerPublicKey.x, + clientMaterial.dataEncryptionKey, + ), + }; + + // Mint a read-only `ukyc_capability_token` for the session. Only the + // client holds the signing key derived from `local_user_secret`, so only + // the client can mint it; scoping it to `read` means it authorizes later + // storage reads without granting write or delete access. + const ukycCapabilityToken = signStorageAccessToken({ + material: clientMaterial, + operations: ['read'], + expiresAt: new Date(Date.now() + UKYC_CAPABILITY_TOKEN_TTL_MS), + }); + + const { sessionId, kycStatus, finalStatus } = await this.messenger.call( + 'KycService:createUkycSession', + { + jwtToken, + vendorMetadata: { + moonPayAccessToken: this.state.accessToken, + moonPayUserId: this.state.moonpayCustomerId, + }, + wrappedEncryptionKey, + ukycCapabilityToken, + }, + ); + + // A user who already finished the journey can return to a session the + // relay has already approved (`kycStatus`) while the vendor is still + // finalizing its own decision (`finalStatus`). There is nothing left to + // verify, so stop here and surface a message rather than launching the + // SDK again. + if ( + kycStatus === KYC_STATUSES.approved && + finalStatus === KYC_STATUSES.pending + ) { + const stillCurrent = this.#updateIfCurrent(generation, (state) => { + state.sumsub.status = 'vendorProcessing'; + state.sumsub.sessionId = sessionId; + state.statusMessage = VENDOR_PROCESSING_MESSAGE; + }); + return stillCurrent ? { kycStatus, finalStatus } : {}; + } + + this.#updateIfCurrent(generation, (state) => { + state.sumsub.status = 'fetchingToken'; + state.sumsub.sessionId = sessionId; + }); + + const { applicantAccessToken } = await this.messenger.call( + 'KycService:createJourney', + sessionId, + ); + + // A reset() may have landed while the session/token was being prepared. + // Gate the `launching` write and the decision to open the SDK behind a + // single generation check: `#updateIfCurrent` only writes when still + // current and reports whether it did. Since there is no `await` between + // this check and `launch` below, a successful result guarantees the SDK + // is never presented on a flow that a concurrent reset() returned to idle. + const stillCurrent = this.#updateIfCurrent(generation, (state) => { + state.sumsub.status = 'launching'; + state.sumsub.applicantAccessToken = applicantAccessToken; + }); + if (!stillCurrent) { + return {}; + } + + // Track whether the SDK ever reported a successful completion. A resolved + // `launch` alone does not imply success — the applicant may have + // abandoned the flow or the SDK may have reported a non-success outcome. + let reachedCompletion = false; + + const result = await this.#sumsubLauncher.launch({ + applicantAccessToken, + onTokenExpiration: async () => { + // A reset() may have superseded this flow while the SDK stayed open. + // Refuse to refresh against the now-stale UKYC session rather than + // silently keeping an orphaned SDK alive. + if (this.#generation !== generation) { + throw new Error( + 'KYC flow was reset; SumSub session is no longer active.', + ); + } + const refreshed = await this.messenger.call( + 'KycService:createJourney', + sessionId, + ); + return refreshed.applicantAccessToken; + }, + onStatusChange: (_prev, next) => { + if (next === SUMSUB_COMPLETED_STATUS) { + reachedCompletion = true; + } + this.#updateIfCurrent(generation, (state) => { + state.sumsub.status = + next === SUMSUB_COMPLETED_STATUS ? 'complete' : 'inProgress'; + }); + }, + locale: params?.locale ?? 'en', + debug: params?.debug ?? false, + }); + + // A resolved `launch` alone is not the final outcome: only a SDK-reported + // completion is worth polling for a verification decision. Anything else + // (abandonment, non-success) is `failed` and must not be polled. + const applied = this.#updateIfCurrent(generation, (state) => { + state.sumsub.status = reachedCompletion ? 'polling' : 'failed'; + state.sumsub.result = result as Json; + }); + + // Once the SDK completes, the authoritative verification decision comes + // from the UKYC backend, not the SDK result. Poll the session status + // until it reaches a terminal decision. Guard on `applied` so a `reset()` + // that landed during `launch` cannot start polling on an idle flow. + if (applied && reachedCompletion) { + if (sessionId) { + await this.#startSessionStatusPolling(sessionId); + } else { + // No session id to poll against; fall back to treating the SDK + // completion as the final outcome. + this.#updateIfCurrent(generation, (state) => { + state.sumsub.status = 'complete'; + }); + } + } + return result; + } catch (error) { + const result = { error: String(error) }; + this.#updateIfCurrent(generation, (state) => { + state.sumsub.status = 'failed'; + state.sumsub.result = result; + }); + return result; + } + } + + /** + * Fetches the current UKYC session status for the active sub-flow and records + * it on state. Useful for a one-off refresh outside the automatic polling + * loop that {@link startSumSub} runs. + * + * @returns The fetched session status. + * @throws If there is no active SumSub session to query. + */ + async getSessionStatus(): Promise { + const { sessionId } = this.state.sumsub; + if (!sessionId) { + throw new Error('Cannot fetch session status: no active SumSub session.'); + } + + // Capture the flow generation so a `reset()` landing while the request is + // in flight cannot write the result onto an idle controller. + const generation = this.#generation; + const sessionStatus = await this.messenger.call( + 'KycService:getSessionStatus', + { sessionId }, + ); + this.#updateIfCurrent(generation, (state) => { + state.sumsub.sessionStatus = sessionStatus; + }); + return sessionStatus; + } + + /** + * Begins polling the UKYC session status until a terminal decision is + * reached. The first poll runs immediately (and is awaited by + * {@link startSumSub}); subsequent polls are scheduled every + * `#sessionStatusPollIntervalMs`. + * + * @param sessionId - The UKYC session id to poll. + * @returns A promise that resolves once the first poll settles. + */ + async #startSessionStatusPolling(sessionId: string): Promise { + // Supersede any prior loop and claim a fresh token for this one. Because + // `#stopPolling` bumps the token, any in-flight poll from a previous loop + // sees a mismatch and neither writes state nor reschedules. + this.#stopPolling(); + const token = this.#pollToken; + + const tick = async (): Promise => { + const shouldStop = await this.#pollSessionStatusOnce(sessionId, token); + if (shouldStop) { + return; + } + this.#pollTimer = setTimeout(() => { + this.#pollTimer = null; + // `tick` swallows its own errors (see `#pollSessionStatusOnce`) and + // therefore never rejects, so this fire-and-forget scheduled poll + // cannot surface as an unhandled rejection. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + tick(); + }, this.#sessionStatusPollIntervalMs); + }; + + await tick(); + } + + /** + * Performs a single session-status poll: fetches the status, records it, and + * resolves the sub-flow when the status is terminal. + * + * Transient errors are swallowed so the loop keeps polling; the last good + * `sessionStatus` is deliberately preserved rather than being overwritten + * with the error. + * + * @param sessionId - The UKYC session id to poll. + * @param token - The polling token captured when the loop started. + * @returns `true` when the loop should stop (terminal status or superseded + * by a reset / new sub-flow), `false` when it should keep polling. + */ + async #pollSessionStatusOnce( + sessionId: string, + token: number, + ): Promise { + try { + const sessionStatus = await this.messenger.call( + 'KycService:getSessionStatus', + { sessionId }, + ); + // Superseded while the request was in flight — drop the result. + if (this.#pollToken !== token) { + return true; + } + const isTerminal = TERMINAL_SESSION_STATUSES.has( + sessionStatus.finalStatus, + ); + this.#applyUpdate((state) => { + state.sumsub.sessionStatus = sessionStatus; + if (isTerminal) { + state.sumsub.status = SUCCESSFUL_SESSION_STATUSES.has( + sessionStatus.finalStatus, + ) + ? 'complete' + : 'failed'; + } + }); + if (isTerminal) { + this.#stopPolling(); + } + return isTerminal; + } catch { + // Keep polling on transient errors, preserving the last good status. + // Stop only when a reset / new sub-flow superseded this loop. + return this.#pollToken !== token; + } + } + + /** + * Stops the session-status polling loop: bumps the polling token (so any + * in-flight `tick` bows out) and clears any scheduled poll. + */ + #stopPolling(): void { + this.#pollToken += 1; + if (this.#pollTimer !== null) { + clearTimeout(this.#pollTimer); + this.#pollTimer = null; + } + } + + /** + * Registers a Money Account wallet with MoonPay Iron. + * + * Consumers provide only the Monad address. The controller reuses the Iron + * customer id captured from MoonPay's hosted frame when available, otherwise + * it resolves the id from the authenticated MetaMask profile via KycService. + * Message construction, signing, submission, and ambiguous-write + * reconciliation stay internal to KYC. + * + * @param params - Money Account wallet registration parameters. + * @param params.address - Monad Money Account address. + * @returns The successful registration state. + */ + async registerMoneyAccountWallet({ + address, + }: { + address: Hex; + }): Promise { + let machine = transitionWalletRegistration(createInitialState(), { + type: 'START', + }); + + const toExistingResult = ( + status: RegistrationStatus, + ): MoneyAccountWalletRegistrationResult | undefined => { + if (status.type === 'active') { + return { type: 'alreadyRegistered', registration: status.registration }; + } + if (status.type === 'disabled') { + return { + type: 'registeredDisabled', + registration: status.registration, + }; + } + return undefined; + }; + + const lookup = async (): Promise => { + try { + return await this.messenger.call( + 'KycService:getWalletRegistrationStatus', + { address }, + ); + } catch (error) { + machine = transitionWalletRegistration(machine, { + type: 'LOOKUP_FAILED', + }); + throw error; + } + }; + + const applyLookup = ( + status: RegistrationStatus, + ): MoneyAccountWalletRegistrationResult | undefined => { + let eventType: 'LOOKUP_ACTIVE' | 'LOOKUP_DISABLED' | 'LOOKUP_ABSENT' = + 'LOOKUP_ABSENT'; + if (status.type === 'active') { + eventType = 'LOOKUP_ACTIVE'; + } else if (status.type === 'disabled') { + eventType = 'LOOKUP_DISABLED'; + } + machine = transitionWalletRegistration(machine, { + type: eventType, + }); + return toExistingResult(status); + }; + + const existingStatus = await lookup(); + const existingResult = applyLookup(existingStatus); + if (existingResult) { + return existingResult; + } + + const customerId = + this.state.moonpayCustomerId ?? + (await this.messenger.call('KycService:getMoonpayCustomerId')); + + while (true) { + const message = buildOwnershipMessage({ + address, + customerId, + now: new Date(), + }); + let signature: string; + try { + signature = await this.messenger.call( + 'KeyringController:signPersonalMessage', + { data: message, from: address }, + ); + machine = transitionWalletRegistration(machine, { type: 'SIGN_OK' }); + } catch (error) { + machine = transitionWalletRegistration(machine, { + type: 'SIGN_FAILED', + retryable: false, + }); + throw error; + } + + try { + const result = await this.messenger.call( + 'KycService:registerSelfHostedWallet', + { + address, + customerId, + message, + signature, + }, + ); + machine = transitionWalletRegistration(machine, { type: 'SUBMIT_OK' }); + return result; + } catch (error) { + if (!(error instanceof WalletRegistrationError)) { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_TERMINAL', + }); + throw error; + } + + if (error.kind === 'conflict') { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_CONFLICT', + }); + } else if (error.kind === 'transient') { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_TRANSIENT', + }); + } else if (error.kind === 'validation') { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_VALIDATION', + utcRollover: + buildOwnershipMessage({ + address, + customerId, + now: new Date(), + }) !== message, + }); + } else if (error.kind === 'rateLimited') { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_RATE_LIMITED', + }); + } else { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_TERMINAL', + }); + } + + if ( + machine.status === 'disambiguate409' || + machine.status === 'checkThenRetry' + ) { + const reconciledResult = applyLookup(await lookup()); + if (reconciledResult) { + return reconciledResult; + } + } + + if (machine.status !== 'signing') { + throw error; + } + } + } + } + + /** + * Resets the flow to idle, clearing session tokens and sub-flow state while + * preserving persisted terms acceptance and the per-product cache. + */ + reset(): void { + this.#authClientToken = null; + // Stop any session-status polling so a late poll cannot write onto the + // now-idle controller. + this.#stopPolling(); + // Invalidate any in-flight async work started before this reset so its + // results are discarded rather than written onto the now-idle controller. + this.#generation += 1; + this.#applyUpdate((state) => { + state.phase = 'idle'; + state.statusMessage = ''; + state.error = null; + state.disclaimers = []; + state.disclaimersError = null; + state.sessionToken = null; + state.accessToken = null; + state.moonpayCustomerId = null; + state.activeProduct = null; + state.sumsub = { + status: 'idle', + result: null, + sessionId: null, + applicantAccessToken: null, + sessionStatus: null, + }; + }); + } + + /** + * Applies a state update only when the flow has not been reset since + * `generation` was captured. Prevents an in-flight async step from writing + * stale results onto a controller that a concurrent {@link reset} has + * returned to idle. + * + * @param generation - The flow generation captured before the async work. + * @param updater - The state mutation to apply when still current. + * @returns `true` if the update was applied, `false` if it was superseded. + */ + #updateIfCurrent( + generation: number, + updater: (state: KycControllerState) => void, + ): boolean { + if (this.#generation !== generation) { + return false; + } + this.#applyUpdate(updater); + return true; + } + + /** + * The single state-update path for this controller. All mutations go through + * here (rather than calling `this.update` directly) so the mechanism stays + * consistent and one subtlety is handled in a single place: + * + * `sumsub.result` is typed as the recursive `Json`, and expanding + * `Draft` (which happens whenever an updater touches `sumsub.result`) + * can trip TypeScript's "type instantiation is excessively deep" guard. By + * typing the callback parameter as the plain {@link KycControllerState} + * instead of Immer's `Draft`, we avoid expanding the draft type while keeping + * the same mutate-in-place semantics (the underlying value is still the Immer + * draft at runtime). + * + * @param updater - The state mutation to apply. + */ + #applyUpdate(updater: (state: KycControllerState) => void): void { + this.update((state) => { + // `@ts-expect-error` cannot be used: ts-bridge does not surface + // TS2589, so the directive is unused and fails the build. + // type issue only happens at the IDE level. + updater(state); + }); + } + + /** + * Transitions to the error phase with a message. + * + * @param message - The error message. + */ + #fail(message: string): void { + this.#applyUpdate((state) => { + state.error = message; + state.phase = 'error'; + }); + } +} diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts new file mode 100644 index 00000000000..344ae092e5c --- /dev/null +++ b/packages/kyc-controller/src/KycService-method-action-types.ts @@ -0,0 +1,177 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { KycService } from './KycService.js'; + +/** + * Resolves the customer's country from the geolocation source and converts it + * to an ISO 3166-1 alpha-3 code. + * + * @returns The alpha-3 country code. + * @throws If the country cannot be determined or mapped. + */ +export type KycServiceGetGeoCountryAction = { + type: `KycService:getGeoCountry`; + handler: KycService['getGeoCountry']; +}; + +/** + * Resolves Iron's internal customer id from the authenticated MetaMask + * profile. + * + * @returns Iron's internal customer id. + */ +export type KycServiceGetMoonpayCustomerIdAction = { + type: `KycService:getMoonpayCustomerId`; + handler: KycService['getMoonpayCustomerId']; +}; + +/** + * Checks whether a Monad Money Account address is already registered. + * + * @param params - The address to check. + * @param params.address - Money Account address. + * @returns Active, disabled, or absent registration status. + */ +export type KycServiceGetWalletRegistrationStatusAction = { + type: `KycService:getWalletRegistrationStatus`; + handler: KycService['getWalletRegistrationStatus']; +}; + +/** + * Submits a signed Monad Money Account ownership proof. + * + * @param params - Signed ownership proof. + * @returns Registered wallet record. + */ +export type KycServiceRegisterSelfHostedWalletAction = { + type: `KycService:registerSelfHostedWallet`; + handler: KycService['registerSelfHostedWallet']; +}; + +/** + * Fetches the disclaimers the customer must accept before a session is + * created. + * + * @param params - The parameters. + * @param params.country - ISO 3166-1 alpha-3 country code. + * @returns The disclaimers. + */ +export type KycServiceFetchDisclaimersAction = { + type: `KycService:fetchDisclaimers`; + handler: KycService['fetchDisclaimers']; +}; + +/** + * Creates a vendor session via the UKYC backend. + * + * @param params - The session parameters. + * @returns The created session token. + */ +export type KycServiceCreateSessionAction = { + type: `KycService:createSession`; + handler: KycService['createSession']; +}; + +/** + * Checks whether KYC is required for the given access token, country, and + * capabilities. + * + * @param params - The check parameters. + * @returns Whether KYC is required. + */ +export type KycServiceCheckKycRequiredAction = { + type: `KycService:checkKycRequired`; + handler: KycService['checkKycRequired']; +}; + +/** + * Requests a per-session wrapping key from the UKYC backend. + * + * The client sends its ephemeral X25519 public key; the backend responds with + * its session public key (`sessionServerPublicKey`) and a `jwtChain` that + * attests it. The caller must verify `jwtChain` against the Fractal JWKS + * (see {@link KycService.fetchJwks}) before trusting the key to wrap the + * `data_encryption_key`. + * + * @param params - The parameters. + * @param params.sessionClientPublicKey - Our ephemeral X25519 public key + * (base64url). + * @returns The wrapping key id, `jwtChain`, and session server public key. + */ +export type KycServiceGetWrappingKeyAction = { + type: `KycService:getWrappingKey`; + handler: KycService['getWrappingKey']; +}; + +/** + * Fetches the Fractal encryption service JWKS used to verify the `jwtChain` + * returned by {@link KycService.getWrappingKey}. + * + * This is an unauthenticated request to a well-known path on the Fractal + * host, distinct from the UKYC base URL. + * + * @returns The JWKS keys. + */ +export type KycServiceFetchJwksAction = { + type: `KycService:fetchJwks`; + handler: KycService['fetchJwks']; +}; + +/** + * Creates a UKYC session for the SumSub document-verification sub-flow, + * handing over the wrapped `data_encryption_key` and the client-signed, + * read-only `ukyc_capability_token` that authorizes later storage access for + * the session. + * + * @param params - The session parameters. + * @returns The UKYC session identifiers. + */ +export type KycServiceCreateUkycSessionAction = { + type: `KycService:createUkycSession`; + handler: KycService['createUkycSession']; +}; + +/** + * Creates (or refreshes) the SumSub verification journey for a UKYC session, + * returning the applicant access token used to launch the SDK. + * + * @param sessionId - The UKYC session id from `createUkycSession`. + * @returns The applicant access token and status. + */ +export type KycServiceCreateJourneyAction = { + type: `KycService:createJourney`; + handler: KycService['createJourney']; +}; + +/** + * Fetches the current status of a UKYC session. Polled after the SumSub SDK + * completes to determine the final verification decision. + * + * @param params - The parameters. + * @param params.sessionId - The UKYC session id. + * @returns The session status. + */ +export type KycServiceGetSessionStatusAction = { + type: `KycService:getSessionStatus`; + handler: KycService['getSessionStatus']; +}; + +/** + * Union of all KycService action types. + */ +export type KycServiceMethodActions = + | KycServiceGetGeoCountryAction + | KycServiceGetMoonpayCustomerIdAction + | KycServiceGetWalletRegistrationStatusAction + | KycServiceRegisterSelfHostedWalletAction + | KycServiceFetchDisclaimersAction + | KycServiceCreateSessionAction + | KycServiceCheckKycRequiredAction + | KycServiceGetWrappingKeyAction + | KycServiceFetchJwksAction + | KycServiceCreateUkycSessionAction + | KycServiceCreateJourneyAction + | KycServiceGetSessionStatusAction; diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts new file mode 100644 index 00000000000..b537d96fa0a --- /dev/null +++ b/packages/kyc-controller/src/KycService.test.ts @@ -0,0 +1,595 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import nock, { cleanAll } from 'nock'; + +import type { KycServiceMessenger } from './KycService.js'; +import { KycService } from './KycService.js'; +import { UKYC_LOCAL_USER_SECRET_SIZE_BYTES } from './ukyc/constants.js'; +import { deriveClientMaterial } from './ukyc/deriveClientMaterial.js'; +import { + encodeStorageAccessTokenForHeader, + signStorageAccessToken, +} from './ukyc/storageAccessToken.js'; + +const MOCK_API_URL = 'https://kyc-api.dev-api.cx.metamask.io'; +const MOCK_FRACTAL_URL = 'https://fractal.dev-api.cx.metamask.io'; + +describe('KycService', () => { + afterEach(() => { + cleanAll(); + }); + + describe('getGeoCountry', () => { + it('maps the geolocation to an ISO alpha-3 country code', async () => { + const { service } = getService({ geolocation: 'US-NY' }); + expect(await service.getGeoCountry()).toBe('USA'); + }); + + it('throws when the location is unknown', async () => { + const { service } = getService({ geolocation: 'UNKNOWN' }); + await expect(service.getGeoCountry()).rejects.toThrow( + /Unable to determine country/u, + ); + }); + + it('throws when the country cannot be mapped to alpha-3', async () => { + const { service } = getService({ geolocation: 'ZZ' }); + await expect(service.getGeoCountry()).rejects.toThrow( + /Unable to map country code "ZZ"/u, + ); + }); + + it('throws when the location resolves to a nullish value', async () => { + const { service } = getService({ geolocation: null }); + await expect(service.getGeoCountry()).rejects.toThrow( + /Unable to determine country/u, + ); + }); + + it('constructs with the default service policy options', async () => { + const { service } = getService({ + defaultPolicy: true, + geolocation: 'US', + }); + expect(await service.getGeoCountry()).toBe('USA'); + }); + }); + + describe('Money Account wallet registration', () => { + it('resolves the Iron customer id', async () => { + nock(MOCK_API_URL) + .get('/vendors/moonpay/customer') + .matchHeader('authorization', 'Bearer test-bearer') + .reply(200, { customerId: 'iron-customer-1' }); + + const { service } = getService(); + + expect(await service.getMoonpayCustomerId()).toBe('iron-customer-1'); + }); + + it('checks Monad wallet registration status', async () => { + nock(MOCK_API_URL) + .get('/vendors/moonpay/self-hosted-wallets') + .reply(200, []); + + const { service } = getService(); + + expect( + await service.getWalletRegistrationStatus({ address: '0xabc' }), + ).toStrictEqual({ type: 'absent' }); + }); + + it('submits a signed Monad wallet ownership proof', async () => { + nock(MOCK_API_URL) + .post('/vendors/moonpay/self-hosted-wallets', { + customer_id: 'iron-customer-1', + address: '0xabc', + blockchain: 'Monad', + message: 'ownership message', + signature: '0xsig', + }) + .reply(200, { + id: 'wallet-1', + address: '0xabc', + disabled: false, + }); + + const { service } = getService(); + + expect( + await service.registerSelfHostedWallet({ + customerId: 'iron-customer-1', + address: '0xabc', + message: 'ownership message', + signature: '0xsig', + }), + ).toMatchObject({ + type: 'registered', + registration: { id: 'wallet-1', blockchain: 'Monad' }, + }); + }); + }); + + describe('fetchDisclaimers', () => { + it('returns the disclaimers for a country', async () => { + const disclaimers = [ + { id: '1', display_name: 'Terms', url: 'https://t' }, + ]; + nock(MOCK_API_URL) + .get('/vendors/moonpay/disclaimers') + .query({ country: 'USA' }) + .reply(200, disclaimers); + const { service } = getService(); + + expect(await service.fetchDisclaimers({ country: 'USA' })).toStrictEqual( + disclaimers, + ); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL) + .get('/vendors/moonpay/disclaimers') + .query({ country: 'USA' }) + .reply(200, [{ id: 1 }]); + const { service } = getService(); + + await expect( + service.fetchDisclaimers({ country: 'USA' }), + ).rejects.toThrow(/Malformed response received from disclaimers API/u); + }); + + it('throws when no bearer token is available', async () => { + const { service } = getService({ bearerToken: '' }); + await expect( + service.fetchDisclaimers({ country: 'USA' }), + ).rejects.toThrow(/Unable to obtain an authentication bearer token/u); + }); + + it('throws an HttpError on a non-ok response', async () => { + nock(MOCK_API_URL) + .get('/vendors/moonpay/disclaimers') + .query({ country: 'USA' }) + .reply(500); + const { service } = getService(); + + await expect( + service.fetchDisclaimers({ country: 'USA' }), + ).rejects.toThrow(/failed with status '500'/u); + }); + }); + + describe('createSession', () => { + it('creates a session and returns the token', async () => { + nock(MOCK_API_URL) + .post('/vendors/moonpay/sessions') + .reply(200, { sessionToken: 'session-1' }); + const { service } = getService(); + + expect( + await service.createSession({ + email: 'a@b.co', + termsAcceptedAt: '2026-01-01T00:00:00.000Z', + disclaimerIds: ['1'], + }), + ).toStrictEqual({ sessionToken: 'session-1' }); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL).post('/vendors/moonpay/sessions').reply(200, {}); + const { service } = getService(); + + await expect( + service.createSession({ + email: 'a@b.co', + termsAcceptedAt: '2026-01-01T00:00:00.000Z', + disclaimerIds: ['1'], + }), + ).rejects.toThrow(/Malformed response received from sessions API/u); + }); + }); + + describe('checkKycRequired', () => { + it('returns whether KYC is required (default capabilities)', async () => { + nock(MOCK_API_URL) + .post('/vendors/moonpay/kyc-required', { + accessToken: 'access-1', + country: 'USA', + capabilities: [{ product: 'ramps' }], + }) + .reply(200, { required: true }); + const { service } = getService(); + + expect( + await service.checkKycRequired({ + accessToken: 'access-1', + country: 'USA', + }), + ).toStrictEqual({ kycRequired: true }); + }); + + it('passes provided capabilities', async () => { + nock(MOCK_API_URL) + .post('/vendors/moonpay/kyc-required', { + accessToken: 'access-1', + country: 'USA', + capabilities: [{ product: 'card' }], + }) + .reply(200, { required: false }); + const { service } = getService(); + + expect( + await service.checkKycRequired({ + accessToken: 'access-1', + country: 'USA', + capabilities: [{ product: 'card' }], + }), + ).toStrictEqual({ kycRequired: false }); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL).post('/vendors/moonpay/kyc-required').reply(200, {}); + const { service } = getService(); + + await expect( + service.checkKycRequired({ accessToken: 'access-1', country: 'USA' }), + ).rejects.toThrow(/Malformed response received from kyc-required API/u); + }); + + it('surfaces the specific field mismatch and payload in the error', async () => { + nock(MOCK_API_URL) + .post('/vendors/moonpay/kyc-required') + .reply(200, { required: 'yes' }); + const { service } = getService(); + + await expect( + service.checkKycRequired({ accessToken: 'access-1', country: 'USA' }), + ).rejects.toThrow( + /Malformed response received from kyc-required API:.*required.*received: \{"required":"yes"\}/su, + ); + }); + }); + + describe('getWrappingKey', () => { + it('requests a wrapping key and returns the attested server key', async () => { + const response = { + id: 'wk', + jwtChain: 'jwt.chain.sig', + sessionServerPublicKey: { kty: 'OKP', crv: 'X25519', x: 'spk-x' }, + }; + nock(MOCK_API_URL) + .post('/wrapping-key', { sessionClientPublicKey: 'cpk' }) + .reply(200, response); + const { service } = getService(); + + expect( + await service.getWrappingKey({ sessionClientPublicKey: 'cpk' }), + ).toStrictEqual(response); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL).post('/wrapping-key').reply(200, { id: 'wk' }); + const { service } = getService(); + + await expect( + service.getWrappingKey({ sessionClientPublicKey: 'cpk' }), + ).rejects.toThrow(/Malformed response received from wrapping-key API/u); + }); + }); + + describe('fetchJwks', () => { + it('fetches the JWKS from the Fractal well-known path', async () => { + const response = { + keys: [{ kty: 'OKP', crv: 'Ed25519', x: 'pub', kid: 'k1' }], + }; + nock(MOCK_FRACTAL_URL).get('/.well-known/jwks.json').reply(200, response); + const { service } = getService(); + + expect(await service.fetchJwks()).toStrictEqual(response); + }); + + it('throws when no Fractal base URL is configured', async () => { + // Omit the option entirely so the constructor falls back to ''. + const { service } = getService({ fractalEncryptionBaseUrl: null }); + + await expect(service.fetchJwks()).rejects.toThrow( + /fractalEncryptionBaseUrl is not configured/u, + ); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_FRACTAL_URL) + .get('/.well-known/jwks.json') + .reply(200, { keys: [{ kty: 'OKP' }] }); + const { service } = getService(); + + await expect(service.fetchJwks()).rejects.toThrow( + /Malformed response received from JWKS API/u, + ); + }); + }); + + describe('createUkycSession', () => { + const wrappedEncryptionKey = { + sessionId: 'wk', + encryptedKey: 'enc', + nonce: 'nonce', + }; + + // A genuinely signed, read-only capability token: minted from real derived + // client material and signed with the Ed25519 `signingKey`, not a + // hand-written plain object. + const material = deriveClientMaterial( + new Uint8Array(UKYC_LOCAL_USER_SECRET_SIZE_BYTES).fill(42), + ); + const ukycCapabilityToken = signStorageAccessToken({ + material, + operations: ['read'], + issuedAt: new Date('2026-07-07T00:00:00.000Z'), + expiresAt: new Date('2026-07-07T04:00:00.000Z'), + }); + // The service base64url-encodes the envelope into a compact string before + // sending it in the request body. + const encodedCapabilityToken = + encodeStorageAccessTokenForHeader(ukycCapabilityToken); + + it('creates a UKYC session and forwards the wrapped key and capability token', async () => { + const response = { + sessionId: 'sid', + }; + nock(MOCK_API_URL) + .post( + '/sessions', + (body) => + body.wrappedEncryptionKey !== undefined && + body.ukycCapabilityToken === encodedCapabilityToken, + ) + .reply(200, response); + const { service } = getService(); + + expect( + await service.createUkycSession({ + jwtToken: 'jwt', + vendorMetadata: { foo: 'bar' }, + wrappedEncryptionKey, + ukycCapabilityToken, + }), + ).toStrictEqual(response); + }); + + it('returns the relay and vendor statuses when present', async () => { + const response = { + sessionId: 'sid', + kycStatus: 'approved', + finalStatus: 'pending', + }; + nock(MOCK_API_URL).post('/sessions').reply(200, response); + const { service } = getService(); + + expect( + await service.createUkycSession({ + jwtToken: 'jwt', + vendorMetadata: { foo: 'bar' }, + wrappedEncryptionKey, + ukycCapabilityToken, + }), + ).toStrictEqual(response); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL).post('/sessions').reply(200, { unexpected: true }); + const { service } = getService(); + + await expect( + service.createUkycSession({ + jwtToken: 'jwt', + vendorMetadata: {}, + wrappedEncryptionKey, + ukycCapabilityToken, + }), + ).rejects.toThrow(/Malformed response received from UKYC sessions API/u); + }); + }); + + describe('createJourney', () => { + it('fetches the applicant access token for a session', async () => { + const response = { status: 'ok', applicantAccessToken: 'aat' }; + nock(MOCK_API_URL).post('/sessions/sid/journey').reply(200, response); + const { service } = getService(); + + expect(await service.createJourney('sid')).toStrictEqual(response); + }); + + it('does not send a Content-Type header since it has no body', async () => { + const response = { status: 'ok', applicantAccessToken: 'aat' }; + nock(MOCK_API_URL) + .post('/sessions/sid/journey') + .matchHeader('content-type', (value) => value === undefined) + .reply(200, response); + const { service } = getService(); + + expect(await service.createJourney('sid')).toStrictEqual(response); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL) + .post('/sessions/sid/journey') + .reply(200, { status: 'ok' }); + const { service } = getService(); + + await expect(service.createJourney('sid')).rejects.toThrow( + /Malformed response received from journey API/u, + ); + }); + }); + + describe('getSessionStatus', () => { + it('returns the session status', async () => { + const response = { + finalStatus: 'approved', + statusMessage: 'All good', + externalUserId: 'ext-1', + kycStatus: 'approved', + vendor: 'sumsub', + vendorStatus: 'GREEN', + }; + nock(MOCK_API_URL).get('/sessions/sid/status').reply(200, response); + const { service } = getService(); + + expect( + await service.getSessionStatus({ sessionId: 'sid' }), + ).toStrictEqual(response); + }); + + it('url-encodes the session id', async () => { + const response = { + finalStatus: 'pending', + externalUserId: 'ext-1', + kycStatus: 'pending', + vendor: 'sumsub', + vendorStatus: 'YELLOW', + }; + nock(MOCK_API_URL).get('/sessions/a%2Fb/status').reply(200, response); + const { service } = getService(); + + expect( + await service.getSessionStatus({ sessionId: 'a/b' }), + ).toStrictEqual(response); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL) + .get('/sessions/sid/status') + .reply(200, { finalStatus: 'approved' }); + const { service } = getService(); + + await expect( + service.getSessionStatus({ sessionId: 'sid' }), + ).rejects.toThrow(/Malformed response received from session status API/u); + }); + + it('throws an HttpError on a non-ok response', async () => { + nock(MOCK_API_URL).get('/sessions/sid/status').reply(404); + const { service } = getService(); + + await expect( + service.getSessionStatus({ sessionId: 'sid' }), + ).rejects.toThrow(/failed with status '404'/u); + }); + }); + + describe('baseUrl', () => { + it('uses the provided baseUrl for requests', async () => { + const customUrl = 'https://kyc-api.local.test'; + const disclaimers = [ + { id: '1', display_name: 'Terms', url: 'https://t' }, + ]; + nock(customUrl) + .get('/vendors/moonpay/disclaimers') + .query({ country: 'USA' }) + .reply(200, disclaimers); + const { service } = getService({ baseUrl: customUrl }); + + expect(await service.fetchDisclaimers({ country: 'USA' })).toStrictEqual( + disclaimers, + ); + }); + + it('throws when baseUrl is empty', () => { + expect(() => getService({ baseUrl: '' })).toThrow( + 'KycService: baseUrl is required', + ); + }); + }); + + describe('messenger actions', () => { + it('exposes methods as messenger actions', async () => { + nock(MOCK_API_URL) + .get('/vendors/moonpay/disclaimers') + .query({ country: 'USA' }) + .reply(200, []); + const { rootMessenger } = getService(); + + expect( + await rootMessenger.call('KycService:fetchDisclaimers', { + country: 'USA', + }), + ).toStrictEqual([]); + }); + }); +}); + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +/** + * Constructs the service under test with mocked auth + geo handlers. + * + * @param args - Options. + * @param args.bearerToken - The bearer token the auth handler returns. + * @param args.geolocation - The location the geolocation handler returns. + * @param args.defaultPolicy - When true, omit `policyOptions` to use defaults. + * @param args.baseUrl - Base URL of the KYC API. + * @param args.fractalEncryptionBaseUrl - Fractal base URL; `null` omits the + * option so the service falls back to an empty string. + * @returns The service, root messenger, and service messenger. + */ +function getService({ + bearerToken = 'test-bearer', + geolocation = 'US-NY', + defaultPolicy = false, + baseUrl = MOCK_API_URL, + // `null` means "omit the option entirely" (exercises the constructor's + // `?? ''` fallback); omitting the field defaults to the mock Fractal URL. + fractalEncryptionBaseUrl = MOCK_FRACTAL_URL, +}: { + bearerToken?: string; + geolocation?: string | null; + defaultPolicy?: boolean; + baseUrl?: string; + fractalEncryptionBaseUrl?: string | null; +} = {}): { + service: KycService; + rootMessenger: RootMessenger; + messenger: KycServiceMessenger; +} { + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + const messenger: KycServiceMessenger = new Messenger({ + namespace: 'KycService', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: [ + 'AuthenticationController:getBearerToken', + 'GeolocationController:getGeolocation', + ], + events: [], + messenger, + }); + rootMessenger.registerActionHandler( + 'AuthenticationController:getBearerToken', + async () => bearerToken, + ); + rootMessenger.registerActionHandler( + 'GeolocationController:getGeolocation', + async () => geolocation as string, + ); + + const service = new KycService({ + fetch, + messenger, + baseUrl, + ...(fractalEncryptionBaseUrl === null ? {} : { fractalEncryptionBaseUrl }), + ...(defaultPolicy ? {} : { policyOptions: { maxRetries: 0 } }), + }); + + return { service, rootMessenger, messenger }; +} diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts new file mode 100644 index 00000000000..1674a0d3cbf --- /dev/null +++ b/packages/kyc-controller/src/KycService.ts @@ -0,0 +1,779 @@ +import { BaseDataService } from '@metamask/base-data-service'; +import type { + DataServiceCacheUpdatedEvent, + DataServiceGranularCacheUpdatedEvent, + DataServiceInvalidateQueriesAction, +} from '@metamask/base-data-service'; +import type { CreateServicePolicyOptions } from '@metamask/controller-utils'; +import { HttpError } from '@metamask/controller-utils'; +import type { GeolocationControllerGetGeolocationAction } from '@metamask/geolocation-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { AuthenticationControllerGetBearerTokenAction } from '@metamask/profile-sync-controller/auth'; +import type { Infer, Struct } from '@metamask/superstruct'; +import { + array, + assert, + boolean, + optional, + string, + StructError, + type, +} from '@metamask/superstruct'; +import type { Json } from '@metamask/utils'; +import { Duration, inMilliseconds } from '@metamask/utils'; +import type { QueryClientConfig } from '@tanstack/query-core'; + +import { alpha2ToAlpha3 } from './countryCodes.js'; +import type { KycServiceMethodActions } from './KycService-method-action-types.js'; +import type { KycDisclaimer, KycSessionStatus } from './types.js'; +import { UKYC_JWKS_PATH } from './ukyc/constants.js'; +import { encodeStorageAccessTokenForHeader } from './ukyc/storageAccessToken.js'; +import type { UkycStorageAccessToken } from './ukyc/storageAccessToken.js'; +import { WalletRegistrationService } from './wallet-registration-service.js'; +import type { + RegistrationOutcome, + RegistrationStatus, +} from './wallet-registration-service.js'; + +// === GENERAL === + +/** + * The name of the {@link KycService}, used to namespace the service's actions. + */ +export const serviceName = 'KycService'; + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = [ + 'getGeoCountry', + 'fetchDisclaimers', + 'createSession', + 'checkKycRequired', + 'getWrappingKey', + 'fetchJwks', + 'createUkycSession', + 'createJourney', + 'getSessionStatus', + 'getMoonpayCustomerId', + 'getWalletRegistrationStatus', + 'registerSelfHostedWallet', +] as const; + +/** + * Invalidates cached queries serviced by {@link KycService}. + */ +export type KycServiceInvalidateQueriesAction = + DataServiceInvalidateQueriesAction; + +/** + * Actions that {@link KycService} exposes to other consumers. + */ +export type KycServiceActions = + | KycServiceMethodActions + | KycServiceInvalidateQueriesAction; + +/** + * Actions from other messengers that {@link KycService} calls. + */ +type AllowedActions = + | AuthenticationControllerGetBearerTokenAction + | GeolocationControllerGetGeolocationAction; + +/** + * Published when {@link KycService}'s cache is updated. + */ +export type KycServiceCacheUpdatedEvent = DataServiceCacheUpdatedEvent< + typeof serviceName +>; + +/** + * Published when a single key within {@link KycService}'s cache is updated. + */ +export type KycServiceGranularCacheUpdatedEvent = + DataServiceGranularCacheUpdatedEvent; + +/** + * Events that {@link KycService} exposes to other consumers. + */ +export type KycServiceEvents = + | KycServiceCacheUpdatedEvent + | KycServiceGranularCacheUpdatedEvent; + +/** + * Events from other messengers that {@link KycService} subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger restricted to actions and events accessed by + * {@link KycService}. + */ +export type KycServiceMessenger = Messenger< + typeof serviceName, + KycServiceActions | AllowedActions, + KycServiceEvents | AllowedEvents +>; + +/** + * Options for constructing a {@link KycService}. + */ +export type KycServiceOptions = { + messenger: KycServiceMessenger; + fetch: typeof fetch; + /** + * Mandatory value that sets the base url to KYC api + */ + baseUrl: string; + /** + * Base URL of the Fractal encryption service, from which the JWKS used to + * verify the `jwtChain` returned by {@link KycService.getWrappingKey} is + * fetched. Required to run the wrapping-key exchange in + * {@link KycService.fetchJwks}. + */ + fractalEncryptionBaseUrl?: string; + /** + * Shared configuration applied to all queries exposed by the service (e.g. a + * default `staleTime`/`cacheTime`). Each data service gets its own + * `QueryClient`. + */ + queryClientConfig?: QueryClientConfig; + policyOptions?: CreateServicePolicyOptions; +}; + +// === API RESPONSE SCHEMAS === + +const DisclaimerStruct = type({ + id: string(), + display_name: string(), + url: string(), +}); +const DisclaimersResponseStruct = array(DisclaimerStruct); + +const CreateSessionResponseStruct = type({ sessionToken: string() }); + +// The live KYC API returns the flag under `required`; the service normalizes +// this to `kycRequired` for consumers (see `checkKycRequired`). +const KycRequiredResponseStruct = type({ required: boolean() }); + +// The session server's X25519 public key, in JWK-like form, returned by +// `/wrapping-key`. `x` is the base64url public key used to wrap the user key. +const SessionServerPublicKeyStruct = type({ + kty: string(), + crv: string(), + x: string(), +}); + +const WrappingKeyResponseStruct = type({ + id: string(), + jwtChain: string(), + sessionServerPublicKey: SessionServerPublicKeyStruct, +}); +export type WrappingKeyResponse = Infer; + +// A single Ed25519 (OKP) JWK. `type` (not `object`) keeps optional/extra JWK +// fields (`use`, `alg`) from failing validation. +const JwkStruct = type({ + kty: string(), + crv: string(), + x: string(), + kid: string(), +}); +const JwksResponseStruct = type({ keys: array(JwkStruct) }); +export type JwksResponse = Infer; + +const UkycSessionResponseStruct = type({ + sessionId: string(), + // The relay-side KYC decision (e.g. `approved`) and the vendor-side final + // status (e.g. `pending`) at session-creation time. Present when the applicant + // already has a session in flight; absent for a brand-new session. + kycStatus: optional(string()), + finalStatus: optional(string()), +}); +export type UkycSessionResponse = Infer; + +const ApplicantAccessTokenResponseStruct = type({ + status: string(), + applicantAccessToken: string(), +}); +export type ApplicantAccessTokenResponse = Infer< + typeof ApplicantAccessTokenResponseStruct +>; + +const SessionStatusResponseStruct = type({ + finalStatus: string(), + statusMessage: optional(string()), + externalUserId: string(), + kycStatus: string(), + vendor: string(), + vendorStatus: string(), +}); + +// === PARAM TYPES === + +export type CreateSessionParams = { + email: string; + termsAcceptedAt: string; + disclaimerIds: string[]; +}; + +export type CheckKycRequiredParams = { + accessToken: string; + country: string; + capabilities?: { product: string }[]; +}; + +export type GetWrappingKeyParams = { + sessionClientPublicKey: string; +}; + +/** + * The wrapped `data_encryption_key` sent to the UKYC backend when creating a + * session. `encryptedKey` and `nonce` are produced by `wrapEncryptionKey`; + * `sessionId` is the wrapping key id returned by `getWrappingKey`. + */ +export type WrappedEncryptionKey = { + sessionId: string; + encryptedKey: string; + nonce: string; +}; + +export type CreateUkycSessionParams = { + jwtToken: string; + vendorMetadata: Record; + wrappedEncryptionKey: WrappedEncryptionKey; + /** + * The client-signed `ukyc_capability_token` (envelope: payload + Ed25519 + * signature) authorizing later storage access for this session. It is minted + * by the client with `read`-only scope — see the UKYC storage-and-auth spec + * for how it is formed. Only the client holds the signing key, so only the + * client can mint it. The envelope is base64url-encoded into a compact string + * before it is sent to the backend. + */ + ukycCapabilityToken: UkycStorageAccessToken; +}; + +export type GetSessionStatusParams = { + sessionId: string; +}; + +export type RegisterSelfHostedWalletParams = { + customerId: string; + address: string; + message: string; + signature: string; +}; + +// === SERVICE DEFINITION === + +/** + * `KycService` communicates with the Universal KYC (UKYC) backend to drive the + * identity + document-verification flow. It is stateless and platform-agnostic: + * HTTP is performed through an injected `fetch`, and the auth bearer token and + * geolocation come from other controllers via the messenger. + * + * It extends {@link BaseDataService}, so every request is routed through + * `fetchQuery`: it is wrapped in the shared service policy (retries, circuit + * breaker) and its result is exposed via the service's `QueryClient`. Read-only + * endpoints (`fetchDisclaimers`, `fetchJwks`) are cached with a `staleTime`; + * the session-creating and status-polling endpoints opt out of caching + * (`staleTime`/`cacheTime` of `0`) so they never serve a stale result. + */ +export class KycService extends BaseDataService< + typeof serviceName, + KycServiceMessenger +> { + readonly #fetch: typeof fetch; + + readonly #baseUrl: string; + + readonly #fractalEncryptionBaseUrl: string; + + readonly #walletRegistrationService: WalletRegistrationService; + + /** + * Constructs a new KycService. + * + * @param options - The constructor options. + * @param options.messenger - The messenger suited for this service. + * @param options.fetch - A function used to make HTTP requests. + * @param options.baseUrl - Base URL of the KYC API + * @param options.fractalEncryptionBaseUrl - Base URL of the Fractal + * encryption service, from which the JWKS used to verify the wrapping-key + * `jwtChain` is fetched. + * @param options.queryClientConfig - Shared configuration for all queries + * exposed by the service. + * @param options.policyOptions - Options for the request service policy. + */ + constructor({ + messenger, + fetch: fetchFunction, + baseUrl, + fractalEncryptionBaseUrl, + queryClientConfig = {}, + policyOptions = {}, + }: KycServiceOptions) { + super({ + name: serviceName, + messenger, + queryClientConfig, + policyOptions, + }); + this.#fetch = fetchFunction; + if (!baseUrl) { + throw new Error('KycService: baseUrl is required'); + } + this.#baseUrl = baseUrl; + this.#fractalEncryptionBaseUrl = fractalEncryptionBaseUrl ?? ''; + this.#walletRegistrationService = new WalletRegistrationService({ + fetch: fetchFunction, + baseUrl, + getAuthToken: async (): Promise => this.#getBearerToken(), + }); + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Resolves the customer's country from the geolocation source and converts it + * to an ISO 3166-1 alpha-3 code. + * + * @returns The alpha-3 country code. + * @throws If the country cannot be determined or mapped. + */ + async getGeoCountry(): Promise { + const location = await this.messenger.call( + 'GeolocationController:getGeolocation', + ); + // Guard nullish/empty geolocation with the documented domain error rather + // than letting `assert(location, string())` surface a superstruct + // assertion error (which would change how the failure reads in + // `disclaimersError`). + const alpha2 = + typeof location === 'string' ? location.split('-')[0].toUpperCase() : ''; + if (!alpha2 || alpha2 === 'UNKNOWN') { + throw new Error( + `Unable to determine country from geolocation (got "${String( + location, + )}").`, + ); + } + const alpha3 = alpha2ToAlpha3(alpha2); + if (!alpha3) { + throw new Error( + `Unable to map country code "${alpha2}" to an ISO 3166-1 alpha-3 code.`, + ); + } + return alpha3; + } + + /** + * Resolves Iron's internal customer id from the authenticated MetaMask + * profile. + * + * @returns Iron's internal customer id. + */ + async getMoonpayCustomerId(): Promise { + return await this.#walletRegistrationService.getMoonpayCustomerId(); + } + + /** + * Checks whether a Monad Money Account address is already registered. + * + * @param params - The address to check. + * @param params.address - Money Account address. + * @returns Active, disabled, or absent registration status. + */ + async getWalletRegistrationStatus({ + address, + }: { + address: string; + }): Promise { + return await this.#walletRegistrationService.getRegistrationStatus({ + address, + blockchain: 'Monad', + }); + } + + /** + * Submits a signed Monad Money Account ownership proof. + * + * @param params - Signed ownership proof. + * @returns Registered wallet record. + */ + async registerSelfHostedWallet( + params: RegisterSelfHostedWalletParams, + ): Promise { + return await this.#walletRegistrationService.registerSelfHostedWallet({ + ...params, + blockchain: 'Monad', + }); + } + + /** + * Fetches the disclaimers the customer must accept before a session is + * created. + * + * @param params - The parameters. + * @param params.country - ISO 3166-1 alpha-3 country code. + * @returns The disclaimers. + */ + async fetchDisclaimers({ + country, + }: { + country: string; + }): Promise { + const url = new URL('/vendors/moonpay/disclaimers', this.#baseUrl); + url.searchParams.set('country', country); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:fetchDisclaimers`, country], + queryFn: async () => this.#requestJson(url, { method: 'GET' }), + staleTime: inMilliseconds(5, Duration.Minute), + }); + return this.#validateResponse( + data, + DisclaimersResponseStruct, + 'disclaimers', + ) as KycDisclaimer[]; + } + + /** + * Creates a vendor session via the UKYC backend. + * + * @param params - The session parameters. + * @returns The created session token. + */ + async createSession( + params: CreateSessionParams, + ): Promise> { + const url = new URL('/vendors/moonpay/sessions', this.#baseUrl); + const data = await this.fetchQuery({ + queryKey: [ + `${this.name}:createSession`, + params.email, + params.termsAcceptedAt, + params.disclaimerIds, + ], + queryFn: async () => + this.#requestJson(url, { + method: 'POST', + body: JSON.stringify(params), + }), + // A session-creating mutation must never serve a stale/cached result. + staleTime: 0, + cacheTime: 0, + }); + return this.#validateResponse( + data, + CreateSessionResponseStruct, + 'sessions', + ); + } + + /** + * Checks whether KYC is required for the given access token, country, and + * capabilities. + * + * @param params - The check parameters. + * @returns Whether KYC is required. + */ + async checkKycRequired( + params: CheckKycRequiredParams, + ): Promise<{ kycRequired: boolean }> { + const url = new URL('/vendors/moonpay/kyc-required', this.#baseUrl); + const capabilities = params.capabilities ?? [{ product: 'ramps' }]; + const data = await this.fetchQuery({ + queryKey: [ + `${this.name}:checkKycRequired`, + params.accessToken, + params.country, + capabilities, + ], + queryFn: async () => + this.#requestJson(url, { + method: 'POST', + body: JSON.stringify({ + accessToken: params.accessToken, + country: params.country, + capabilities, + }), + }), + // The requirement can change server-side, so always re-check. + staleTime: 0, + cacheTime: 0, + }); + const { required } = this.#validateResponse( + data, + KycRequiredResponseStruct, + 'kyc-required', + ); + return { kycRequired: required }; + } + + /** + * Requests a per-session wrapping key from the UKYC backend. + * + * The client sends its ephemeral X25519 public key; the backend responds with + * its session public key (`sessionServerPublicKey`) and a `jwtChain` that + * attests it. The caller must verify `jwtChain` against the Fractal JWKS + * (see {@link KycService.fetchJwks}) before trusting the key to wrap the + * `data_encryption_key`. + * + * @param params - The parameters. + * @param params.sessionClientPublicKey - Our ephemeral X25519 public key + * (base64url). + * @returns The wrapping key id, `jwtChain`, and session server public key. + */ + async getWrappingKey( + params: GetWrappingKeyParams, + ): Promise { + const url = new URL('/wrapping-key', this.#baseUrl); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:getWrappingKey`, params.sessionClientPublicKey], + queryFn: async () => + this.#requestJson(url, { + method: 'POST', + body: JSON.stringify({ + sessionClientPublicKey: params.sessionClientPublicKey, + }), + }), + // A per-session key exchange must always run fresh. + staleTime: 0, + cacheTime: 0, + }); + return this.#validateResponse( + data, + WrappingKeyResponseStruct, + 'wrapping-key', + ); + } + + /** + * Fetches the Fractal encryption service JWKS used to verify the `jwtChain` + * returned by {@link KycService.getWrappingKey}. + * + * This is an unauthenticated request to a well-known path on the Fractal + * host, distinct from the UKYC base URL. + * + * @returns The JWKS keys. + */ + async fetchJwks(): Promise { + if (!this.#fractalEncryptionBaseUrl) { + throw new Error( + 'KycService: fractalEncryptionBaseUrl is not configured; cannot fetch JWKS to verify the wrapping key.', + ); + } + const url = new URL(UKYC_JWKS_PATH, this.#fractalEncryptionBaseUrl); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:fetchJwks`, this.#fractalEncryptionBaseUrl], + queryFn: async () => + this.#requestJson(url, { method: 'GET' }, { authenticated: false }), + staleTime: inMilliseconds(1, Duration.Hour), + }); + return this.#validateResponse(data, JwksResponseStruct, 'JWKS'); + } + + /** + * Creates a UKYC session for the SumSub document-verification sub-flow, + * handing over the wrapped `data_encryption_key` and the client-signed, + * read-only `ukyc_capability_token` that authorizes later storage access for + * the session. + * + * @param params - The session parameters. + * @returns The UKYC session identifiers. + */ + async createUkycSession( + params: CreateUkycSessionParams, + ): Promise { + const url = new URL('/sessions', this.#baseUrl); + const data = await this.fetchQuery({ + queryKey: [ + `${this.name}:createUkycSession`, + params.wrappedEncryptionKey.sessionId, + ], + queryFn: async () => + this.#requestJson(url, { + method: 'POST', + body: JSON.stringify({ + vendorId: 'moonpay', + vendorUserId: 'mockedId', + jwtToken: params.jwtToken, + vendorMetadata: params.vendorMetadata, + wrappedEncryptionKey: params.wrappedEncryptionKey, + ukycCapabilityToken: encodeStorageAccessTokenForHeader( + params.ukycCapabilityToken, + ), + }), + }), + // A session-creating mutation must never serve a stale/cached result. + staleTime: 0, + cacheTime: 0, + }); + return this.#validateResponse( + data, + UkycSessionResponseStruct, + 'UKYC sessions', + ); + } + + /** + * Creates (or refreshes) the SumSub verification journey for a UKYC session, + * returning the applicant access token used to launch the SDK. + * + * @param sessionId - The UKYC session id from `createUkycSession`. + * @returns The applicant access token and status. + */ + async createJourney( + sessionId: string, + ): Promise { + const url = new URL( + `/sessions/${encodeURIComponent(sessionId)}/journey`, + this.#baseUrl, + ); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:createJourney`, sessionId], + queryFn: async () => this.#requestJson(url, { method: 'POST' }), + // Journeys are (re)created on demand; do not reuse a cached token. + staleTime: 0, + cacheTime: 0, + }); + return this.#validateResponse( + data, + ApplicantAccessTokenResponseStruct, + 'journey', + ); + } + + /** + * Fetches the current status of a UKYC session. Polled after the SumSub SDK + * completes to determine the final verification decision. + * + * @param params - The parameters. + * @param params.sessionId - The UKYC session id. + * @returns The session status. + */ + async getSessionStatus( + params: GetSessionStatusParams, + ): Promise { + const url = new URL( + `/sessions/${encodeURIComponent(params.sessionId)}/status`, + this.#baseUrl, + ); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:getSessionStatus`, params.sessionId], + queryFn: async () => this.#requestJson(url, { method: 'GET' }), + // Status is polled for a terminal decision, so it must always be fresh. + staleTime: 0, + cacheTime: 0, + }); + return this.#validateResponse( + data, + SessionStatusResponseStruct, + 'session status', + ); + } + + /** + * Validates a parsed API response against a superstruct schema, throwing a + * descriptive error when the response does not match. + * + * Unlike a bare `Struct.is` check, this surfaces exactly which field was + * missing or had the wrong type, which is essential for diagnosing shape + * mismatches between the client and the live API. + * + * @param data - The parsed response body. + * @param struct - The superstruct schema the body is expected to satisfy. + * @param apiName - A human-readable name of the API, used in the error message. + * @returns The validated, typed response. + * @throws If `data` does not match `struct`. + */ + #validateResponse( + data: unknown, + struct: Struct, + apiName: string, + ): Type { + try { + assert(data, struct); + return data; + } catch (error) { + const detail = + error instanceof StructError + ? `${error.message} (received: ${JSON.stringify(data)})` + : // `assert` only ever throws `StructError` for the plain structs used + // here, so this is a defensive fallback that is not exercised. + /* istanbul ignore next */ + String(error); + throw new Error( + `Malformed response received from ${apiName} API: ${detail}`, + ); + } + } + + /** + * Gets the authenticated wallet bearer token. + * + * @returns The bearer token. + */ + async #getBearerToken(): Promise { + const bearerToken = await this.messenger.call( + 'AuthenticationController:getBearerToken', + ); + assert(bearerToken, string()); + if (!bearerToken) { + throw new Error( + 'Unable to obtain an authentication bearer token - is the wallet signed in?', + ); + } + return bearerToken; + } + + /** + * Performs a single JSON request. + * + * This is meant to be used as the `queryFn` for {@link fetchQuery}, which + * wraps it in the shared service policy (retries, circuit breaker). Requests + * are authenticated with the wallet bearer token by default; pass + * `{ authenticated: false }` for calls to services that do not expect it + * (e.g. the Fractal JWKS endpoint). + * + * @param url - The request URL. + * @param init - The request init (method, body). + * @param options - Request options. + * @param options.authenticated - Whether to attach the bearer token. Defaults + * to `true`. + * @returns The parsed JSON response. + */ + async #requestJson( + url: URL, + init: RequestInit, + options: { authenticated?: boolean } = {}, + ): Promise { + const { authenticated = true } = options; + + const headers: Record = {}; + + // Only advertise a JSON body when one is actually sent; bodyless requests + // (e.g. `createJourney`) must not carry a `Content-Type`. + if (init.body !== undefined && init.body !== null) { + headers['Content-Type'] = 'application/json'; + } + + if (authenticated) { + headers.Authorization = `Bearer ${await this.#getBearerToken()}`; + } + + const response = await this.#fetch(url.toString(), { + ...init, + headers, + }); + if (!response.ok) { + throw new HttpError( + response.status, + `Fetching '${url.toString()}' failed with status '${response.status}'`, + ); + } + + return (await response.json()) as Json; + } +} diff --git a/packages/kyc-controller/src/countryCodes.test.ts b/packages/kyc-controller/src/countryCodes.test.ts new file mode 100644 index 00000000000..9188868966d --- /dev/null +++ b/packages/kyc-controller/src/countryCodes.test.ts @@ -0,0 +1,19 @@ +import { ALPHA2_TO_ALPHA3, alpha2ToAlpha3 } from './countryCodes.js'; + +describe('countryCodes', () => { + it('exposes the alpha-2 to alpha-3 map', () => { + expect(ALPHA2_TO_ALPHA3.US).toBe('USA'); + }); + + it('maps a known uppercase alpha-2 code', () => { + expect(alpha2ToAlpha3('GB')).toBe('GBR'); + }); + + it('is case-insensitive', () => { + expect(alpha2ToAlpha3('fr')).toBe('FRA'); + }); + + it('returns undefined for an unknown code', () => { + expect(alpha2ToAlpha3('ZZ')).toBeUndefined(); + }); +}); diff --git a/packages/kyc-controller/src/countryCodes.ts b/packages/kyc-controller/src/countryCodes.ts new file mode 100644 index 00000000000..a5712d24109 --- /dev/null +++ b/packages/kyc-controller/src/countryCodes.ts @@ -0,0 +1,270 @@ +/** + * ISO 3166-1 alpha-2 to alpha-3 country code mapping. + * + * The geolocation source returns ISO 3166-2 codes whose leading segment is an + * alpha-2 country code (e.g. "US", "US-NY"). The identity vendor APIs + * (disclaimers, kyc-required) expect alpha-3 codes (e.g. "USA"). This map + * bridges the two. + */ +export const ALPHA2_TO_ALPHA3: Record = { + AD: 'AND', + AE: 'ARE', + AF: 'AFG', + AG: 'ATG', + AI: 'AIA', + AL: 'ALB', + AM: 'ARM', + AO: 'AGO', + AQ: 'ATA', + AR: 'ARG', + AS: 'ASM', + AT: 'AUT', + AU: 'AUS', + AW: 'ABW', + AX: 'ALA', + AZ: 'AZE', + BA: 'BIH', + BB: 'BRB', + BD: 'BGD', + BE: 'BEL', + BF: 'BFA', + BG: 'BGR', + BH: 'BHR', + BI: 'BDI', + BJ: 'BEN', + BL: 'BLM', + BM: 'BMU', + BN: 'BRN', + BO: 'BOL', + BQ: 'BES', + BR: 'BRA', + BS: 'BHS', + BT: 'BTN', + BV: 'BVT', + BW: 'BWA', + BY: 'BLR', + BZ: 'BLZ', + CA: 'CAN', + CC: 'CCK', + CD: 'COD', + CF: 'CAF', + CG: 'COG', + CH: 'CHE', + CI: 'CIV', + CK: 'COK', + CL: 'CHL', + CM: 'CMR', + CN: 'CHN', + CO: 'COL', + CR: 'CRI', + CU: 'CUB', + CV: 'CPV', + CW: 'CUW', + CX: 'CXR', + CY: 'CYP', + CZ: 'CZE', + DE: 'DEU', + DJ: 'DJI', + DK: 'DNK', + DM: 'DMA', + DO: 'DOM', + DZ: 'DZA', + EC: 'ECU', + EE: 'EST', + EG: 'EGY', + EH: 'ESH', + ER: 'ERI', + ES: 'ESP', + ET: 'ETH', + FI: 'FIN', + FJ: 'FJI', + FK: 'FLK', + FM: 'FSM', + FO: 'FRO', + FR: 'FRA', + GA: 'GAB', + GB: 'GBR', + GD: 'GRD', + GE: 'GEO', + GF: 'GUF', + GG: 'GGY', + GH: 'GHA', + GI: 'GIB', + GL: 'GRL', + GM: 'GMB', + GN: 'GIN', + GP: 'GLP', + GQ: 'GNQ', + GR: 'GRC', + GS: 'SGS', + GT: 'GTM', + GU: 'GUM', + GW: 'GNB', + GY: 'GUY', + HK: 'HKG', + HM: 'HMD', + HN: 'HND', + HR: 'HRV', + HT: 'HTI', + HU: 'HUN', + ID: 'IDN', + IE: 'IRL', + IL: 'ISR', + IM: 'IMN', + IN: 'IND', + IO: 'IOT', + IQ: 'IRQ', + IR: 'IRN', + IS: 'ISL', + IT: 'ITA', + JE: 'JEY', + JM: 'JAM', + JO: 'JOR', + JP: 'JPN', + KE: 'KEN', + KG: 'KGZ', + KH: 'KHM', + KI: 'KIR', + KM: 'COM', + KN: 'KNA', + KP: 'PRK', + KR: 'KOR', + KW: 'KWT', + KY: 'CYM', + KZ: 'KAZ', + LA: 'LAO', + LB: 'LBN', + LC: 'LCA', + LI: 'LIE', + LK: 'LKA', + LR: 'LBR', + LS: 'LSO', + LT: 'LTU', + LU: 'LUX', + LV: 'LVA', + LY: 'LBY', + MA: 'MAR', + MC: 'MCO', + MD: 'MDA', + ME: 'MNE', + MF: 'MAF', + MG: 'MDG', + MH: 'MHL', + MK: 'MKD', + ML: 'MLI', + MM: 'MMR', + MN: 'MNG', + MO: 'MAC', + MP: 'MNP', + MQ: 'MTQ', + MR: 'MRT', + MS: 'MSR', + MT: 'MLT', + MU: 'MUS', + MV: 'MDV', + MW: 'MWI', + MX: 'MEX', + MY: 'MYS', + MZ: 'MOZ', + NA: 'NAM', + NC: 'NCL', + NE: 'NER', + NF: 'NFK', + NG: 'NGA', + NI: 'NIC', + NL: 'NLD', + NO: 'NOR', + NP: 'NPL', + NR: 'NRU', + NU: 'NIU', + NZ: 'NZL', + OM: 'OMN', + PA: 'PAN', + PE: 'PER', + PF: 'PYF', + PG: 'PNG', + PH: 'PHL', + PK: 'PAK', + PL: 'POL', + PM: 'SPM', + PN: 'PCN', + PR: 'PRI', + PS: 'PSE', + PT: 'PRT', + PW: 'PLW', + PY: 'PRY', + QA: 'QAT', + RE: 'REU', + RO: 'ROU', + RS: 'SRB', + RU: 'RUS', + RW: 'RWA', + SA: 'SAU', + SB: 'SLB', + SC: 'SYC', + SD: 'SDN', + SE: 'SWE', + SG: 'SGP', + SH: 'SHN', + SI: 'SVN', + SJ: 'SJM', + SK: 'SVK', + SL: 'SLE', + SM: 'SMR', + SN: 'SEN', + SO: 'SOM', + SR: 'SUR', + SS: 'SSD', + ST: 'STP', + SV: 'SLV', + SX: 'SXM', + SY: 'SYR', + SZ: 'SWZ', + TC: 'TCA', + TD: 'TCD', + TF: 'ATF', + TG: 'TGO', + TH: 'THA', + TJ: 'TJK', + TK: 'TKL', + TL: 'TLS', + TM: 'TKM', + TN: 'TUN', + TO: 'TON', + TR: 'TUR', + TT: 'TTO', + TV: 'TUV', + TW: 'TWN', + TZ: 'TZA', + UA: 'UKR', + UG: 'UGA', + UM: 'UMI', + US: 'USA', + UY: 'URY', + UZ: 'UZB', + VA: 'VAT', + VC: 'VCT', + VE: 'VEN', + VG: 'VGB', + VI: 'VIR', + VN: 'VNM', + VU: 'VUT', + WF: 'WLF', + WS: 'WSM', + YE: 'YEM', + YT: 'MYT', + ZA: 'ZAF', + ZM: 'ZMB', + ZW: 'ZWE', +}; + +/** + * Converts an ISO 3166-1 alpha-2 country code (e.g. "US") to its alpha-3 + * equivalent (e.g. "USA"). Returns `undefined` for unknown codes. + * + * @param alpha2 - The ISO 3166-1 alpha-2 country code. + * @returns The alpha-3 code, or `undefined` if the input is not recognized. + */ +export function alpha2ToAlpha3(alpha2: string): string | undefined { + return ALPHA2_TO_ALPHA3[alpha2.toUpperCase()]; +} diff --git a/packages/kyc-controller/src/crypto.test.ts b/packages/kyc-controller/src/crypto.test.ts new file mode 100644 index 00000000000..45bfc7b190b --- /dev/null +++ b/packages/kyc-controller/src/crypto.test.ts @@ -0,0 +1,204 @@ +import { gcm } from '@noble/ciphers/aes'; +import { x25519 } from '@noble/curves/ed25519'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha2'; +import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils'; +import { base64 } from '@scure/base'; + +import type { EncryptedCredentialsEnvelope } from './crypto.js'; +import { decryptCredentials, generateKeyPair } from './crypto.js'; + +/** + * Builds an encrypted-credentials envelope that `decryptCredentials` can + * reverse with `ourPublicKey`'s matching private key. + * + * @param ourPublicKey - The recipient's X25519 public key. + * @param credentials - The plaintext credentials to encrypt. + * @param options - Encoding options. + * @param options.encoding - `'hex'` (default) or `'base64'`. + * @param options.ivLength - IV length in bytes (default 12). + * @param options.useNonceField - Emit `nonce` instead of `iv`. + * @returns The encrypted envelope. + */ +function makeEnvelope( + ourPublicKey: Uint8Array, + credentials: Record, + { + encoding = 'hex' as 'hex' | 'base64', + ivLength = 12, + useNonceField = false, + } = {}, +): EncryptedCredentialsEnvelope { + const ephemeralPrivate = x25519.utils.randomSecretKey(); + const ephemeralPublic = x25519.getPublicKey(ephemeralPrivate); + const shared = x25519.getSharedSecret(ephemeralPrivate, ourPublicKey); + const key = hkdf(sha256, shared, undefined, undefined, 32); + const iv = new Uint8Array(ivLength).fill(7); + const ciphertext = gcm(key, iv).encrypt( + utf8ToBytes(JSON.stringify(credentials)), + ); + const encode = (bytes: Uint8Array): string => + encoding === 'hex' ? bytesToHex(bytes) : base64.encode(bytes); + const envelope: EncryptedCredentialsEnvelope = { + ephemeralPublicKey: encode(ephemeralPublic), + ciphertext: encode(ciphertext), + }; + if (useNonceField) { + envelope.nonce = encode(iv); + } else { + envelope.iv = encode(iv); + } + return envelope; +} + +describe('crypto', () => { + describe('generateKeyPair', () => { + it('produces a 32-byte keypair with a hex public key', () => { + const keypair = generateKeyPair(); + expect(keypair.privateKey).toHaveLength(32); + expect(keypair.publicKey).toHaveLength(32); + expect(keypair.publicKeyHex).toMatch(/^[0-9a-f]{64}$/u); + }); + }); + + describe('decryptCredentials', () => { + it('decrypts a hex-encoded envelope object', () => { + const keypair = generateKeyPair(); + const envelope = makeEnvelope(keypair.publicKey, { + accessToken: 'access-1', + }); + + const { credentials, method } = decryptCredentials( + envelope, + keypair.privateKey, + ); + + expect(credentials.accessToken).toBe('access-1'); + expect(method).toBe('aes-256-gcm/hkdf-sha256'); + }); + + it('decrypts a base64-encoded envelope', () => { + const keypair = generateKeyPair(); + const envelope = makeEnvelope( + keypair.publicKey, + { clientToken: 'client-1' }, + { encoding: 'base64' }, + ); + + const { credentials } = decryptCredentials(envelope, keypair.privateKey); + + expect(credentials.clientToken).toBe('client-1'); + }); + + it('honors an explicit base64 encoding hint', () => { + const keypair = generateKeyPair(); + const envelope = makeEnvelope( + keypair.publicKey, + { accessToken: 'access-2' }, + { encoding: 'base64' }, + ); + envelope.encoding = 'base64'; + + const { credentials } = decryptCredentials(envelope, keypair.privateKey); + + expect(credentials.accessToken).toBe('access-2'); + }); + + it('accepts a `nonce` field as an alias for `iv`', () => { + const keypair = generateKeyPair(); + const envelope = makeEnvelope( + keypair.publicKey, + { accessToken: 'access-3' }, + { useNonceField: true }, + ); + + const { credentials } = decryptCredentials(envelope, keypair.privateKey); + + expect(credentials.accessToken).toBe('access-3'); + }); + + it('decrypts an envelope delivered as a JSON string', () => { + const keypair = generateKeyPair(); + const envelope = makeEnvelope(keypair.publicKey, { + accessToken: 'access-4', + }); + + const { credentials } = decryptCredentials( + JSON.stringify(envelope), + keypair.privateKey, + ); + + expect(credentials.accessToken).toBe('access-4'); + }); + + it('decrypts an envelope delivered as base64(JSON)', () => { + const keypair = generateKeyPair(); + const envelope = makeEnvelope(keypair.publicKey, { + accessToken: 'access-5', + }); + const base64Json = base64.encode(utf8ToBytes(JSON.stringify(envelope))); + + const { credentials } = decryptCredentials( + base64Json, + keypair.privateKey, + ); + + expect(credentials.accessToken).toBe('access-5'); + }); + + it('throws for a JSON string that fails to parse', () => { + const keypair = generateKeyPair(); + expect(() => + decryptCredentials('{ not valid json', keypair.privateKey), + ).toThrow(/looked like JSON but failed to parse/u); + }); + + it('throws for base64 that decodes to non-JSON starting with a brace', () => { + const keypair = generateKeyPair(); + const bad = base64.encode(utf8ToBytes('{ still not json')); + expect(() => decryptCredentials(bad, keypair.privateKey)).toThrow( + /base64-decoded to non-JSON/u, + ); + }); + + it('throws for an opaque string that is neither JSON nor base64(JSON)', () => { + const keypair = generateKeyPair(); + const bad = base64.encode(utf8ToBytes('hello world')); + expect(() => decryptCredentials(bad, keypair.privateKey)).toThrow( + /opaque string/u, + ); + }); + + it('throws for an object missing required fields', () => { + const keypair = generateKeyPair(); + expect(() => + decryptCredentials( + { ephemeralPublicKey: 'aa' } as EncryptedCredentialsEnvelope, + keypair.privateKey, + ), + ).toThrow(/missing required fields/u); + }); + + it('reports the value type for a non-object input', () => { + const keypair = generateKeyPair(); + expect(() => + decryptCredentials( + 123 as unknown as EncryptedCredentialsEnvelope, + keypair.privateKey, + ), + ).toThrow(/Got: number/u); + }); + + it('throws when the IV length is not 12 bytes', () => { + const keypair = generateKeyPair(); + const envelope = makeEnvelope( + keypair.publicKey, + { accessToken: 'x' }, + { ivLength: 16 }, + ); + expect(() => decryptCredentials(envelope, keypair.privateKey)).toThrow( + /Unexpected IV length 16/u, + ); + }); + }); +}); diff --git a/packages/kyc-controller/src/crypto.ts b/packages/kyc-controller/src/crypto.ts new file mode 100644 index 00000000000..50012f016d3 --- /dev/null +++ b/packages/kyc-controller/src/crypto.ts @@ -0,0 +1,238 @@ +/** + * Check / Auth frame key exchange and credential decryption. + * + * The identity vendor's Check and Auth frames return encrypted credentials. + * The confirmed protocol is X25519 ECDH + AES-256-GCM (an "ECDH-ES" pattern + * signalled by a 12-byte IV): + * + * 1. Client generates an X25519 keypair, sends `publicKey` (hex) into the + * frame as a URL param. + * 2. Frame generates its own ephemeral X25519 keypair and encrypts the + * credentials, returning `{ ephemeralPublicKey, iv, ciphertext }`. + * 3. Client reverses: + * shared = X25519(ourPrivate, theirEphemeralPublic) + * key = HKDF-SHA256(shared, salt=none, info=none, 32 bytes) + * plain = AES-256-GCM.decrypt(key, iv, ciphertext || 16-byte tag) + * + * This module is platform-agnostic: it uses `@noble/*` and `@metamask/utils` + * (via the shared encoding helpers) and avoids `Buffer` / `atob` so it runs + * unchanged on mobile, extension, and web. + */ + +import { gcm } from '@noble/ciphers/aes'; +import { x25519 } from '@noble/curves/ed25519'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha2'; +import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; + +import { base64UrlToBytes } from './encoding.js'; + +/** + * An X25519 keypair used for the Check/Auth frame key exchange. + */ +export type X25519KeyPair = { + /** Raw 32-byte X25519 private (scalar) key. Never leaves the device. */ + privateKey: Uint8Array; + /** Raw 32-byte X25519 public key. */ + publicKey: Uint8Array; + /** Hex-encoded public key, ready to drop into a Check/Auth frame URL. */ + publicKeyHex: string; +}; + +/** + * The encrypted-credentials envelope returned by the Check/Auth frames. Binary + * fields may be hex or base64; the IV field may be named `iv` or `nonce`. + */ +export type EncryptedCredentialsEnvelope = { + /** Ephemeral public key produced by the frame for this exchange (32 bytes). */ + ephemeralPublicKey: string; + /** Per-message IV. May be provided as `iv` or `nonce`. */ + iv?: string; + nonce?: string; + /** Ciphertext (plaintext + 16-byte GCM auth tag). */ + ciphertext: string; + /** Optional explicit encoding hint. Defaults to auto-detect. */ + encoding?: 'hex' | 'base64'; +}; + +/** + * Decrypted Check/Auth frame credentials. + * + * - `accessToken` is the Bearer token for the identity API. + * - `clientToken` is the short-lived token consumed by the Auth frame when the + * Check frame returns `connectionRequired`. + */ +export type DecryptedCredentials = { + accessToken?: string; + clientToken?: string; + [key: string]: unknown; +}; + +/** + * Result of a successful decryption — the credentials plus the `method` that + * authenticated. + */ +export type DecryptResult = { + credentials: DecryptedCredentials; + method: string; +}; + +/** + * Generate a fresh X25519 keypair. The private key never leaves the device; + * only `publicKeyHex` is sent to the vendor via the frame URL. + * + * @returns The generated keypair. + */ +export function generateKeyPair(): X25519KeyPair { + const privateKey = x25519.utils.randomSecretKey(); + const publicKey = x25519.getPublicKey(privateKey); + return { + privateKey, + publicKey, + publicKeyHex: bytesToHex(publicKey), + }; +} + +/** + * Decode a binary envelope field that may be hex or base64. + * + * @param value - The encoded field. + * @param encoding - Optional explicit encoding; auto-detected when omitted. + * @returns The decoded bytes. + */ +function decodeBinary(value: string, encoding?: 'hex' | 'base64'): Uint8Array { + const isHex = + encoding === 'hex' || + (encoding === undefined && /^[0-9a-fA-F]+$/u.test(value)); + if (isHex) { + return hexToBytes(value); + } + return base64UrlToBytes(value); +} + +/** + * Coerce the `credentials` field into a structured envelope. The frame may + * deliver it as an object, a JSON string, or base64(JSON). + * + * @param input - The raw credentials value. + * @returns The normalized envelope. + * @throws If the value is not a structured or base64(JSON) envelope, or is + * missing required fields. + */ +function normalizeEnvelope( + input: EncryptedCredentialsEnvelope | string, +): EncryptedCredentialsEnvelope { + let value: unknown = input; + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed.startsWith('{')) { + try { + value = JSON.parse(trimmed); + } catch { + throw new Error( + `credentials looked like JSON but failed to parse (preview: "${trimmed.slice( + 0, + 64, + )}").`, + ); + } + } else { + let decodedText: string | null = null; + try { + decodedText = new TextDecoder().decode(base64UrlToBytes(trimmed)); + } catch { + decodedText = null; + } + const decodedTrimmed = decodedText?.trim(); + if (decodedTrimmed?.startsWith('{')) { + try { + value = JSON.parse(decodedTrimmed); + } catch { + throw new Error( + `credentials base64-decoded to non-JSON (preview: "${decodedTrimmed.slice( + 0, + 64, + )}").`, + ); + } + } else { + throw new Error( + `credentials is an opaque string, not a structured or base64(JSON) envelope (preview: "${trimmed.slice( + 0, + 64, + )}").`, + ); + } + } + } + + const env = value as Partial; + if (!env.ephemeralPublicKey || !(env.iv ?? env.nonce) || !env.ciphertext) { + const keys = + value && typeof value === 'object' + ? Object.keys(value).join(', ') + : typeof value; + throw new Error( + `credentials envelope missing required fields (ephemeralPublicKey/iv/ciphertext). Got: ${keys}`, + ); + } + return env as EncryptedCredentialsEnvelope; +} + +/** + * X25519 ECDH to AES-256-GCM decryption. + * + * @param theirPublicKey - The frame's ephemeral public key. + * @param iv - The 12-byte GCM IV. + * @param ciphertext - The ciphertext including the 16-byte auth tag. + * @param ourPrivateKey - Our X25519 private key. + * @returns The decrypted credentials and method. + */ +function aesGcmDecrypt( + theirPublicKey: Uint8Array, + iv: Uint8Array, + ciphertext: Uint8Array, + ourPrivateKey: Uint8Array, +): DecryptResult { + const shared = x25519.getSharedSecret(ourPrivateKey, theirPublicKey); + const key = hkdf(sha256, shared, undefined, undefined, 32); + const plaintext = gcm(key, iv).decrypt(ciphertext); + const text = new TextDecoder().decode(plaintext); + return { + credentials: JSON.parse(text) as DecryptedCredentials, + method: 'aes-256-gcm/hkdf-sha256', + }; +} + +/** + * Decrypt a Check/Auth frame credentials envelope using our X25519 private + * key. + * + * @param rawEnvelope - The raw envelope (object, JSON string, or base64(JSON)). + * @param ourPrivateKey - Our X25519 private key. + * @returns The parsed credentials and the method that authenticated. + * @throws If the envelope is malformed or the IV length is not 12 bytes. + */ +export function decryptCredentials( + rawEnvelope: EncryptedCredentialsEnvelope | string, + ourPrivateKey: Uint8Array, +): DecryptResult { + const envelope = normalizeEnvelope(rawEnvelope); + const theirPublicKey = decodeBinary( + envelope.ephemeralPublicKey, + envelope.encoding, + ); + // `normalizeEnvelope` guarantees one of `iv` / `nonce` is present. + const ivField = (envelope.iv ?? envelope.nonce) as string; + const iv = decodeBinary(ivField, envelope.encoding); + const ciphertext = decodeBinary(envelope.ciphertext, envelope.encoding); + + if (iv.length !== 12) { + throw new Error( + `Unexpected IV length ${iv.length} (expected 12 for AES-256-GCM).`, + ); + } + + return aesGcmDecrypt(theirPublicKey, iv, ciphertext, ourPrivateKey); +} diff --git a/packages/kyc-controller/src/encoding.test.ts b/packages/kyc-controller/src/encoding.test.ts new file mode 100644 index 00000000000..bd21da29df3 --- /dev/null +++ b/packages/kyc-controller/src/encoding.test.ts @@ -0,0 +1,32 @@ +import { areUint8ArraysEqual } from '@metamask/utils'; + +import { base64UrlToBytes, toBase64Url } from './encoding.js'; + +describe('encoding', () => { + describe('toBase64Url', () => { + it('produces unpadded, url-safe base64', () => { + // 0xFB 0xFF encodes to "+/8=" in standard base64, exercising both the + // `+`->`-`, `/`->`_`, and padding-stripping substitutions. + const encoded = toBase64Url(new Uint8Array([0xfb, 0xff])); + + expect(encoded).toBe('-_8'); + expect(encoded).not.toContain('='); + }); + }); + + describe('base64UrlToBytes', () => { + it('round-trips arbitrary bytes through toBase64Url', () => { + const bytes = new Uint8Array([0x00, 0x01, 0xfb, 0xff, 0x10, 0x2a, 0x7f]); + + const roundTripped = base64UrlToBytes(toBase64Url(bytes)); + + expect(areUint8ArraysEqual(roundTripped, bytes)).toBe(true); + }); + + it('decodes an already-padded standard base64url string', () => { + const bytes = new Uint8Array([0xfb, 0xff]); + + expect(areUint8ArraysEqual(base64UrlToBytes('-_8='), bytes)).toBe(true); + }); + }); +}); diff --git a/packages/kyc-controller/src/encoding.ts b/packages/kyc-controller/src/encoding.ts new file mode 100644 index 00000000000..f9fb9f265a2 --- /dev/null +++ b/packages/kyc-controller/src/encoding.ts @@ -0,0 +1,39 @@ +import { base64ToBytes, bytesToBase64 } from '@metamask/utils'; + +/** + * Shared base64url encoding helpers used by frame crypto and UKYC modules. + * + * These are platform-agnostic: they rely on `@metamask/utils` rather than + * `Buffer` / `atob`, so they run unchanged on mobile, extension, and web. + */ + +/** + * Encodes bytes as unpadded base64url (RFC 4648 §5). This is the wire shape + * used for `storage_id`, `signing_public_key`, and Ed25519 signatures in the + * UKYC storage API. + * + * @param bytes - The bytes to encode. + * @returns The base64url string without `=` padding. + */ +export function toBase64Url(bytes: Uint8Array): string { + return bytesToBase64(bytes) + .replace(/\+/gu, '-') + .replace(/\//gu, '_') + .replace(/[=]+$/u, ''); +} + +/** + * Decodes an unpadded (or padded) base64url string back to bytes. Inverse of + * {@link toBase64Url}. + * + * @param value - The base64url string. + * @returns The decoded bytes. + */ +export function base64UrlToBytes(value: string): Uint8Array { + return base64ToBytes( + value + .replace(/-/gu, '+') + .replace(/_/gu, '/') + .padEnd(value.length + ((4 - (value.length % 4)) % 4), '='), + ); +} diff --git a/packages/kyc-controller/src/index.test.ts b/packages/kyc-controller/src/index.test.ts index baa030ac10f..3bbd3b4c258 100644 --- a/packages/kyc-controller/src/index.test.ts +++ b/packages/kyc-controller/src/index.test.ts @@ -1,9 +1,20 @@ -import greeter from './index.js'; +import * as packageExports from './index.js'; -describe('Test', () => { - it('greets', () => { - const name = 'Huey'; - const result = greeter(name); - expect(result).toBe('Hello, Huey!'); +describe('@metamask/kyc-controller', () => { + it('exports the controller, service, selectors, and helpers', () => { + expect(packageExports).toMatchObject({ + KycController: expect.any(Function), + KycService: expect.any(Function), + getDefaultKycControllerState: expect.any(Function), + selectKycPhase: expect.any(Function), + selectKycSumSub: expect.any(Function), + selectIsKycRequiredForProduct: expect.any(Function), + alpha2ToAlpha3: expect.any(Function), + generateKeyPair: expect.any(Function), + decryptCredentials: expect.any(Function), + WalletRegistrationError: expect.any(Function), + controllerName: 'KycController', + serviceName: 'KycService', + }); }); }); diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index 6972c117292..f0deafb95b0 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -1,9 +1,133 @@ -/** - * Example function that returns a greeting for the given name. - * - * @param name - The name to greet. - * @returns The greeting. - */ -export default function greeter(name: string): string { - return `Hello, ${name}!`; -} +export { + KycController, + getDefaultKycControllerState, + controllerName, +} from './KycController.js'; +export type { + KycControllerActions, + KycControllerEvents, + KycControllerGetStateAction, + KycControllerMessenger, + MoneyAccountWalletRegistrationResult, + KycControllerOptions, + KycControllerState, + KycControllerStateChangeEvent, +} from './KycController.js'; +export type { + KycControllerAcceptTermsAndStartSessionAction, + KycControllerBuildAuthFrameUrlAction, + KycControllerBuildCheckFrameUrlAction, + KycControllerBuildResetFrameUrlAction, + KycControllerCheckKycRequiredAction, + KycControllerClearSavedTermsAction, + KycControllerGetKycStatusAction, + KycControllerGetSessionStatusAction, + KycControllerHandleFrameMessageAction, + KycControllerInitializeAction, + KycControllerLoadDisclaimersAction, + KycControllerResetAction, + KycControllerRegisterMoneyAccountWalletAction, + KycControllerStartSumSubAction, +} from './KycController-method-action-types.js'; + +export { KycService, serviceName } from './KycService.js'; +export type { + ApplicantAccessTokenResponse, + CheckKycRequiredParams, + CreateSessionParams, + CreateUkycSessionParams, + GetSessionStatusParams, + GetWrappingKeyParams, + JwksResponse, + KycServiceActions, + KycServiceCacheUpdatedEvent, + KycServiceEvents, + KycServiceGranularCacheUpdatedEvent, + KycServiceInvalidateQueriesAction, + KycServiceMessenger, + KycServiceOptions, + RegisterSelfHostedWalletParams, + UkycSessionResponse, + WrappedEncryptionKey, + WrappingKeyResponse, +} from './KycService.js'; +export type { + KycServiceCheckKycRequiredAction, + KycServiceCreateJourneyAction, + KycServiceCreateSessionAction, + KycServiceCreateUkycSessionAction, + KycServiceFetchDisclaimersAction, + KycServiceFetchJwksAction, + KycServiceGetGeoCountryAction, + KycServiceGetSessionStatusAction, + KycServiceGetWrappingKeyAction, + KycServiceGetMoonpayCustomerIdAction, + KycServiceGetWalletRegistrationStatusAction, + KycServiceRegisterSelfHostedWalletAction, +} from './KycService-method-action-types.js'; + +export { + selectIsKycRequiredForProduct, + selectKycPhase, + selectKycSumSub, +} from './selectors.js'; + +export { alpha2ToAlpha3, ALPHA2_TO_ALPHA3 } from './countryCodes.js'; +export { decryptCredentials, generateKeyPair } from './crypto.js'; +export type { + DecryptedCredentials, + DecryptResult, + EncryptedCredentialsEnvelope, + X25519KeyPair, +} from './crypto.js'; + +export type { + KycDisclaimer, + KycPhase, + KycProduct, + KycSessionStatus, + KycSumSubLaunchParams, + KycSumSubLauncher, + KycSumSubStatus, + KycVendor, +} from './types.js'; + +// UKYC storage-access-token utilities. Exported so a signed capability token can +// be minted for testing UKYC Storage (see `mintUkycTestToken`). +export { + UKYC_CAPABILITY_AUTH_SCHEME, + UKYC_KWIL_AUDIENCE, + UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, + UKYC_STORAGE_ACCESS_TOKEN_AUDIENCES, + UKYC_STORAGE_ACCESS_TOKEN_VERSION, +} from './ukyc/constants.js'; +export { + deriveClientMaterial, + encodeClientMaterial, +} from './ukyc/deriveClientMaterial.js'; +export type { + EncodedUkycClientMaterial, + UkycClientMaterial, +} from './ukyc/deriveClientMaterial.js'; +export { + encodeStorageAccessTokenForHeader, + signStorageAccessToken, +} from './ukyc/storageAccessToken.js'; +export type { + SignStorageAccessTokenParams, + UkycStorageAccessToken, + UkycStorageAccessTokenPayload, + UkycStorageOperation, + UkycTokenPresenter, +} from './ukyc/storageAccessToken.js'; +export { mintUkycTestToken } from './ukyc/testToken.js'; +export type { + MintedUkycTestToken, + MintUkycTestTokenParams, +} from './ukyc/testToken.js'; + +export type { + SelfHostedRegistration, + WalletRegistrationErrorKind, +} from './wallet-registration-service.js'; +export { WalletRegistrationError } from './wallet-registration-service.js'; diff --git a/packages/kyc-controller/src/ownership-message.test.ts b/packages/kyc-controller/src/ownership-message.test.ts new file mode 100644 index 00000000000..071144a4642 --- /dev/null +++ b/packages/kyc-controller/src/ownership-message.test.ts @@ -0,0 +1,66 @@ +import { buildOwnershipMessage } from './ownership-message.js'; + +describe('buildOwnershipMessage', () => { + it('builds the exact MoonPay ownership sentence', () => { + const result = buildOwnershipMessage({ + address: '0xAbCdEf1234567890', + customerId: 'customer-123', + now: new Date('2026-08-12T15:30:00.000Z'), + }); + + expect(result).toBe( + 'I am verifying ownership of the wallet address 0xAbCdEf1234567890 as customer customer-123. This message was signed on 12/08/2026 to confirm my control over this wallet.', + ); + }); + + it('formats the date in UTC across a local date boundary', () => { + const result = buildOwnershipMessage({ + address: '0x1234', + customerId: 'customer-123', + now: new Date('2027-01-01T00:30:00.000Z'), + }); + + expect(result).toContain('signed on 01/01/2027'); + }); + + it('preserves the exact supplied address casing', () => { + const result = buildOwnershipMessage({ + address: '0xAbCdEf', + customerId: 'customer-123', + now: new Date('2026-08-12T15:30:00.000Z'), + }); + + expect(result).toContain('wallet address 0xAbCdEf as customer'); + }); + + it('does not add surrounding whitespace or a trailing newline', () => { + const result = buildOwnershipMessage({ + address: '0x1234', + customerId: 'customer-123', + now: new Date('2026-08-12T15:30:00.000Z'), + }); + + expect(result).toBe(result.trim()); + expect(result.endsWith('\n')).toBe(false); + }); + + it('builds a fresh message after UTC midnight', () => { + const request = { + address: '0x1234', + customerId: 'customer-123', + }; + + const beforeMidnight = buildOwnershipMessage({ + ...request, + now: new Date('2026-08-12T23:59:59.999Z'), + }); + const afterMidnight = buildOwnershipMessage({ + ...request, + now: new Date('2026-08-13T00:00:00.000Z'), + }); + + expect(beforeMidnight).toContain('signed on 12/08/2026'); + expect(afterMidnight).toContain('signed on 13/08/2026'); + expect(afterMidnight).not.toBe(beforeMidnight); + }); +}); diff --git a/packages/kyc-controller/src/ownership-message.ts b/packages/kyc-controller/src/ownership-message.ts new file mode 100644 index 00000000000..539d5e3aac3 --- /dev/null +++ b/packages/kyc-controller/src/ownership-message.ts @@ -0,0 +1,32 @@ +export type BuildOwnershipMessageRequest = { + address: string; + customerId: string; + now: Date; +}; + +/** + * Builds the proof-of-ownership message required to register a self-hosted + * wallet with MoonPay Iron (`POST /addresses/crypto/selfhosted`). + * + * The returned string is the exact sentence that must be both signed (EIP-191 + * `personal_sign`) and sent, byte-for-byte, in the registration request body. + * The date is always formatted as `DD/MM/YYYY` in UTC so a signature produced + * just before UTC midnight is not reused with a stale date after rollover. + * + * @param request - Values embedded in the ownership message. + * @param request.address - Wallet address, kept verbatim (no re-casing). + * @param request.customerId - Iron customer id; must match the request body. + * @param request.now - Reference time used to derive the UTC calendar date. + * @returns The exact message to sign and submit. + */ +export function buildOwnershipMessage({ + address, + customerId, + now, +}: BuildOwnershipMessageRequest): string { + const day = String(now.getUTCDate()).padStart(2, '0'); + const month = String(now.getUTCMonth() + 1).padStart(2, '0'); + const year = now.getUTCFullYear(); + + return `I am verifying ownership of the wallet address ${address} as customer ${customerId}. This message was signed on ${day}/${month}/${year} to confirm my control over this wallet.`; +} diff --git a/packages/kyc-controller/src/selectors.test.ts b/packages/kyc-controller/src/selectors.test.ts new file mode 100644 index 00000000000..5eee934acd0 --- /dev/null +++ b/packages/kyc-controller/src/selectors.test.ts @@ -0,0 +1,33 @@ +import { getDefaultKycControllerState } from './KycController.js'; +import { + selectIsKycRequiredForProduct, + selectKycPhase, + selectKycSumSub, +} from './selectors.js'; + +describe('selectors', () => { + it('selectKycPhase returns the current phase', () => { + const state = { ...getDefaultKycControllerState(), phase: 'form' as const }; + expect(selectKycPhase(state)).toBe('form'); + }); + + it('selectKycSumSub returns the sub-flow state', () => { + const state = getDefaultKycControllerState(); + expect(selectKycSumSub(state)).toStrictEqual(state.sumsub); + }); + + describe('selectIsKycRequiredForProduct', () => { + it('returns the cached requirement for a product', () => { + const state = { + ...getDefaultKycControllerState(), + kycRequiredByProduct: { ramps: true }, + }; + expect(selectIsKycRequiredForProduct('ramps')(state)).toBe(true); + }); + + it('returns undefined when the product has not been checked', () => { + const state = getDefaultKycControllerState(); + expect(selectIsKycRequiredForProduct('card')(state)).toBeUndefined(); + }); + }); +}); diff --git a/packages/kyc-controller/src/selectors.ts b/packages/kyc-controller/src/selectors.ts new file mode 100644 index 00000000000..6247e01796f --- /dev/null +++ b/packages/kyc-controller/src/selectors.ts @@ -0,0 +1,42 @@ +import { createSelector } from 'reselect'; + +import type { KycControllerState } from './KycController.js'; +import type { KycProduct } from './types.js'; + +const selectKycRequiredByProduct = ( + state: KycControllerState, +): KycControllerState['kycRequiredByProduct'] => state.kycRequiredByProduct; + +/** + * Selects the current flow phase. + * + * @param state - The KycController state. + * @returns The current phase. + */ +export const selectKycPhase = ( + state: KycControllerState, +): KycControllerState['phase'] => state.phase; + +/** + * Selects the SumSub sub-flow state. + * + * @param state - The KycController state. + * @returns The SumSub state. + */ +export const selectKycSumSub = ( + state: KycControllerState, +): KycControllerState['sumsub'] => state.sumsub; + +/** + * Creates a selector that returns whether KYC is required for a product. + * + * @param product - The consuming feature. + * @returns A selector returning the cached requirement, or `undefined`. + */ +export const selectIsKycRequiredForProduct = ( + product: KycProduct, +): ((state: KycControllerState) => boolean | undefined) => + createSelector( + [selectKycRequiredByProduct], + (map): boolean | undefined => map[product], + ); diff --git a/packages/kyc-controller/src/types.ts b/packages/kyc-controller/src/types.ts new file mode 100644 index 00000000000..1323aea0c1b --- /dev/null +++ b/packages/kyc-controller/src/types.ts @@ -0,0 +1,156 @@ +/** + * Shared types for the KYC controller and service. + * + * The KYC flow is vendor-backed (currently MoonPay for identity + SumSub for + * document verification) but the surface exposed to consumers (ramps, card) is + * intentionally vendor-neutral so a future vendor swap does not ripple out. + */ + +/** + * A MetaMask feature that consumes KYC. Used to key the per-product + * "is KYC required" cache so ramps and card can share one controller. + */ +export type KycProduct = 'ramps' | 'card'; + +/** + * Identity vendors supported behind the KYC surface. + */ +export type KycVendor = 'moonpay'; + +/** + * Phases of the end-to-end identity flow. + * + * - `idle` — nothing started. + * - `terms` — waiting for the customer to accept the vendor terms. + * - `session` — creating the vendor session. + * - `check` — running the invisible connection-check frame. + * - `auth` — running the visible authentication (OTP) frame. + * - `form` — authenticated. When the flow is scoped to a product, the + * KYC-required check runs automatically from here; otherwise the consumer + * drives it manually via `checkKycRequired`. + * - `submit` — submitting the KYC-required check. + * - `done` — flow complete; see `kycRequiredByProduct` / `sumsub`. When KYC is + * required, the document-verification sub-flow is launched automatically. + * - `error` — flow halted; see `error`. + */ +export type KycPhase = + | 'idle' + | 'terms' + | 'session' + | 'check' + | 'auth' + | 'form' + | 'submit' + | 'done' + | 'error'; + +/** + * Progress of the SumSub document-verification sub-flow. + * + * - `polling` — the SDK finished and the controller is polling the UKYC + * backend for the session's final decision (see `KycSessionStatus`). The + * sub-flow resolves to `complete` or `failed` once a terminal status arrives. + * - `vendorProcessing` — session creation reported that the applicant is + * already approved on the relay (`kycStatus`) while the vendor is still + * finalizing its own decision (`finalStatus`). There is nothing left for the + * applicant to do, so the SDK is not launched; see `statusMessage`. + */ +export type KycSumSubStatus = + | 'idle' + | 'creatingSession' + | 'fetchingToken' + | 'launching' + | 'inProgress' + | 'polling' + | 'complete' + | 'failed' + | 'vendorProcessing'; + +/** + * The status of a UKYC session, returned by the `GET /sessions/{id}/status` + * endpoint and polled after the SumSub SDK completes to determine the final + * verification decision. + */ +export type KycSessionStatus = { + /** + * The overall status of the session. Terminal values (e.g. `approved`, + * `completed`, `rejected`, `failed`, `blocked`) end polling; any other value + * keeps polling. + */ + finalStatus: string; + /** Optional human-readable message describing the status. */ + statusMessage?: string; + /** The vendor-agnostic external user id associated with the session. */ + externalUserId: string; + /** The KYC decision status. */ + kycStatus: string; + /** The identity vendor that handled the session. */ + vendor: string; + /** The vendor-specific status. */ + vendorStatus: string; +}; + +/** + * A single disclaimer/term the customer must accept before a session is + * created. + */ +export type KycDisclaimer = { + id: string; + // Mirrors the vendor API response field, which is snake_case. + // eslint-disable-next-line @typescript-eslint/naming-convention + display_name: string; + url: string; +}; + +/** + * Parameters passed to a platform SumSub launcher. + */ +export type KycSumSubLaunchParams = { + /** + * The applicant access token used to initialize the SumSub SDK. + */ + applicantAccessToken: string; + + /** + * Called by the SDK when the access token expires; must resolve with a fresh + * applicant access token. + */ + onTokenExpiration: () => Promise; + + /** + * Called when the SDK reports a status transition. + */ + onStatusChange?: (prevStatus: string, newStatus: string) => void; + + /** + * BCP-47 locale for the SDK UI. + */ + locale?: string; + + /** + * Enables SDK debug logging. + */ + debug?: boolean; +}; + +/** + * Platform adapter that launches the native/web SumSub SDK. + * + * The KYC controller is platform-agnostic and does not import any SDK; each + * client (mobile / extension / web) injects an implementation of this + * interface. The controller owns all orchestration (session creation, token + * exchange, token refresh, state) and only delegates the actual SDK + * presentation to `launch`. + */ +export type KycSumSubLauncher = { + /** + * Whether the underlying SDK is available in the current runtime (e.g. the + * native module is linked). When `false`, `startSumSub` fails fast. + */ + isAvailable(): boolean; + + /** + * Presents the SumSub verification flow and resolves with the SDK result. + */ + launch(params: KycSumSubLaunchParams): Promise>; +}; diff --git a/packages/kyc-controller/src/ukyc/constants.ts b/packages/kyc-controller/src/ukyc/constants.ts new file mode 100644 index 00000000000..c75c850c521 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/constants.ts @@ -0,0 +1,82 @@ +/** + * Constants for the UKYC client-derived key material and storage-authorization + * layer. See the architecture doc, section "Client-Derived Material". + */ + +/** + * Fully-qualified key path for the `local_user_secret` in Encrypted User + * Storage. + */ +export const UKYC_LOCAL_USER_SECRET_PATH = `ukyc.local_user_secret` as const; + +/** + * Size of the `local_user_secret` in bytes. 32 bytes (256 bits) provides high + * entropy and matches the input length expected by the HKDF-SHA256 derivations + * below. + */ +export const UKYC_LOCAL_USER_SECRET_SIZE_BYTES = 32; + +/** + * Byte length of each value derived from `local_user_secret`. + * + * `signingKey` is 32 bytes because it is used directly as the Ed25519 + * private key (for Ed25519 the 32-byte seed *is* the private key). + */ +export const UKYC_DERIVED_KEY_SIZES = { + storageId: 32, + dataEncryptionKey: 32, + signingKey: 32, + relayTunnelKey: 32, +} as const; + +/** + * HKDF `info` labels providing domain separation between the values derived + * from `local_user_secret`. + */ +export const UKYC_KDF_INFO = { + storageId: 'metamask.ukyc.storage.v1.storage_id', + dataEncryptionKey: 'metamask.ukyc.storage.v1.data_encryption_key', + signingKey: 'metamask.ukyc.storage.v1.signing_key', + relayTunnelKey: 'metamask.ukyc.storage.v1.relay_tunnel_key', +} as const; + +/** + * Version bound into every `storage_access_token` payload. + */ +export const UKYC_STORAGE_ACCESS_TOKEN_VERSION = 1; + +/** + * Audience identifying the UKYC user-storage service. Required by UKYC Storage + * when it verifies a `storage_access_token`. + */ +export const UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE = + 'metamask:user-storage:ukyc' as const; + +/** + * Audience identifying the idOS Kwil credential-registry nodes. Required by + * idOS Kwil when it verifies a `storage_access_token`. + */ +export const UKYC_KWIL_AUDIENCE = 'idos:kwil' as const; + +/** + * Full audience list bound into every `storage_access_token` payload. `aud` + * lists every verifier that may accept the token, so both UKYC Storage and + * idOS Kwil can each find their own entry. + */ +export const UKYC_STORAGE_ACCESS_TOKEN_AUDIENCES = [ + UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, + UKYC_KWIL_AUDIENCE, +] as const; + +/** + * Authorization scheme under which a signed `storage_access_token` envelope is + * carried to UKYC Storage: `Authorization: AccessToken `. + * The credentials portion is what `encodeStorageAccessTokenForHeader` returns. + */ +export const UKYC_CAPABILITY_AUTH_SCHEME = 'AccessToken' as const; + +/** + * Standard well-known path where the Fractal encryption service publishes its + * JWKS (the Ed25519 public keys used to sign the `jwtChain`). + */ +export const UKYC_JWKS_PATH = '/.well-known/jwks.json'; diff --git a/packages/kyc-controller/src/ukyc/deriveClientMaterial.test.ts b/packages/kyc-controller/src/ukyc/deriveClientMaterial.test.ts new file mode 100644 index 00000000000..17ae0da5bdb --- /dev/null +++ b/packages/kyc-controller/src/ukyc/deriveClientMaterial.test.ts @@ -0,0 +1,109 @@ +import { areUint8ArraysEqual } from '@metamask/utils'; +import { ed25519 } from '@noble/curves/ed25519'; + +import { + UKYC_DERIVED_KEY_SIZES, + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, +} from './constants.js'; +import { + deriveClientMaterial, + encodeClientMaterial, +} from './deriveClientMaterial.js'; + +const LOCAL_USER_SECRET = new Uint8Array( + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, +).fill(42); +const OTHER_LOCAL_USER_SECRET = new Uint8Array( + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, +).fill(43); + +describe('UKYC deriveClientMaterial', () => { + it('derives each value at the documented length', () => { + const material = deriveClientMaterial(LOCAL_USER_SECRET); + + expect(material.storageId).toHaveLength(UKYC_DERIVED_KEY_SIZES.storageId); + expect(material.dataEncryptionKey).toHaveLength( + UKYC_DERIVED_KEY_SIZES.dataEncryptionKey, + ); + expect(material.signingKey).toHaveLength(UKYC_DERIVED_KEY_SIZES.signingKey); + expect(material.relayTunnelKey).toHaveLength( + UKYC_DERIVED_KEY_SIZES.relayTunnelKey, + ); + // Ed25519 public keys are 32 bytes. + expect(material.signingPublicKey).toHaveLength(32); + }); + + it('is deterministic for the same local_user_secret', () => { + const a = deriveClientMaterial(LOCAL_USER_SECRET); + const b = deriveClientMaterial(LOCAL_USER_SECRET); + + expect(a).toStrictEqual(b); + }); + + it('produces different material for a different local_user_secret', () => { + const a = deriveClientMaterial(LOCAL_USER_SECRET); + const b = deriveClientMaterial(OTHER_LOCAL_USER_SECRET); + + expect(areUint8ArraysEqual(a.storageId, b.storageId)).toBe(false); + expect(areUint8ArraysEqual(a.dataEncryptionKey, b.dataEncryptionKey)).toBe( + false, + ); + expect(areUint8ArraysEqual(a.signingKey, b.signingKey)).toBe(false); + expect(areUint8ArraysEqual(a.relayTunnelKey, b.relayTunnelKey)).toBe(false); + }); + + it('domain-separates the derived values from one another', () => { + const { storageId, dataEncryptionKey, signingKey, relayTunnelKey } = + deriveClientMaterial(LOCAL_USER_SECRET); + const values = [storageId, dataEncryptionKey, signingKey, relayTunnelKey]; + + for (let i = 0; i < values.length; i++) { + for (let j = i + 1; j < values.length; j++) { + expect(areUint8ArraysEqual(values[i], values[j])).toBe(false); + } + } + }); + + it('derives a signing public key that matches the signing key', () => { + const material = deriveClientMaterial(LOCAL_USER_SECRET); + + expect(material.signingPublicKey).toStrictEqual( + ed25519.getPublicKey(material.signingKey), + ); + }); + + it('produces a working Ed25519 keypair for storage authorization', () => { + const material = deriveClientMaterial(LOCAL_USER_SECRET); + const message = new TextEncoder().encode('storage-authorization-payload'); + + const signature = ed25519.sign(message, material.signingKey); + + expect(ed25519.verify(signature, message, material.signingPublicKey)).toBe( + true, + ); + }); +}); + +describe('UKYC encodeClientMaterial', () => { + it('encodes storage_id and signing public key as unpadded base64url', () => { + const material = deriveClientMaterial(LOCAL_USER_SECRET); + + const encoded = encodeClientMaterial(material); + + expect(encoded.storageId).toMatch(/^[A-Za-z0-9_-]+$/u); + expect(encoded.signingPublicKey).toMatch(/^[A-Za-z0-9_-]+$/u); + expect(encoded.storageId).not.toContain('='); + expect(encoded.signingPublicKey).not.toContain('='); + }); + + it('omits secret material from the encoded output', () => { + const material = deriveClientMaterial(LOCAL_USER_SECRET); + + const encoded = encodeClientMaterial(material); + + expect(Object.keys(encoded).sort()).toStrictEqual([ + 'signingPublicKey', + 'storageId', + ]); + }); +}); diff --git a/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts b/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts new file mode 100644 index 00000000000..59ffa68d024 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts @@ -0,0 +1,128 @@ +import { stringToBytes } from '@metamask/utils'; +import { ed25519 } from '@noble/curves/ed25519'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha2'; + +import { UKYC_DERIVED_KEY_SIZES, UKYC_KDF_INFO } from './constants.js'; +import { toBase64Url } from '../encoding.js'; + +/** + * Derives UKYC client material from the root `local_user_secret` using + * HKDF-SHA256 with domain-separated `info` labels — see the architecture doc, + * section "Client-Derived Material". + */ + +/** + * The set of values derived from `local_user_secret`. + */ +export type UkycClientMaterial = { + /** Opaque lookup key for the encrypted KYC object. */ + storageId: Uint8Array; + /** Symmetric key that encrypts `encrypted_kyc_data` (or wraps per-blob keys). */ + dataEncryptionKey: Uint8Array; + /** + * Ed25519 private key used to sign `storage_access_token` capabilities. For + * Ed25519 the 32-byte HKDF output *is* the private key. The private half + * never leaves the device. + */ + signingKey: Uint8Array; + /** Public half of `signingKey`, registered with the object on first write. */ + signingPublicKey: Uint8Array; + /** Key for establishing/authenticating the encrypted tunnel to idOS, if needed. */ + relayTunnelKey: Uint8Array; +}; + +/** + * The same material with byte fields base64url-encoded, matching the wire + * shapes in the architecture doc (`storage_id` as an opaque id, public key as a + * "base64url public key"). Secret material is intentionally omitted. + */ +export type EncodedUkycClientMaterial = { + storageId: string; + signingPublicKey: string; +}; + +/** + * Derives a single labeled value from `local_user_secret`. + * + * No salt is used: `local_user_secret` is already a high-entropy + * uniformly-random secret, so per-output domain separation comes entirely from + * the `info` label. + * + * @param localUserSecret - The root `local_user_secret` bytes. + * @param info - Domain-separation label for this output. + * @param length - Desired output length in bytes. + * @returns The derived bytes. + */ +function deriveLabeled( + localUserSecret: Uint8Array, + info: string, + length: number, +): Uint8Array { + return hkdf(sha256, localUserSecret, undefined, stringToBytes(info), length); +} + +/** + * Derives all UKYC client material from the root `local_user_secret`. + * + * This is a pure function of `local_user_secret`: the same input always yields + * the same outputs, which is what makes `storage_id` and `signing_key` stable + * across sessions and devices. + * + * @param localUserSecret - The `local_user_secret` produced by + * `getOrCreateLocalUserSecret`. + * @returns The derived {@link UkycClientMaterial}. + */ +export function deriveClientMaterial( + localUserSecret: Uint8Array, +): UkycClientMaterial { + const storageId = deriveLabeled( + localUserSecret, + UKYC_KDF_INFO.storageId, + UKYC_DERIVED_KEY_SIZES.storageId, + ); + + const dataEncryptionKey = deriveLabeled( + localUserSecret, + UKYC_KDF_INFO.dataEncryptionKey, + UKYC_DERIVED_KEY_SIZES.dataEncryptionKey, + ); + + const signingKey = deriveLabeled( + localUserSecret, + UKYC_KDF_INFO.signingKey, + UKYC_DERIVED_KEY_SIZES.signingKey, + ); + + const relayTunnelKey = deriveLabeled( + localUserSecret, + UKYC_KDF_INFO.relayTunnelKey, + UKYC_DERIVED_KEY_SIZES.relayTunnelKey, + ); + + const signingPublicKey = ed25519.getPublicKey(signingKey); + + return { + storageId, + dataEncryptionKey, + signingKey, + signingPublicKey, + relayTunnelKey, + }; +} + +/** + * Encodes the non-secret client material into the base64url wire shapes used by + * the UKYC storage API (`storage_id` and `signing_public_key`). + * + * @param material - The derived client material. + * @returns The base64url-encoded, non-secret fields. + */ +export function encodeClientMaterial( + material: UkycClientMaterial, +): EncodedUkycClientMaterial { + return { + storageId: toBase64Url(material.storageId), + signingPublicKey: toBase64Url(material.signingPublicKey), + }; +} diff --git a/packages/kyc-controller/src/ukyc/jwtChain.test.ts b/packages/kyc-controller/src/ukyc/jwtChain.test.ts new file mode 100644 index 00000000000..735da283d21 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/jwtChain.test.ts @@ -0,0 +1,100 @@ +import { stringToBytes } from '@metamask/utils'; +import { ed25519 } from '@noble/curves/ed25519'; + +import { toBase64Url } from '../encoding.js'; +import type { Jwk } from './jwtChain.js'; +import { verifyJwtChain } from './jwtChain.js'; + +const KID = 'key-1'; +const PAYLOAD = { sessionServerPublicKeyX: 'spk-x', nonce: 'nonce-1' }; + +const SIGNING_PRIVATE_KEY = ed25519.utils.randomSecretKey(); +const SIGNING_PUBLIC_KEY = ed25519.getPublicKey(SIGNING_PRIVATE_KEY); + +const JWK: Jwk = { + kty: 'OKP', + crv: 'Ed25519', + x: toBase64Url(SIGNING_PUBLIC_KEY), + kid: KID, +}; + +/** + * Builds a compact EdDSA JWT signed with the module's signing key. + * + * @param options - Overrides. + * @param options.header - The protected header (defaults to a valid EdDSA one). + * @param options.payload - The payload (defaults to {@link PAYLOAD}). + * @param options.privateKey - The signing key (defaults to the module key). + * @param options.tamper - When true, corrupts the signature. + * @returns The compact-serialized JWT. + */ +function buildJwt({ + header = { alg: 'EdDSA', kid: KID }, + payload = PAYLOAD, + privateKey = SIGNING_PRIVATE_KEY, + tamper = false, +}: { + header?: Record; + payload?: Record; + privateKey?: Uint8Array; + tamper?: boolean; +} = {}): string { + const headerSegment = toBase64Url(stringToBytes(JSON.stringify(header))); + const payloadSegment = toBase64Url(stringToBytes(JSON.stringify(payload))); + const signature = ed25519.sign( + new TextEncoder().encode(`${headerSegment}.${payloadSegment}`), + privateKey, + ); + if (tamper) { + signature[0] = signature[0] === 0 ? 1 : 0; + } + return `${headerSegment}.${payloadSegment}.${toBase64Url(signature)}`; +} + +describe('UKYC verifyJwtChain', () => { + it('returns the payload for a validly-signed jwtChain', () => { + expect(verifyJwtChain([JWK], buildJwt())).toStrictEqual(PAYLOAD); + }); + + it('rejects a jwtChain that is not three segments', () => { + expect(() => verifyJwtChain([JWK], 'only.two')).toThrow( + 'not a well-formed JWT', + ); + }); + + it('rejects a non-EdDSA algorithm', () => { + const jwt = buildJwt({ header: { alg: 'RS256', kid: KID } }); + + expect(() => verifyJwtChain([JWK], jwt)).toThrow('expected EdDSA'); + }); + + it('rejects when no JWKS key matches the kid', () => { + const jwt = buildJwt({ header: { alg: 'EdDSA', kid: 'other' } }); + + expect(() => verifyJwtChain([JWK], jwt)).toThrow('no JWKS key matches'); + }); + + it('rejects a JWKS key that is not an Ed25519 OKP key', () => { + const badJwk: Jwk = { ...JWK, crv: 'X25519' }; + + expect(() => verifyJwtChain([badJwk], buildJwt())).toThrow( + 'is not an Ed25519 OKP key', + ); + }); + + it('rejects a tampered signature', () => { + expect(() => verifyJwtChain([JWK], buildJwt({ tamper: true }))).toThrow( + 'signature verification failed', + ); + }); + + it('rejects a malformed (non-JSON) header segment', () => { + const jwt = `not-json.${toBase64Url( + stringToBytes(JSON.stringify(PAYLOAD)), + )}.sig`; + + expect(() => verifyJwtChain([JWK], jwt)).toThrow( + 'failed to decode jwtChain header', + ); + }); +}); diff --git a/packages/kyc-controller/src/ukyc/jwtChain.ts b/packages/kyc-controller/src/ukyc/jwtChain.ts new file mode 100644 index 00000000000..ebb40d93d48 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/jwtChain.ts @@ -0,0 +1,113 @@ +import { bytesToString } from '@metamask/utils'; +import { ed25519 } from '@noble/curves/ed25519'; + +import { base64UrlToBytes } from '../encoding.js'; + +/** + * Verifies the `jwtChain` returned by the Fractal encryption service against + * its published JWKS. + * + * The signature check is done with `@noble/curves` (rather than WebCrypto + * `subtle`) because not every MetaMask runtime exposes a `subtle` + * implementation for Ed25519; JWT parsing is a plain base64url/JSON decode, so + * no `jose` dependency is required. + */ + +/** + * A single Ed25519 (OKP) JSON Web Key from the Fractal JWKS. + */ +export type Jwk = { + kty: string; + crv: string; + x: string; + kid: string; + use?: string; + alg?: string; +}; + +/** + * The verified `jwtChain` payload. `sessionServerPublicKeyX` attests the + * server's X25519 public key so the client can confirm the value returned + * out-of-band by `getWrappingKey` was not tampered with. + */ +export type JwtChainPayload = { + sessionServerPublicKeyX: string; + nonce: string; +}; + +/** + * The protected header of a compact JWT. + */ +type JwtHeader = { + alg?: string; + kid?: string; +}; + +/** + * Decodes a base64url JWT segment into a parsed JSON object. + * + * @param segment - The base64url-encoded segment. + * @param label - Human-readable segment name for error messages. + * @returns The parsed JSON object. + */ +function decodeJsonSegment(segment: string, label: string): Type { + try { + return JSON.parse(bytesToString(base64UrlToBytes(segment))) as Type; + } catch (error) { + throw new Error( + `UKYC: failed to decode jwtChain ${label}: ${String(error)}`, + ); + } +} + +/** + * Verifies `jwtChain` against `keys`: matches the JWT header `kid` to a + * published Ed25519 signing key and checks the EdDSA signature over the + * `header.payload` input. Returns the decoded, verified payload. + * + * @param keys - The JWKS keys published by the Fractal encryption service. + * @param jwtChain - The compact-serialized EdDSA JWT from `getWrappingKey`. + * @returns The verified JWT payload. + */ +export function verifyJwtChain(keys: Jwk[], jwtChain: string): JwtChainPayload { + const [headerSegment, payloadSegment, signatureSegment] = jwtChain.split('.'); + if (!headerSegment || !payloadSegment || !signatureSegment) { + throw new Error( + 'UKYC: jwtChain is not a well-formed JWT (expected 3 segments).', + ); + } + + const header = decodeJsonSegment(headerSegment, 'header'); + if (header.alg !== 'EdDSA') { + throw new Error( + `UKYC: unsupported jwtChain alg "${String( + header.alg, + )}" (expected EdDSA).`, + ); + } + + const jwk = keys.find((key) => key.kid === header.kid); + if (!jwk) { + throw new Error( + `UKYC: no JWKS key matches jwtChain kid "${String(header.kid)}".`, + ); + } + if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519') { + throw new Error( + `UKYC: JWKS key ${jwk.kid} is not an Ed25519 OKP key (kty=${jwk.kty}, crv=${jwk.crv}).`, + ); + } + + const isValid = ed25519.verify( + base64UrlToBytes(signatureSegment), + new TextEncoder().encode(`${headerSegment}.${payloadSegment}`), + base64UrlToBytes(jwk.x), + ); + if (!isValid) { + throw new Error( + 'UKYC: jwtChain signature verification failed against JWKS.', + ); + } + + return decodeJsonSegment(payloadSegment, 'payload'); +} diff --git a/packages/kyc-controller/src/ukyc/localUserSecret.test.ts b/packages/kyc-controller/src/ukyc/localUserSecret.test.ts new file mode 100644 index 00000000000..b2e7869813d --- /dev/null +++ b/packages/kyc-controller/src/ukyc/localUserSecret.test.ts @@ -0,0 +1,148 @@ +import { base64ToBytes, bytesToBase64 } from '@metamask/utils'; + +import { + UKYC_LOCAL_USER_SECRET_PATH, + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, +} from './constants.js'; +import type { UkycLocalUserSecretStore } from './localUserSecret.js'; +import { + getOrCreateLocalUserSecret, + hasLocalUserSecret, + loadLocalUserSecret, +} from './localUserSecret.js'; + +const SECRET_BYTES = new Uint8Array(UKYC_LOCAL_USER_SECRET_SIZE_BYTES).fill(7); +const SECRET_BASE64 = bytesToBase64(SECRET_BYTES); + +/** + * Builds a stateful in-memory store adapter backed by a single value. + * + * @param initial - The initial stored base64 value. + * @returns The store plus jest spies for `get` / `set`. + */ +function makeStore(initial: string | null = null): { + store: UkycLocalUserSecretStore; + get: jest.Mock; + set: jest.Mock; +} { + let value = initial; + const get = jest.fn(async () => value); + const set = jest.fn(async (_path: string, next: string) => { + value = next; + }); + return { store: { get, set }, get, set }; +} + +describe('UKYC localUserSecret', () => { + describe('loadLocalUserSecret', () => { + it('returns null when no local_user_secret is stored', async () => { + const { store, get } = makeStore(null); + + expect(await loadLocalUserSecret(store)).toBeNull(); + expect(get).toHaveBeenCalledWith(UKYC_LOCAL_USER_SECRET_PATH, undefined); + }); + + it('decodes and returns the stored local_user_secret', async () => { + const { store } = makeStore(SECRET_BASE64); + + expect(await loadLocalUserSecret(store)).toStrictEqual(SECRET_BYTES); + }); + + it('forwards the entropy source id', async () => { + const { store, get } = makeStore(SECRET_BASE64); + + await loadLocalUserSecret(store, 'entropy-1'); + + expect(get).toHaveBeenCalledWith( + UKYC_LOCAL_USER_SECRET_PATH, + 'entropy-1', + ); + }); + + it('throws when the stored local_user_secret has an unexpected length', async () => { + const { store } = makeStore(bytesToBase64(new Uint8Array(16))); + + await expect(loadLocalUserSecret(store)).rejects.toThrow( + 'unexpected length', + ); + }); + }); + + describe('getOrCreateLocalUserSecret', () => { + it('returns the existing local_user_secret without generating a new one', async () => { + const { store, set } = makeStore(SECRET_BASE64); + + expect(await getOrCreateLocalUserSecret(store)).toStrictEqual( + SECRET_BYTES, + ); + expect(set).not.toHaveBeenCalled(); + }); + + it('generates and persists a new local_user_secret on first enrollment', async () => { + const { store, set } = makeStore(null); + + const result = await getOrCreateLocalUserSecret(store); + + expect(set).toHaveBeenCalledTimes(1); + const [path, persisted] = set.mock.calls[0]; + expect(path).toBe(UKYC_LOCAL_USER_SECRET_PATH); + // The persisted value round-trips to the returned bytes. + expect(result).toStrictEqual(base64ToBytes(persisted)); + expect(result).toHaveLength(UKYC_LOCAL_USER_SECRET_SIZE_BYTES); + }); + + it('converges on a competing value that won the write race', async () => { + const competing = new Uint8Array(UKYC_LOCAL_USER_SECRET_SIZE_BYTES).fill( + 9, + ); + // First read (existence check) misses; the re-read after our write sees a + // value another writer landed first. + const get = jest + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(bytesToBase64(competing)); + const set = jest.fn().mockResolvedValue(undefined); + + const result = await getOrCreateLocalUserSecret({ get, set }); + + expect(result).toStrictEqual(competing); + }); + + it('deduplicates concurrent create calls into a single generation', async () => { + const { store, set } = makeStore(null); + + const [a, b] = await Promise.all([ + getOrCreateLocalUserSecret(store), + getOrCreateLocalUserSecret(store), + ]); + + expect(a).toStrictEqual(b); + expect(set).toHaveBeenCalledTimes(1); + }); + + it('falls back to the generated secret if the re-read returns nothing', async () => { + // `get` always misses, even after the write, so the helper falls back to + // the value it just generated. + const get = jest.fn().mockResolvedValue(null); + const set = jest.fn().mockResolvedValue(undefined); + + const result = await getOrCreateLocalUserSecret({ get, set }); + + expect(result).toHaveLength(UKYC_LOCAL_USER_SECRET_SIZE_BYTES); + }); + }); + + describe('hasLocalUserSecret', () => { + it('returns true when a local_user_secret exists', async () => { + const { store } = makeStore(SECRET_BASE64); + + expect(await hasLocalUserSecret(store)).toBe(true); + }); + + it('returns false when no local_user_secret exists', async () => { + const { store } = makeStore(null); + + expect(await hasLocalUserSecret(store)).toBe(false); + }); + }); +}); diff --git a/packages/kyc-controller/src/ukyc/localUserSecret.ts b/packages/kyc-controller/src/ukyc/localUserSecret.ts new file mode 100644 index 00000000000..46583684e96 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/localUserSecret.ts @@ -0,0 +1,161 @@ +import { base64ToBytes, bytesToBase64 } from '@metamask/utils'; +import { randomBytes } from '@noble/hashes/utils'; + +import { + UKYC_LOCAL_USER_SECRET_PATH, + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, +} from './constants.js'; + +/** + * Orchestrates creation and loading of the UKYC `local_user_secret`. + * + * The `local_user_secret` is the root secret for all UKYC client-derived + * material. It is generated once, on first enrollment, and persisted to + * MetaMask Encrypted User Storage. It is never transmitted off the device, not + * even to the idOS Relay. Every subsequent value (`storage_id`, + * `data_encryption_key`, `signing_key`, `relay_tunnel_key`) is derived from it + * via HKDF — see `deriveClientMaterial`. + * + * This module is platform-agnostic: the Encrypted User Storage backing is + * injected as a {@link UkycLocalUserSecretStore} so the controller (which owns + * the messenger) supplies the concrete `UserStorageController` calls. + */ + +/** + * The Encrypted User Storage operations this module needs. On MetaMask clients + * these are backed by `UserStorageController:performGetStorage` / + * `performSetStorage`. + */ +export type UkycLocalUserSecretStore = { + /** + * Reads the base64 string stored at `path`, or `null` if none exists. + */ + get: (path: string, entropySourceId?: string) => Promise; + /** + * Writes the base64 string `value` at `path`. + */ + set: (path: string, value: string, entropySourceId?: string) => Promise; +}; + +/** + * In-flight `getOrCreateLocalUserSecret` calls, keyed by entropy source. + * Deduplicates concurrent enrollments in a single client session so we never + * generate and persist two competing `local_user_secret`s for the same source. + */ +const inFlightCreations = new Map>(); + +/** + * Loads the persisted `local_user_secret` from Encrypted User Storage, if one + * exists. + * + * @param store - The Encrypted User Storage adapter. + * @param entropySourceId - Optional HD keyring entropy source id, used to scope + * the secret to a specific SRP in multi-SRP wallets. Defaults to the primary SRP. + * @returns The decoded `local_user_secret` bytes, or `null` if none has been + * enrolled. + */ +export async function loadLocalUserSecret( + store: UkycLocalUserSecretStore, + entropySourceId?: string, +): Promise { + const stored = await store.get(UKYC_LOCAL_USER_SECRET_PATH, entropySourceId); + + if (!stored) { + return null; + } + + const localUserSecret = base64ToBytes(stored); + + if (localUserSecret.length !== UKYC_LOCAL_USER_SECRET_SIZE_BYTES) { + throw new Error( + `UKYC: stored local_user_secret has unexpected length ${localUserSecret.length}, expected ${UKYC_LOCAL_USER_SECRET_SIZE_BYTES}.`, + ); + } + + return localUserSecret; +} + +/** + * Persists a freshly generated `local_user_secret` to Encrypted User Storage. + * + * @param store - The Encrypted User Storage adapter. + * @param localUserSecret - The `local_user_secret` bytes to persist. + * @param entropySourceId - Optional HD keyring entropy source id. + */ +async function persistLocalUserSecret( + store: UkycLocalUserSecretStore, + localUserSecret: Uint8Array, + entropySourceId?: string, +): Promise { + await store.set( + UKYC_LOCAL_USER_SECRET_PATH, + bytesToBase64(localUserSecret), + entropySourceId, + ); +} + +/** + * Creates the UKYC `local_user_secret` if it does not already exist, otherwise + * loads the existing one. This is the single entry point used on UKYC + * enrollment. + * + * The operation is idempotent and safe against concurrent callers in the same + * session: repeated or parallel calls resolve to the same `local_user_secret` + * and never generate more than one secret for a given entropy source. + * + * @param store - The Encrypted User Storage adapter. + * @param entropySourceId - Optional HD keyring entropy source id, used to scope + * the secret to a specific SRP in multi-SRP wallets. Defaults to the primary SRP. + * @returns The `local_user_secret` bytes (existing or newly created). + */ +export async function getOrCreateLocalUserSecret( + store: UkycLocalUserSecretStore, + entropySourceId?: string, +): Promise { + const cacheKey = entropySourceId ?? ''; + + const pending = inFlightCreations.get(cacheKey); + if (pending) { + return pending; + } + + const creation = (async (): Promise => { + const existing = await loadLocalUserSecret(store, entropySourceId); + if (existing) { + return existing; + } + + const localUserSecret = randomBytes(UKYC_LOCAL_USER_SECRET_SIZE_BYTES); + await persistLocalUserSecret(store, localUserSecret, entropySourceId); + + // Re-read after persisting so that all callers converge on whatever value + // actually landed in storage (defends against a competing write that may + // have won the race, e.g. from another device syncing the same feature). + return ( + (await loadLocalUserSecret(store, entropySourceId)) ?? localUserSecret + ); + })(); + + inFlightCreations.set(cacheKey, creation); + + try { + return await creation; + } finally { + inFlightCreations.delete(cacheKey); + } +} + +/** + * Whether a `local_user_secret` has already been enrolled for the given entropy + * source. + * + * @param store - The Encrypted User Storage adapter. + * @param entropySourceId - Optional HD keyring entropy source id. + * @returns `true` if a `local_user_secret` exists in Encrypted User Storage. + */ +export async function hasLocalUserSecret( + store: UkycLocalUserSecretStore, + entropySourceId?: string, +): Promise { + return (await loadLocalUserSecret(store, entropySourceId)) !== null; +} diff --git a/packages/kyc-controller/src/ukyc/storageAccessToken.test.ts b/packages/kyc-controller/src/ukyc/storageAccessToken.test.ts new file mode 100644 index 00000000000..d0299e7c9ef --- /dev/null +++ b/packages/kyc-controller/src/ukyc/storageAccessToken.test.ts @@ -0,0 +1,241 @@ +import { base64ToBytes } from '@metamask/utils'; +import { ed25519 } from '@noble/curves/ed25519'; + +import { + UKYC_KWIL_AUDIENCE, + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, + UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, + UKYC_STORAGE_ACCESS_TOKEN_VERSION, +} from './constants.js'; +import { deriveClientMaterial } from './deriveClientMaterial.js'; +import { + canonicalizeJson, + encodeStorageAccessTokenForHeader, + signStorageAccessToken, +} from './storageAccessToken.js'; + +const LOCAL_USER_SECRET = new Uint8Array( + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, +).fill(42); +const MATERIAL = deriveClientMaterial(LOCAL_USER_SECRET); + +const ISSUED_AT = new Date('2026-07-07T00:00:00.000Z'); +const EXPIRES_AT = new Date('2026-07-07T04:00:00.000Z'); + +/** + * Decodes an unpadded base64url string back to bytes. + * + * @param value - The base64url string. + * @returns The decoded bytes. + */ +function fromBase64Url(value: string): Uint8Array { + const padded = value.padEnd(Math.ceil(value.length / 4) * 4, '='); + return base64ToBytes(padded.replace(/-/gu, '+').replace(/_/gu, '/')); +} + +describe('UKYC canonicalizeJson', () => { + it('sorts object keys by code unit', () => { + expect(canonicalizeJson({ b: 1, a: 2, c: 3 })).toBe('{"a":2,"b":1,"c":3}'); + }); + + it('preserves array order and emits no whitespace', () => { + expect(canonicalizeJson({ z: [3, 2, 1], a: 'x' })).toBe( + '{"a":"x","z":[3,2,1]}', + ); + }); + + it('drops undefined members', () => { + expect(canonicalizeJson({ a: 1, b: undefined, c: 2 })).toBe( + '{"a":1,"c":2}', + ); + }); + + it('serializes primitives', () => { + expect(canonicalizeJson(null)).toBe('null'); + expect(canonicalizeJson(true)).toBe('true'); + expect(canonicalizeJson(false)).toBe('false'); + expect(canonicalizeJson('hi')).toBe('"hi"'); + expect(canonicalizeJson(7)).toBe('7'); + }); + + it('rejects non-integer numbers', () => { + expect(() => canonicalizeJson(1.5)).toThrow('non-integer'); + }); +}); + +describe('UKYC signStorageAccessToken', () => { + it('mints a client-presented token with the expected payload', () => { + const token = signStorageAccessToken({ + material: MATERIAL, + operations: ['delete'], + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }); + + expect(token.payload).toStrictEqual({ + version: UKYC_STORAGE_ACCESS_TOKEN_VERSION, + aud: [UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, UKYC_KWIL_AUDIENCE], + storage_id: expect.stringMatching(/^[A-Za-z0-9_-]+$/u), + signing_public_key: expect.stringMatching(/^[A-Za-z0-9_-]+$/u), + operations: ['delete'], + presenter: 'client', + issued_at: '2026-07-07T00:00:00Z', + expires_at: '2026-07-07T04:00:00Z', + }); + expect(token.payload).not.toHaveProperty('session_id'); + }); + + it('formats timestamps as RFC 3339 with whole seconds, truncating sub-second precision', () => { + const token = signStorageAccessToken({ + material: MATERIAL, + operations: ['read'], + issuedAt: new Date('2026-07-07T00:00:00.715Z'), + expiresAt: new Date('2026-07-07T04:00:00.999Z'), + }); + + expect(token.payload.issued_at).toBe('2026-07-07T00:00:00Z'); + expect(token.payload.expires_at).toBe('2026-07-07T04:00:00Z'); + }); + + it('produces a signature that verifies against the signing public key', () => { + const token = signStorageAccessToken({ + material: MATERIAL, + operations: ['read', 'write'], + presenter: 'idos-relay', + sessionId: 'session-1', + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }); + + const message = new TextEncoder().encode(canonicalizeJson(token.payload)); + const signature = fromBase64Url(token.signature); + + expect(ed25519.verify(signature, message, MATERIAL.signingPublicKey)).toBe( + true, + ); + }); + + it('binds session_id for Relay-presented tokens', () => { + const token = signStorageAccessToken({ + material: MATERIAL, + operations: ['read'], + presenter: 'idos-relay', + sessionId: 'session-42', + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }); + + expect(token.payload.session_id).toBe('session-42'); + expect(token.payload.presenter).toBe('idos-relay'); + }); + + it('is deterministic for the same inputs', () => { + const params = { + material: MATERIAL, + operations: ['read' as const], + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }; + + expect(signStorageAccessToken(params)).toStrictEqual( + signStorageAccessToken(params), + ); + }); + + it('rejects a Relay presenter without a session_id', () => { + expect(() => + signStorageAccessToken({ + material: MATERIAL, + operations: ['read'], + presenter: 'idos-relay', + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }), + ).toThrow('requires a session_id'); + }); + + it('rejects delegating a delete token to the Relay', () => { + expect(() => + signStorageAccessToken({ + material: MATERIAL, + operations: ['delete'], + presenter: 'idos-relay', + sessionId: 'session-1', + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }), + ).toThrow('cannot be delegated to the Relay'); + }); + + it('rejects delete combined with other operations', () => { + expect(() => + signStorageAccessToken({ + material: MATERIAL, + operations: ['delete', 'read'], + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }), + ).toThrow('must contain only "delete"'); + }); + + it('rejects an empty operations list', () => { + expect(() => + signStorageAccessToken({ + material: MATERIAL, + operations: [], + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }), + ).toThrow('at least one operation'); + }); + + it('rejects duplicate operations', () => { + expect(() => + signStorageAccessToken({ + material: MATERIAL, + operations: ['read', 'read'], + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }), + ).toThrow('must be unique'); + }); + + it('rejects an expiry at or before issued_at', () => { + expect(() => + signStorageAccessToken({ + material: MATERIAL, + operations: ['read'], + issuedAt: EXPIRES_AT, + expiresAt: EXPIRES_AT, + }), + ).toThrow('expires_at must be after issued_at'); + }); + + it('defaults issuedAt to now when omitted', () => { + const token = signStorageAccessToken({ + material: MATERIAL, + operations: ['read'], + expiresAt: new Date(Date.now() + 60_000), + }); + + expect(token.payload.issued_at).toStrictEqual(expect.any(String)); + }); +}); + +describe('UKYC encodeStorageAccessTokenForHeader', () => { + it('encodes the envelope as unpadded base64url that round-trips', () => { + const token = signStorageAccessToken({ + material: MATERIAL, + operations: ['read'], + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }); + + const header = encodeStorageAccessTokenForHeader(token); + + expect(header).toMatch(/^[A-Za-z0-9_-]+$/u); + expect( + JSON.parse(new TextDecoder().decode(fromBase64Url(header))), + ).toStrictEqual(token); + }); +}); diff --git a/packages/kyc-controller/src/ukyc/storageAccessToken.ts b/packages/kyc-controller/src/ukyc/storageAccessToken.ts new file mode 100644 index 00000000000..abc9455a1e3 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/storageAccessToken.ts @@ -0,0 +1,262 @@ +import { stringToBytes } from '@metamask/utils'; +import { ed25519 } from '@noble/curves/ed25519'; + +import { + UKYC_STORAGE_ACCESS_TOKEN_AUDIENCES, + UKYC_STORAGE_ACCESS_TOKEN_VERSION, +} from './constants.js'; +import type { UkycClientMaterial } from './deriveClientMaterial.js'; +import { toBase64Url } from '../encoding.js'; + +/** + * Mints `storage_access_token` capabilities — the client-signed, scoped, + * session-bound proofs that authorize UKYC storage operations. See the + * architecture doc, section "Storage Authentication". + * + * The token is Ed25519 over RFC 8785 (JCS) canonical JSON of the payload. Only + * the client holds the private `signing_key`, so only the client can mint a + * token; a `read`/`write`-scoped token may then be handed to the Relay to + * present, but `delete` is never delegated. + */ + +/** + * Storage operations a `storage_access_token` can authorize. + */ +export type UkycStorageOperation = 'read' | 'write' | 'delete'; + +/** + * Who presents the token to UKYC storage. The Relay may only present + * `read`/`write` tokens; `delete` is always client-presented. + */ +export type UkycTokenPresenter = 'client' | 'idos-relay'; + +/** + * The signed `storage_access_token` payload. Field names are snake_case because + * they are canonicalized and hashed exactly as they appear on the wire. + */ +export type UkycStorageAccessTokenPayload = { + version: number; + /** Every verifier that may accept the token, e.g. UKYC Storage and idOS Kwil. */ + aud: string[]; + // Wire-shape fields are snake_case; they are canonicalized and signed exactly + // as they appear on the wire. + /* eslint-disable @typescript-eslint/naming-convention */ + storage_id: string; + signing_public_key: string; + operations: UkycStorageOperation[]; + presenter: UkycTokenPresenter; + /** UKYC session id. Required (and only present) when presenter is `idos-relay`. */ + session_id?: string; + issued_at: string; + expires_at: string; + /* eslint-enable @typescript-eslint/naming-convention */ +}; + +/** + * The on-the-wire envelope: the payload plus its detached Ed25519 signature + * (base64url) over the JCS canonicalization of the payload. + */ +export type UkycStorageAccessToken = { + payload: UkycStorageAccessTokenPayload; + signature: string; +}; + +/** + * Inputs for minting a `storage_access_token`. + */ +export type SignStorageAccessTokenParams = { + /** Client material derived from `local_user_secret`. */ + material: UkycClientMaterial; + /** Operations the token authorizes. `delete` must be the sole operation. */ + operations: UkycStorageOperation[]; + /** Who will present the token. Defaults to `client`. */ + presenter?: UkycTokenPresenter; + /** UKYC session id. Required when presenter is `idos-relay`. */ + sessionId?: string; + /** Token issue time. Defaults to now. */ + issuedAt?: Date; + /** Token expiry. Must be strictly after `issuedAt`. */ + expiresAt: Date; +}; + +type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue | undefined }; + +/** + * Serializes a JSON value to RFC 8785 (JCS) canonical form. + * + * Scope note: this implementation covers the JSON shapes used by UKYC storage + * payloads — objects, arrays, strings, integers, booleans, and null. Object + * members are sorted by their UTF-16 code units (matching JS default string + * ordering, which is what JCS requires) and `undefined` members are dropped. + * Non-finite and non-integer numbers are rejected, since the payloads never + * contain them and correct JCS number formatting for the general case is + * intentionally out of scope here. + * + * @param value - The value to canonicalize. + * @returns The canonical JSON string. + */ +export function canonicalizeJson(value: JsonValue): string { + if (value === null) { + return 'null'; + } + + if (typeof value === 'boolean') { + return value ? 'true' : 'false'; + } + + if (typeof value === 'number') { + if (!Number.isInteger(value)) { + throw new Error( + 'UKYC: cannot canonicalize a non-integer number for JCS.', + ); + } + return JSON.stringify(value); + } + + if (typeof value === 'string') { + return JSON.stringify(value); + } + + if (Array.isArray(value)) { + return `[${value.map((item) => canonicalizeJson(item)).join(',')}]`; + } + + const entries = Object.keys(value) + .sort() + .reduce((acc, key) => { + const child = value[key]; + if (child !== undefined) { + acc.push(`${JSON.stringify(key)}:${canonicalizeJson(child)}`); + } + return acc; + }, []); + + return `{${entries.join(',')}}`; +} + +/** + * Formats a date as RFC 3339 UTC with whole-second precision (e.g. + * `2026-07-07T00:00:00Z`). `Date.prototype.toISOString` always emits + * milliseconds (`...:00.000Z`); the `storage_access_token` wire format omits + * fractional seconds, so the sub-second component is truncated (not rounded). + * + * @param date - The date to format. + * @returns The RFC 3339 timestamp without fractional seconds. + */ +function toRfc3339Seconds(date: Date): string { + return `${date.toISOString().slice(0, 19)}Z`; +} + +/** + * Builds and signs a `storage_access_token`. + * + * @param params - See {@link SignStorageAccessTokenParams}. + * @returns The signed token envelope. + */ +export function signStorageAccessToken( + params: SignStorageAccessTokenParams, +): UkycStorageAccessToken { + const { + material, + operations, + presenter = 'client', + sessionId, + issuedAt = new Date(), + expiresAt, + } = params; + + assertValidOperations(operations); + + if (expiresAt.getTime() <= issuedAt.getTime()) { + throw new Error( + 'UKYC: storage_access_token expires_at must be after issued_at.', + ); + } + + const isDelete = operations.includes('delete'); + + if (presenter === 'idos-relay' && isDelete) { + throw new Error( + 'UKYC: a delete-scoped storage_access_token cannot be delegated to the Relay.', + ); + } + + if (presenter === 'idos-relay' && !sessionId) { + throw new Error( + 'UKYC: a Relay-presented storage_access_token requires a session_id.', + ); + } + + const payload: UkycStorageAccessTokenPayload = { + version: UKYC_STORAGE_ACCESS_TOKEN_VERSION, + aud: [...UKYC_STORAGE_ACCESS_TOKEN_AUDIENCES], + storage_id: toBase64Url(material.storageId), + signing_public_key: toBase64Url(material.signingPublicKey), + operations, + presenter, + issued_at: toRfc3339Seconds(issuedAt), + expires_at: toRfc3339Seconds(expiresAt), + }; + + // Only bind session_id for Relay-presented tokens; omit the key entirely for + // client-presented tokens so it does not appear in the canonicalized payload. + if (presenter === 'idos-relay') { + payload.session_id = sessionId; + } + + const message = stringToBytes(canonicalizeJson(payload)); + const signature = ed25519.sign(message, material.signingKey); + + return { + payload, + signature: toBase64Url(signature), + }; +} + +/** + * Serializes a signed token to a compact string suitable for header transport + * (`Authorization: AccessToken `, see `UKYC_CAPABILITY_AUTH_SCHEME`). The + * complete envelope is base64url-encoded; the private `signing_key` is never + * included. + * + * @param token - The signed token envelope. + * @returns The base64url-encoded envelope string. + */ +export function encodeStorageAccessTokenForHeader( + token: UkycStorageAccessToken, +): string { + return toBase64Url(stringToBytes(JSON.stringify(token))); +} + +/** + * Validates that an operations list is one storage understands: a non-empty set + * of `read`/`write`, or exactly `['delete']`. `delete` is never combined with + * other operations. + * + * @param operations - The requested operations. + */ +function assertValidOperations(operations: UkycStorageOperation[]): void { + if (operations.length === 0) { + throw new Error( + 'UKYC: storage_access_token requires at least one operation.', + ); + } + + const unique = new Set(operations); + + if (unique.size !== operations.length) { + throw new Error('UKYC: storage_access_token operations must be unique.'); + } + + if (unique.has('delete') && operations.length > 1) { + throw new Error( + 'UKYC: a delete-scoped storage_access_token must contain only "delete".', + ); + } +} diff --git a/packages/kyc-controller/src/ukyc/testToken.test.ts b/packages/kyc-controller/src/ukyc/testToken.test.ts new file mode 100644 index 00000000000..2784d87ba28 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/testToken.test.ts @@ -0,0 +1,133 @@ +import { ed25519 } from '@noble/curves/ed25519'; +import { hexToBytes, stringToBytes } from '@metamask/utils'; + +import { + UKYC_CAPABILITY_AUTH_SCHEME, + UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, + UKYC_KWIL_AUDIENCE, +} from './constants.js'; +import { canonicalizeJson } from './storageAccessToken.js'; +import { mintUkycTestToken } from './testToken.js'; +import { base64UrlToBytes } from '../encoding.js'; + +// A fixed 32-byte secret (all 0x42), as hex, so storage_id and keys are stable. +const SECRET_HEX = '42'.repeat(32); +const ISSUED_AT = new Date('2026-07-07T00:00:00Z'); +const EXPIRES_AT = new Date('2026-07-07T04:00:00Z'); + +/** + * Splits an `AccessToken ` header and decodes the credentials into the + * envelope, the way UKYC Storage does on the wire. + * + * @param header - The full Authorization header value. + * @returns The decoded token envelope. + */ +function decodeHeader(header: string): { + payload: Record; + signature: string; +} { + const [scheme, creds] = header.split(' '); + expect(scheme).toBe(UKYC_CAPABILITY_AUTH_SCHEME); + return JSON.parse(new TextDecoder().decode(base64UrlToBytes(creds))); +} + +describe('UKYC mintUkycTestToken', () => { + it('mints a client token from a hex secret with the derived identifiers', () => { + const result = mintUkycTestToken({ + localUserSecret: SECRET_HEX, + operations: ['read', 'write'], + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }); + + expect(result.localUserSecret).toBe(SECRET_HEX); + expect(result.token.payload).toMatchObject({ + version: 1, + aud: [UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, UKYC_KWIL_AUDIENCE], + operations: ['read', 'write'], + presenter: 'client', + issued_at: '2026-07-07T00:00:00Z', + expires_at: '2026-07-07T04:00:00Z', + }); + // storage_id / signing_public_key are the client-derived values, not + // anything a server fills in. + expect(result.storageId).toBe(result.token.payload.storage_id); + expect(result.signingPublicKey).toBe( + result.token.payload.signing_public_key, + ); + expect(result.token.payload).not.toHaveProperty('session_id'); + }); + + it('produces an Authorization header whose signature verifies (as UKYC Storage checks it)', () => { + const result = mintUkycTestToken({ + localUserSecret: SECRET_HEX, + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }); + + const envelope = decodeHeader(result.authorizationHeader); + const message = stringToBytes(canonicalizeJson(envelope.payload)); + const signature = base64UrlToBytes(envelope.signature); + const publicKey = base64UrlToBytes(result.signingPublicKey); + + expect(ed25519.verify(signature, message, publicKey)).toBe(true); + }); + + it('defaults operations to ["read"] and expiry to issued_at + 4h', () => { + const result = mintUkycTestToken({ + localUserSecret: SECRET_HEX, + issuedAt: ISSUED_AT, + }); + + expect(result.token.payload.operations).toStrictEqual(['read']); + expect(result.token.payload.issued_at).toBe('2026-07-07T00:00:00Z'); + expect(result.token.payload.expires_at).toBe('2026-07-07T04:00:00Z'); + }); + + it('accepts a raw byte secret and is deterministic for the same inputs', () => { + const secret = hexToBytes(SECRET_HEX); + const params = { + localUserSecret: secret, + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }; + + expect(mintUkycTestToken(params)).toStrictEqual(mintUkycTestToken(params)); + }); + + it('generates a fresh random secret when none is supplied', () => { + const result = mintUkycTestToken(); + + // 32 bytes hex-encoded. + expect(result.localUserSecret).toMatch(/^[0-9a-f]{64}$/u); + expect(result.token.payload.operations).toStrictEqual(['read']); + expect(result.authorizationHeader.startsWith( + `${UKYC_CAPABILITY_AUTH_SCHEME} `, + )).toBe(true); + }); + + it('binds session_id for a Relay-presented token', () => { + const result = mintUkycTestToken({ + localUserSecret: SECRET_HEX, + operations: ['read', 'write'], + presenter: 'idos-relay', + sessionId: 'session-1', + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }); + + expect(result.token.payload.presenter).toBe('idos-relay'); + expect(result.token.payload.session_id).toBe('session-1'); + }); + + it('rejects a Relay presenter without a session_id', () => { + expect(() => + mintUkycTestToken({ + localUserSecret: SECRET_HEX, + presenter: 'idos-relay', + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }), + ).toThrow('requires a session_id'); + }); +}); diff --git a/packages/kyc-controller/src/ukyc/testToken.ts b/packages/kyc-controller/src/ukyc/testToken.ts new file mode 100644 index 00000000000..055d1b08448 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/testToken.ts @@ -0,0 +1,132 @@ +import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils'; + +import { + UKYC_CAPABILITY_AUTH_SCHEME, + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, +} from './constants.js'; +import { + deriveClientMaterial, + encodeClientMaterial, +} from './deriveClientMaterial.js'; +import { + encodeStorageAccessTokenForHeader, + signStorageAccessToken, +} from './storageAccessToken.js'; +import type { + UkycStorageAccessToken, + UkycStorageOperation, + UkycTokenPresenter, +} from './storageAccessToken.js'; + +/** + * Mints ready-to-use UKYC `storage_access_token`s for testing UKYC Storage. + * + * This composes the same pure functions the MetaMask client uses in production + * (`deriveClientMaterial` + `signStorageAccessToken`), so a third party such as + * idOS can produce a valid, signed token without deriving one on-device. No + * server "fills in" the `signing_public_key`: it is derived from a + * `local_user_secret` the caller controls and registered on first write, so + * reusing the same secret yields a stable `storage_id` and controlling key. + */ + +/** Default token lifetime when `expiresAt` is not supplied (4 hours). */ +const DEFAULT_TOKEN_LIFETIME_MS = 4 * 60 * 60 * 1000; + +/** + * Inputs for {@link mintUkycTestToken}. All fields are optional; the only value + * a caller usually pins is `localUserSecret`, so `storage_id` and the signing + * key stay stable across runs. + */ +export type MintUkycTestTokenParams = { + /** + * The root `local_user_secret`, as raw 32 bytes or a hex string. When omitted + * a fresh random secret is generated (returned in the result so it can be + * reused). + */ + localUserSecret?: Uint8Array | string; + /** Operations the token authorizes. Defaults to `['read']`. */ + operations?: UkycStorageOperation[]; + /** Who will present the token. Defaults to `client`. */ + presenter?: UkycTokenPresenter; + /** UKYC session id. Required when `presenter` is `idos-relay`. */ + sessionId?: string; + /** Token issue time. Defaults to now. */ + issuedAt?: Date; + /** Token expiry. Defaults to `issuedAt` + 4 hours. */ + expiresAt?: Date; +}; + +/** + * The minted token plus everything needed to exercise UKYC Storage with it. + */ +export type MintedUkycTestToken = { + /** The `local_user_secret` used, hex-encoded, so the caller can reuse it. */ + localUserSecret: string; + /** base64url `storage_id` — use it as the `{storage_id}` path segment. */ + storageId: string; + /** base64url `signing_public_key` registered on first write. */ + signingPublicKey: string; + /** The signed token envelope (payload + signature). */ + token: UkycStorageAccessToken; + /** + * The full `Authorization` header value, e.g. + * `AccessToken `, ready to send to UKYC Storage. + */ + authorizationHeader: string; +}; + +/** + * Resolves the caller-supplied secret into raw bytes, generating a random one + * when none is provided. + * + * @param secret - Raw 32 bytes, a hex string, or undefined for a random secret. + * @returns The `local_user_secret` bytes. + */ +function resolveLocalUserSecret(secret?: Uint8Array | string): Uint8Array { + if (secret === undefined) { + return randomBytes(UKYC_LOCAL_USER_SECRET_SIZE_BYTES); + } + return typeof secret === 'string' ? hexToBytes(secret) : secret; +} + +/** + * Mints a signed UKYC `storage_access_token` for testing. + * + * @param params - See {@link MintUkycTestTokenParams}. + * @returns The token, its `Authorization` header, and the derived identifiers. + */ +export function mintUkycTestToken( + params: MintUkycTestTokenParams = {}, +): MintedUkycTestToken { + const { + operations = ['read'], + presenter, + sessionId, + issuedAt = new Date(), + expiresAt = new Date(issuedAt.getTime() + DEFAULT_TOKEN_LIFETIME_MS), + } = params; + + const localUserSecret = resolveLocalUserSecret(params.localUserSecret); + const material = deriveClientMaterial(localUserSecret); + + const token = signStorageAccessToken({ + material, + operations, + presenter, + sessionId, + issuedAt, + expiresAt, + }); + + const { storageId, signingPublicKey } = encodeClientMaterial(material); + + return { + localUserSecret: bytesToHex(localUserSecret), + storageId, + signingPublicKey, + token, + authorizationHeader: `${UKYC_CAPABILITY_AUTH_SCHEME} ${encodeStorageAccessTokenForHeader( + token, + )}`, + }; +} diff --git a/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts new file mode 100644 index 00000000000..bfd3225d27b --- /dev/null +++ b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts @@ -0,0 +1,90 @@ +import { areUint8ArraysEqual } from '@metamask/utils'; +import { box } from 'tweetnacl'; + +import { base64UrlToBytes, toBase64Url } from '../encoding.js'; +import { wrapEncryptionKey } from './wrapEncryptionKey.js'; + +const DATA_ENCRYPTION_KEY = new Uint8Array(32).fill(7); + +/** + * Reverses {@link wrapEncryptionKey} from the server's perspective: opens the + * NaCl box using the server private key + client public key. + * + * @param serverPrivateKey - The server's X25519 private key. + * @param clientPublicKey - The client's X25519 public key. + * @param encryptedKey - The base64url ciphertext (+ tag). + * @param nonce - The base64url nonce. + * @returns The recovered plaintext key. + */ +function unwrap( + serverPrivateKey: Uint8Array, + clientPublicKey: Uint8Array, + encryptedKey: string, + nonce: string, +): Uint8Array { + const recovered = box.open( + base64UrlToBytes(encryptedKey), + base64UrlToBytes(nonce), + clientPublicKey, + serverPrivateKey, + ); + if (recovered === null) { + throw new Error('Failed to open NaCl box'); + } + return recovered; +} + +describe('UKYC wrapEncryptionKey', () => { + it('wraps a key the session server can recover', () => { + const serverKeyPair = box.keyPair(); + const clientKeyPair = box.keyPair(); + + const { encryptedKey, nonce } = wrapEncryptionKey( + clientKeyPair.secretKey, + toBase64Url(serverKeyPair.publicKey), + DATA_ENCRYPTION_KEY, + ); + + const recovered = unwrap( + serverKeyPair.secretKey, + clientKeyPair.publicKey, + encryptedKey, + nonce, + ); + expect(areUint8ArraysEqual(recovered, DATA_ENCRYPTION_KEY)).toBe(true); + }); + + it('emits base64url fields', () => { + const serverPublicKey = box.keyPair().publicKey; + const clientPrivateKey = box.keyPair().secretKey; + + const { encryptedKey, nonce } = wrapEncryptionKey( + clientPrivateKey, + toBase64Url(serverPublicKey), + DATA_ENCRYPTION_KEY, + ); + + expect(encryptedKey).toMatch(/^[A-Za-z0-9\-_]+$/u); + expect(nonce).toMatch(/^[A-Za-z0-9\-_]+$/u); + }); + + it('uses a fresh nonce per call', () => { + const serverPublicKey = box.keyPair().publicKey; + const clientPrivateKey = box.keyPair().secretKey; + const serverPublicKeyB64 = toBase64Url(serverPublicKey); + + const first = wrapEncryptionKey( + clientPrivateKey, + serverPublicKeyB64, + DATA_ENCRYPTION_KEY, + ); + const second = wrapEncryptionKey( + clientPrivateKey, + serverPublicKeyB64, + DATA_ENCRYPTION_KEY, + ); + + expect(first.nonce).not.toBe(second.nonce); + expect(first.encryptedKey).not.toBe(second.encryptedKey); + }); +}); diff --git a/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts new file mode 100644 index 00000000000..04c6a10cfc4 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts @@ -0,0 +1,56 @@ +import { randomBytes, box } from 'tweetnacl'; + +import { base64UrlToBytes, toBase64Url } from '../encoding.js'; + +/** + * Wraps the `data_encryption_key` for the UKYC session server using NaCl's + * `crypto_box` (X25519 + XSalsa20-Poly1305) established with the server's + * per-session wrapping key. + * + * Unlike {@link wrapUserKey} (which generates a fresh ephemeral keypair per + * call), this uses the session client keypair whose public half was already + * handed to the server via `POST /wrapping-key`. The server therefore already + * knows our public key and can derive the same shared secret from its session + * private key, so only `{ encryptedKey, nonce }` need be transmitted. + */ + +/** + * The transmitted portion of a wrapped encryption key: the `crypto_box` + * ciphertext (which includes the 16-byte Poly1305 auth tag) and the nonce, + * both unpadded base64url-encoded. + */ +export type WrappedEncryptionKeyParts = { + encryptedKey: string; + nonce: string; +}; + +/** + * Wraps `keyToWrap` for the UKYC session server. + * + * The box is sealed with NaCl's `crypto_box`, keyed by the X25519 shared secret + * between our session client private key and the session server public key + * returned by `getWrappingKey`. + * + * @param sessionClientPrivateKey - Our session's X25519 private key. + * @param sessionServerPublicKey - The server's X25519 public key (base64url). + * @param keyToWrap - The raw symmetric key bytes to encrypt. + * @returns The base64url `encryptedKey` (ciphertext + tag) and `nonce`. + */ +export function wrapEncryptionKey( + sessionClientPrivateKey: Uint8Array, + sessionServerPublicKey: string, + keyToWrap: Uint8Array, +): WrappedEncryptionKeyParts { + const serverPublicKey = base64UrlToBytes(sessionServerPublicKey); + const nonce = randomBytes(box.nonceLength); + const encryptedKey = box( + keyToWrap, + nonce, + serverPublicKey, + sessionClientPrivateKey, + ); + return { + encryptedKey: toBase64Url(encryptedKey), + nonce: toBase64Url(nonce), + }; +} diff --git a/packages/kyc-controller/src/ukyc/wrapUserKey.test.ts b/packages/kyc-controller/src/ukyc/wrapUserKey.test.ts new file mode 100644 index 00000000000..3842792c254 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/wrapUserKey.test.ts @@ -0,0 +1,114 @@ +import { areUint8ArraysEqual, base64ToBytes } from '@metamask/utils'; +import { gcm } from '@noble/ciphers/aes'; +import { x25519 } from '@noble/curves/ed25519'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha2'; +import { bytesToHex } from '@noble/hashes/utils'; + +import { wrapUserKey } from './wrapUserKey.js'; + +const X25519_KEY_SIZE_BYTES = 32; +const IV_SIZE_BYTES = 12; + +const DATA_ENCRYPTION_KEY = new Uint8Array(32).fill(7); + +/** + * Decodes the unpadded base64url blob produced by {@link wrapUserKey}. + * + * @param value - The base64url-encoded wrapped key. + * @returns The raw blob bytes. + */ +function fromBase64Url(value: string): Uint8Array { + return base64ToBytes( + value + .replace(/-/gu, '+') + .replace(/_/gu, '/') + .padEnd(value.length + ((4 - (value.length % 4)) % 4), '='), + ); +} + +/** + * Reverses {@link wrapUserKey} with the recipient's private key. + * + * @param blob - The base64url wrapped key. + * @param recipientPrivateKey - The recipient's X25519 private key. + * @returns The recovered plaintext key bytes. + */ +function unwrapUserKey( + blob: string, + recipientPrivateKey: Uint8Array, +): Uint8Array { + const bytes = fromBase64Url(blob); + const ephemeralPublicKey = bytes.slice(0, X25519_KEY_SIZE_BYTES); + const iv = bytes.slice( + X25519_KEY_SIZE_BYTES, + X25519_KEY_SIZE_BYTES + IV_SIZE_BYTES, + ); + const ciphertext = bytes.slice(X25519_KEY_SIZE_BYTES + IV_SIZE_BYTES); + + const shared = x25519.getSharedSecret( + recipientPrivateKey, + ephemeralPublicKey, + ); + const aeadKey = hkdf(sha256, shared, undefined, undefined, 32); + return gcm(aeadKey, iv).decrypt(ciphertext); +} + +describe('UKYC wrapUserKey', () => { + it('produces an unpadded base64url blob', () => { + const privateKey = x25519.utils.randomSecretKey(); + const publicKey = x25519.getPublicKey(privateKey); + + const wrapped = wrapUserKey(bytesToHex(publicKey), DATA_ENCRYPTION_KEY); + + expect(wrapped).toMatch(/^[A-Za-z0-9_-]+$/u); + expect(wrapped).not.toContain('='); + }); + + it('wraps a key the recipient can recover (hex public key)', () => { + const privateKey = x25519.utils.randomSecretKey(); + const publicKey = x25519.getPublicKey(privateKey); + + const wrapped = wrapUserKey(bytesToHex(publicKey), DATA_ENCRYPTION_KEY); + const recovered = unwrapUserKey(wrapped, privateKey); + + expect(areUint8ArraysEqual(recovered, DATA_ENCRYPTION_KEY)).toBe(true); + }); + + it('accepts a base64url-encoded public key', () => { + const privateKey = x25519.utils.randomSecretKey(); + const publicKey = x25519.getPublicKey(privateKey); + const publicKeyBase64Url = Buffer.from(publicKey) + .toString('base64') + .replace(/\+/gu, '-') + .replace(/\//gu, '_') + .replace(/[=]+$/u, ''); + + const wrapped = wrapUserKey(publicKeyBase64Url, DATA_ENCRYPTION_KEY); + const recovered = unwrapUserKey(wrapped, privateKey); + + expect(areUint8ArraysEqual(recovered, DATA_ENCRYPTION_KEY)).toBe(true); + }); + + it('produces a fresh ephemeral key (non-deterministic output) per call', () => { + const privateKey = x25519.utils.randomSecretKey(); + const publicKey = x25519.getPublicKey(privateKey); + + const first = wrapUserKey(bytesToHex(publicKey), DATA_ENCRYPTION_KEY); + const second = wrapUserKey(bytesToHex(publicKey), DATA_ENCRYPTION_KEY); + + expect(first).not.toStrictEqual(second); + expect( + areUint8ArraysEqual( + unwrapUserKey(first, privateKey), + unwrapUserKey(second, privateKey), + ), + ).toBe(true); + }); + + it('rejects a public key of the wrong length', () => { + expect(() => wrapUserKey('abcd', DATA_ENCRYPTION_KEY)).toThrow( + 'unexpected length', + ); + }); +}); diff --git a/packages/kyc-controller/src/ukyc/wrapUserKey.ts b/packages/kyc-controller/src/ukyc/wrapUserKey.ts new file mode 100644 index 00000000000..f694d6310e8 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/wrapUserKey.ts @@ -0,0 +1,101 @@ +import { base64ToBytes } from '@metamask/utils'; +import { gcm } from '@noble/ciphers/aes'; +import { x25519 } from '@noble/curves/ed25519'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha2'; +import { hexToBytes, randomBytes } from '@noble/hashes/utils'; + +import { toBase64Url } from '../encoding.js'; + +/** + * Wraps (encrypts) a symmetric key so that only the holder of a given X25519 + * private key can recover it — used to seal the `data_encryption_key` for the + * idOS Relay before it is handed to the UKYC API as `wrappedUserKey`. + */ + +/** X25519 public/private keys and AES-256-GCM keys are all 32 bytes. */ +const X25519_KEY_SIZE_BYTES = 32; + +/** 96-bit IV, the AES-GCM standard nonce size. */ +const IV_SIZE_BYTES = 12; + +/** + * Decodes an X25519 public key presented as either hex or base64/base64url. + * + * @param recipientPublicKey - The recipient's 32-byte X25519 public key. + * @returns The raw 32-byte public key. + */ +function decodePublicKey(recipientPublicKey: string): Uint8Array { + const isHex = + recipientPublicKey.length === X25519_KEY_SIZE_BYTES * 2 && + /^[0-9a-fA-F]+$/u.test(recipientPublicKey); + + const bytes = isHex + ? hexToBytes(recipientPublicKey) + : base64ToBytes( + recipientPublicKey + .replace(/-/gu, '+') + .replace(/_/gu, '/') + .padEnd( + recipientPublicKey.length + + ((4 - (recipientPublicKey.length % 4)) % 4), + '=', + ), + ); + + if (bytes.length !== X25519_KEY_SIZE_BYTES) { + throw new Error( + `UKYC: wrappingPublicKey has unexpected length ${bytes.length}, expected ${X25519_KEY_SIZE_BYTES}.`, + ); + } + return bytes; +} + +/** + * Wraps `keyToWrap` for the holder of `recipientPublicKey` using an + * ephemeral-static ECDH + AES-256-GCM sealed-box scheme: + * + * ephemeral = fresh X25519 keypair (one per call) + * shared = X25519(ephemeralPrivate, recipientPublic) + * aeadKey = HKDF-SHA256(shared, 32 bytes) + * iv = 12 random bytes + * ct = AES-256-GCM(aeadKey, iv).encrypt(keyToWrap) // ct includes tag + * + * The recipient reverses it with their private key: + * + * shared = X25519(recipientPrivate, ephemeralPublic) + * aeadKey = HKDF-SHA256(shared, 32 bytes) + * key = AES-256-GCM(aeadKey, iv).decrypt(ct) + * + * This mirrors the X25519 + AES-256-GCM/HKDF decryption used for MoonPay + * Check/Auth-frame credentials, so both directions share one primitive. + * + * @param recipientPublicKey - The recipient's X25519 public key (hex or base64). + * @param keyToWrap - The raw symmetric key bytes to encrypt (e.g. the + * `data_encryption_key`). + * @returns Base64url of `ephemeralPublicKey(32) || iv(12) || ciphertext+tag`. + */ +export function wrapUserKey( + recipientPublicKey: string, + keyToWrap: Uint8Array, +): string { + const recipient = decodePublicKey(recipientPublicKey); + + const ephemeralPrivateKey = x25519.utils.randomSecretKey(); + const ephemeralPublicKey = x25519.getPublicKey(ephemeralPrivateKey); + + const shared = x25519.getSharedSecret(ephemeralPrivateKey, recipient); + const aeadKey = hkdf(sha256, shared, undefined, undefined, 32); + + const iv = randomBytes(IV_SIZE_BYTES); + const ciphertext = gcm(aeadKey, iv).encrypt(keyToWrap); + + const blob = new Uint8Array( + ephemeralPublicKey.length + iv.length + ciphertext.length, + ); + blob.set(ephemeralPublicKey, 0); + blob.set(iv, ephemeralPublicKey.length); + blob.set(ciphertext, ephemeralPublicKey.length + iv.length); + + return toBase64Url(blob); +} diff --git a/packages/kyc-controller/src/ukyc/wrappedRelayPayload.test.ts b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.test.ts new file mode 100644 index 00000000000..bfc422dd7fa --- /dev/null +++ b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.test.ts @@ -0,0 +1,90 @@ +import { UKYC_LOCAL_USER_SECRET_SIZE_BYTES } from './constants.js'; +import { deriveClientMaterial } from './deriveClientMaterial.js'; +import type { UkycStorageAccessToken } from './storageAccessToken.js'; +import { signStorageAccessToken } from './storageAccessToken.js'; +import { buildWrappedRelayPayload } from './wrappedRelayPayload.js'; + +const LOCAL_USER_SECRET = new Uint8Array( + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, +).fill(42); +const MATERIAL = deriveClientMaterial(LOCAL_USER_SECRET); +const ISSUED_AT = new Date('2026-07-07T00:00:00.000Z'); +const EXPIRES_AT = new Date('2026-07-07T04:00:00.000Z'); + +/** + * Mints a token with a given presenter/operations for the tests below. + * + * @param presenter - The token presenter. + * @param operations - The token operations. + * @returns The signed token. + */ +function tokenFor( + presenter: 'client' | 'idos-relay', + operations: ('read' | 'write' | 'delete')[], +): UkycStorageAccessToken { + return signStorageAccessToken({ + material: MATERIAL, + operations, + presenter, + sessionId: presenter === 'idos-relay' ? 'session-1' : undefined, + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }); +} + +describe('UKYC buildWrappedRelayPayload', () => { + it('bundles the Relay-facing material with the token', () => { + const token = tokenFor('idos-relay', ['read', 'write']); + + const payload = buildWrappedRelayPayload(MATERIAL, token); + + expect(payload.storage_id).toMatch(/^[A-Za-z0-9_-]+$/u); + expect(payload.data_encryption_key).toMatch(/^[A-Za-z0-9_-]+$/u); + expect(payload.signing_public_key).toMatch(/^[A-Za-z0-9_-]+$/u); + expect(payload.storage_access_token).toBe(token); + }); + + it('shares the data_encryption_key with the Relay', () => { + const token = tokenFor('idos-relay', ['read']); + + const payload = buildWrappedRelayPayload(MATERIAL, token); + + // The DEK is intentionally included so the Relay can encrypt/decrypt. + expect(payload.data_encryption_key.length).toBeGreaterThan(0); + }); + + it('never leaks the local secret or private signing key', () => { + const token = tokenFor('idos-relay', ['read']); + + const payload = buildWrappedRelayPayload(MATERIAL, token); + + expect(Object.keys(payload).sort()).toStrictEqual([ + 'data_encryption_key', + 'signing_public_key', + 'storage_access_token', + 'storage_id', + ]); + }); + + it('rejects a client-presented token', () => { + const token = tokenFor('client', ['read']); + + expect(() => buildWrappedRelayPayload(MATERIAL, token)).toThrow( + 'requires a Relay-presented storage_access_token', + ); + }); + + it('rejects a delete-scoped token', () => { + // A delete token cannot be Relay-presented, so craft one that slips past + // signing by mutating the presenter after the fact. + const token = tokenFor('client', ['delete']); + const relayDeleteToken = { + ...token, + payload: { ...token.payload, presenter: 'idos-relay' as const }, + }; + + expect(() => buildWrappedRelayPayload(MATERIAL, relayDeleteToken)).toThrow( + 'must not carry a delete-scoped storage_access_token', + ); + }); +}); diff --git a/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts new file mode 100644 index 00000000000..ae28ec4ec2d --- /dev/null +++ b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts @@ -0,0 +1,65 @@ +import type { UkycClientMaterial } from './deriveClientMaterial.js'; +import { toBase64Url } from '../encoding.js'; +import type { UkycStorageAccessToken } from './storageAccessToken.js'; + +/** + * Builds the `wrapped_relay_payload` — the only client-derived material that + * leaves the device. It is sent to the idOS Relay (through the UKYC API) so the + * Relay can encrypt/decrypt and store KYC payloads on the user's behalf. See the + * architecture doc, section "Client-Derived Material". + * + * Neither `local_user_secret` nor the private `signing_key` are ever included. + * The `data_encryption_key` *is* included: it is intentionally shared with the + * Relay so it can encrypt/decrypt payloads transiently. + */ + +/** + * The Relay-facing bundle. `data_encryption_key` is base64url-encoded secret + * material shared with the Relay; the other fields are non-secret. + */ +export type UkycWrappedRelayPayload = { + // Wire-shape fields are snake_case. + /* eslint-disable @typescript-eslint/naming-convention */ + storage_id: string; + data_encryption_key: string; + signing_public_key: string; + storage_access_token: UkycStorageAccessToken; + /* eslint-enable @typescript-eslint/naming-convention */ +}; + +/** + * Assembles the `wrapped_relay_payload` from derived client material and a + * Relay-presented `storage_access_token`. + * + * The token must be scoped for the Relay to present (`presenter: 'idos-relay'`) + * and must not authorize `delete`, which is never delegated to the Relay. + * + * @param material - Client material derived from `local_user_secret`. + * @param storageAccessToken - A `read`/`write`-scoped, Relay-presented token. + * @returns The `wrapped_relay_payload` to send to the Relay via the UKYC API. + */ +export function buildWrappedRelayPayload( + material: UkycClientMaterial, + storageAccessToken: UkycStorageAccessToken, +): UkycWrappedRelayPayload { + const { presenter, operations } = storageAccessToken.payload; + + if (presenter !== 'idos-relay') { + throw new Error( + 'UKYC: wrapped_relay_payload requires a Relay-presented storage_access_token.', + ); + } + + if (operations.includes('delete')) { + throw new Error( + 'UKYC: wrapped_relay_payload must not carry a delete-scoped storage_access_token.', + ); + } + + return { + storage_id: toBase64Url(material.storageId), + data_encryption_key: toBase64Url(material.dataEncryptionKey), + signing_public_key: toBase64Url(material.signingPublicKey), + storage_access_token: storageAccessToken, + }; +} diff --git a/packages/kyc-controller/src/wallet-registration-machine.test.ts b/packages/kyc-controller/src/wallet-registration-machine.test.ts new file mode 100644 index 00000000000..de58a81ef12 --- /dev/null +++ b/packages/kyc-controller/src/wallet-registration-machine.test.ts @@ -0,0 +1,297 @@ +import { + createInitialState, + transition, +} from './wallet-registration-machine.js'; +import type { + WalletRegistrationEvent, + WalletRegistrationState, +} from './wallet-registration-machine.js'; + +const run = ( + events: WalletRegistrationEvent[], + initial: WalletRegistrationState = createInitialState(), +): WalletRegistrationState => + events.reduce((state, event) => transition(state, event), initial); + +describe('wallet registration machine: lookup', () => { + it('starts idle', () => { + expect(createInitialState().status).toBe('idle'); + }); + + it('start moves idle to preparing', () => { + expect(run([{ type: 'START' }]).status).toBe('preparing'); + }); + + it('an active existing registration skips signing and completes', () => { + const state = run([{ type: 'START' }, { type: 'LOOKUP_ACTIVE' }]); + expect(state.status).toBe('alreadyRegistered'); + }); + + it('a disabled existing registration enters registeredDisabled', () => { + const state = run([{ type: 'START' }, { type: 'LOOKUP_DISABLED' }]); + expect(state.status).toBe('registeredDisabled'); + }); + + it('an absent registration proceeds to signing', () => { + const state = run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }]); + expect(state.status).toBe('signing'); + }); + + it('a failed lookup enters lookupUnavailable and never assumes absent', () => { + const state = run([{ type: 'START' }, { type: 'LOOKUP_FAILED' }]); + expect(state.status).toBe('lookupUnavailable'); + }); +}); + +describe('wallet registration machine: signing', () => { + const atSigning = (): WalletRegistrationState => + run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }]); + + it('a locked keyring during signing waits then resumes the same attempt', () => { + const locked = transition(atSigning(), { type: 'WALLET_LOCKED' }); + expect(locked.status).toBe('awaitingUnlock'); + + const resumed = transition(locked, { type: 'WALLET_UNLOCKED' }); + expect(resumed.status).toBe('signing'); + }); + + it('successful signing moves to submitting', () => { + expect(transition(atSigning(), { type: 'SIGN_OK' }).status).toBe( + 'submitting', + ); + }); + + it('explicit user rejection reaches cancelled', () => { + expect(transition(atSigning(), { type: 'SIGN_REJECTED' }).status).toBe( + 'cancelled', + ); + }); + + it('classifies signing failures as retryable or terminal', () => { + expect( + transition(atSigning(), { type: 'SIGN_FAILED', retryable: true }).status, + ).toBe('failedRetryable'); + expect( + transition(atSigning(), { type: 'SIGN_FAILED', retryable: false }).status, + ).toBe('failedTerminal'); + }); + + it('cancellation during signing aborts without failing', () => { + expect(transition(atSigning(), { type: 'CANCEL' }).status).toBe( + 'cancelled', + ); + }); +}); + +describe('wallet registration machine: submitting outcomes', () => { + const atSubmitting = (): WalletRegistrationState => + run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }, { type: 'SIGN_OK' }]); + + it('200 reaches registered', () => { + expect(transition(atSubmitting(), { type: 'SUBMIT_OK' }).status).toBe( + 'registered', + ); + }); + + it('any 409 enters disambiguate409', () => { + expect( + transition(atSubmitting(), { + type: 'SUBMIT_CONFLICT', + }).status, + ).toBe('disambiguate409'); + }); + + it('timeout / 5xx enters checkThenRetry', () => { + expect( + transition(atSubmitting(), { type: 'SUBMIT_TRANSIENT' }).status, + ).toBe('checkThenRetry'); + }); + + it('a UTC-rollover 400 rebuilds and re-signs once', () => { + expect( + transition(atSubmitting(), { + type: 'SUBMIT_VALIDATION', + utcRollover: true, + }).status, + ).toBe('signing'); + }); + + it('a non-rollover 400 is terminal', () => { + expect( + transition(atSubmitting(), { + type: 'SUBMIT_VALIDATION', + utcRollover: false, + }).status, + ).toBe('failedTerminal'); + }); + + it('401 / 403 / 404 are terminal', () => { + expect(transition(atSubmitting(), { type: 'SUBMIT_TERMINAL' }).status).toBe( + 'failedTerminal', + ); + }); + + it('429 becomes retryable', () => { + expect( + transition(atSubmitting(), { type: 'SUBMIT_RATE_LIMITED' }).status, + ).toBe('failedRetryable'); + }); + + it('cancellation during submitting aborts without failing', () => { + expect(transition(atSubmitting(), { type: 'CANCEL' }).status).toBe( + 'cancelled', + ); + }); +}); + +describe('wallet registration machine: 409 disambiguation', () => { + const atDisambiguate = (): WalletRegistrationState => + run([ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'SIGN_OK' }, + { type: 'SUBMIT_CONFLICT' }, + ]); + + it('an active list match after 409 completes as alreadyRegistered', () => { + expect(transition(atDisambiguate(), { type: 'LOOKUP_ACTIVE' }).status).toBe( + 'alreadyRegistered', + ); + }); + + it('a disabled list match after 409 enters registeredDisabled', () => { + expect( + transition(atDisambiguate(), { type: 'LOOKUP_DISABLED' }).status, + ).toBe('registeredDisabled'); + }); + + it('a 409 plus GET miss is retryable', () => { + expect(transition(atDisambiguate(), { type: 'LOOKUP_ABSENT' }).status).toBe( + 'failedRetryable', + ); + }); + + it('a failed GET during disambiguation is lookupUnavailable', () => { + expect(transition(atDisambiguate(), { type: 'LOOKUP_FAILED' }).status).toBe( + 'lookupUnavailable', + ); + }); + + it('cancellation during disambiguation does not become a failure', () => { + expect(transition(atDisambiguate(), { type: 'CANCEL' }).status).toBe( + 'cancelled', + ); + }); +}); + +describe('wallet registration machine: checkThenRetry after 5xx/timeout', () => { + const atCheck = ( + initial?: WalletRegistrationState, + ): WalletRegistrationState => + run( + [ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'SIGN_OK' }, + { type: 'SUBMIT_TRANSIENT' }, + ], + initial, + ); + + it('a GET showing the resource completes without another POST', () => { + expect(transition(atCheck(), { type: 'LOOKUP_ACTIVE' }).status).toBe( + 'alreadyRegistered', + ); + }); + + it('a disabled GET result enters registeredDisabled', () => { + expect(transition(atCheck(), { type: 'LOOKUP_DISABLED' }).status).toBe( + 'registeredDisabled', + ); + }); + + it('an absent GET result retries signing within the attempt ceiling', () => { + expect(transition(atCheck(), { type: 'LOOKUP_ABSENT' }).status).toBe( + 'signing', + ); + }); + + it('a failed GET during reconciliation is lookupUnavailable', () => { + expect(transition(atCheck(), { type: 'LOOKUP_FAILED' }).status).toBe( + 'lookupUnavailable', + ); + }); + + it('stops retrying once the attempt ceiling is reached', () => { + let state = createInitialState(); + state = run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }], state); + // Loop sign -> transient -> absent until the ceiling flips to retryable. + for (let i = 0; i < 5; i++) { + if (state.status === 'signing') { + state = transition(state, { type: 'SIGN_OK' }); + state = transition(state, { type: 'SUBMIT_TRANSIENT' }); + state = transition(state, { type: 'LOOKUP_ABSENT' }); + } + } + expect(state.status).toBe('failedRetryable'); + }); + + it('cancellation during checkThenRetry does not become a failure', () => { + expect(transition(atCheck(), { type: 'CANCEL' }).status).toBe('cancelled'); + }); +}); + +describe('wallet registration machine: retry, resume, and concurrency', () => { + it('retry from failedRetryable re-checks server state via preparing', () => { + const state = run([ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'SIGN_OK' }, + { type: 'SUBMIT_RATE_LIMITED' }, + { type: 'RETRY' }, + ]); + expect(state.status).toBe('preparing'); + }); + + it('retry from lookupUnavailable re-checks server state via preparing', () => { + const state = run([ + { type: 'START' }, + { type: 'LOOKUP_FAILED' }, + { type: 'RETRY' }, + ]); + expect(state.status).toBe('preparing'); + }); + + it('retry from cancelled restarts via preparing', () => { + const state = run([ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'CANCEL' }, + { type: 'RETRY' }, + ]); + expect(state.status).toBe('preparing'); + }); + + it('a second START while in-flight is ignored (one operation)', () => { + const inFlight = run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }]); + expect(inFlight.status).toBe('signing'); + expect(transition(inFlight, { type: 'START' }).status).toBe('signing'); + }); + + it('ignores events that do not apply to the current state', () => { + const preparing = run([{ type: 'START' }]); + expect(transition(preparing, { type: 'SUBMIT_OK' }).status).toBe( + 'preparing', + ); + }); + + it('terminal success states ignore further events', () => { + const registered = run([ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'SIGN_OK' }, + { type: 'SUBMIT_OK' }, + ]); + expect(transition(registered, { type: 'RETRY' }).status).toBe('registered'); + }); +}); diff --git a/packages/kyc-controller/src/wallet-registration-machine.ts b/packages/kyc-controller/src/wallet-registration-machine.ts new file mode 100644 index 00000000000..3251c56d02e --- /dev/null +++ b/packages/kyc-controller/src/wallet-registration-machine.ts @@ -0,0 +1,221 @@ +/** + * Pure, hand-rolled finite state machine for the MoonPay Iron self-hosted + * wallet registration signing step. It follows the FSM convention used + * elsewhere in `core` (no XState dependency): a single pure `transition` + * reducer plus a data-driven transition table. + * + * Side effects (server lookups, signing, POSTing) live in the interpreter that + * drives this machine; every effect result is fed back in as an event, so the + * machine itself stays deterministic and trivially testable. + */ + +/** Every state in the signing step. */ +export type WalletRegistrationStatus = + | 'idle' + | 'preparing' + | 'awaitingUnlock' + | 'signing' + | 'submitting' + | 'disambiguate409' + | 'checkThenRetry' + | 'lookupUnavailable' + | 'registered' + | 'alreadyRegistered' + | 'registeredDisabled' + | 'failedRetryable' + | 'failedTerminal' + | 'cancelled'; + +/** Machine context carried across transitions. */ +export type WalletRegistrationContext = { + /** Number of sign attempts made so far (used for the retry ceiling). */ + attempts: number; + /** Maximum number of sign attempts before a retryable failure is surfaced. */ + maxAttempts: number; +}; + +export type WalletRegistrationState = { + status: WalletRegistrationStatus; + context: WalletRegistrationContext; +}; + +/** Events the interpreter dispatches into the machine. */ +export type WalletRegistrationEvent = + | { type: 'START' } + | { type: 'WALLET_LOCKED' } + | { type: 'WALLET_UNLOCKED' } + | { type: 'LOOKUP_ACTIVE' } + | { type: 'LOOKUP_DISABLED' } + | { type: 'LOOKUP_ABSENT' } + | { type: 'LOOKUP_FAILED' } + | { type: 'SIGN_OK' } + | { type: 'SIGN_REJECTED' } + | { type: 'SIGN_FAILED'; retryable: boolean } + | { type: 'SUBMIT_OK' } + | { type: 'SUBMIT_CONFLICT' } + | { type: 'SUBMIT_TRANSIENT' } + | { type: 'SUBMIT_VALIDATION'; utcRollover: boolean } + | { type: 'SUBMIT_TERMINAL' } + | { type: 'SUBMIT_RATE_LIMITED' } + | { type: 'RETRY' } + | { type: 'CANCEL' }; + +type EventType = WalletRegistrationEvent['type']; + +type Handler = ( + state: WalletRegistrationState, + event: WalletRegistrationEvent, +) => WalletRegistrationState; + +const DEFAULT_MAX_ATTEMPTS = 3; + +/** + * Creates the initial idle state. + * + * @param maxAttempts - Optional retry ceiling for sign attempts. + * @returns A fresh idle machine state. + */ +export function createInitialState( + maxAttempts: number = DEFAULT_MAX_ATTEMPTS, +): WalletRegistrationState { + return { status: 'idle', context: { attempts: 0, maxAttempts } }; +} + +/** + * Builds a handler that moves to a status while preserving context. + * + * @param status - Target status. + * @returns A handler transitioning to `status`. + */ +function keep(status: WalletRegistrationStatus): Handler { + return (state) => ({ status, context: state.context }); +} + +/** + * Builds a handler that moves to a status and resets the retry context. Used + * when the user (or app resume) starts a fresh attempt from scratch. + * + * @param status - Target status. + * @returns A handler transitioning to `status` with reset context. + */ +function reset(status: WalletRegistrationStatus): Handler { + return (state) => ({ + status, + context: { ...state.context, attempts: 0 }, + }); +} + +/** + * Moves to `signing` and counts this as a new sign attempt. + * + * @param state - Current state. + * @returns The `signing` state with an incremented attempt count. + */ +const toSigning: Handler = (state) => ({ + status: 'signing', + context: { ...state.context, attempts: state.context.attempts + 1 }, +}); + +const toPreparing = reset('preparing'); +const toAlreadyRegistered = keep('alreadyRegistered'); +const toRegisteredDisabled = keep('registeredDisabled'); +const toLookupUnavailable = keep('lookupUnavailable'); +const toCancelled = keep('cancelled'); + +const signFailed: Handler = (state, event) => { + const { retryable } = event as Extract< + WalletRegistrationEvent, + { type: 'SIGN_FAILED' } + >; + return retryable + ? keep('failedRetryable')(state, event) + : keep('failedTerminal')(state, event); +}; + +const submitValidation: Handler = (state, event) => { + const { utcRollover } = event as Extract< + WalletRegistrationEvent, + { type: 'SUBMIT_VALIDATION' } + >; + return utcRollover && state.context.attempts < state.context.maxAttempts + ? toSigning(state, event) + : keep('failedTerminal')(state, event); +}; + +const checkThenRetryAbsent: Handler = (state, event) => + state.context.attempts < state.context.maxAttempts + ? toSigning(state, event) + : keep('failedRetryable')(state, event); + +const TABLE: Partial< + Record>> +> = { + idle: { + START: toPreparing, + }, + preparing: { + LOOKUP_ACTIVE: toAlreadyRegistered, + LOOKUP_DISABLED: toRegisteredDisabled, + LOOKUP_ABSENT: toSigning, + LOOKUP_FAILED: toLookupUnavailable, + }, + awaitingUnlock: { + WALLET_UNLOCKED: keep('signing'), + }, + signing: { + SIGN_OK: keep('submitting'), + SIGN_REJECTED: toCancelled, + SIGN_FAILED: signFailed, + WALLET_LOCKED: keep('awaitingUnlock'), + CANCEL: toCancelled, + }, + submitting: { + SUBMIT_OK: keep('registered'), + SUBMIT_CONFLICT: keep('disambiguate409'), + SUBMIT_TRANSIENT: keep('checkThenRetry'), + SUBMIT_VALIDATION: submitValidation, + SUBMIT_TERMINAL: keep('failedTerminal'), + SUBMIT_RATE_LIMITED: keep('failedRetryable'), + CANCEL: toCancelled, + }, + disambiguate409: { + LOOKUP_ACTIVE: toAlreadyRegistered, + LOOKUP_DISABLED: toRegisteredDisabled, + LOOKUP_ABSENT: keep('failedRetryable'), + LOOKUP_FAILED: toLookupUnavailable, + CANCEL: toCancelled, + }, + checkThenRetry: { + LOOKUP_ACTIVE: toAlreadyRegistered, + LOOKUP_DISABLED: toRegisteredDisabled, + LOOKUP_ABSENT: checkThenRetryAbsent, + LOOKUP_FAILED: toLookupUnavailable, + CANCEL: toCancelled, + }, + failedRetryable: { + RETRY: toPreparing, + }, + lookupUnavailable: { + RETRY: toPreparing, + }, + cancelled: { + RETRY: toPreparing, + }, +}; + +/** + * Pure transition reducer. Unhandled (state, event) pairs are no-ops, which is + * how the machine enforces "one in-flight operation" (a second `START` while + * busy is ignored) and how terminal states stay put. + * + * @param state - Current machine state. + * @param event - Event to apply. + * @returns The next state (or the same state for unhandled events). + */ +export function transition( + state: WalletRegistrationState, + event: WalletRegistrationEvent, +): WalletRegistrationState { + const handler = TABLE[state.status]?.[event.type]; + return handler ? handler(state, event) : state; +} diff --git a/packages/kyc-controller/src/wallet-registration-service.test.ts b/packages/kyc-controller/src/wallet-registration-service.test.ts new file mode 100644 index 00000000000..a8ee7540d23 --- /dev/null +++ b/packages/kyc-controller/src/wallet-registration-service.test.ts @@ -0,0 +1,530 @@ +import { + WalletRegistrationError, + WalletRegistrationService, +} from './wallet-registration-service.js'; + +const BASE_URL = 'https://proxy.metamask.test'; +const AUTH_TOKEN = 'session-jwt-abc'; + +type FetchInit = { + method?: string; + headers: Record; + body?: string; +}; + +type HttpResponse = { + ok: boolean; + status: number; + json: () => Promise; + text: () => Promise; +}; + +type FetchLike = ( + url: string, + init?: { + method?: string; + headers?: Record; + body?: string; + signal?: unknown; + }, +) => Promise; + +const jsonResponse = (status: number, body: unknown): HttpResponse => ({ + ok: status >= 200 && status < 300, + status, + json: async (): Promise => body, + text: async (): Promise => JSON.stringify(body), +}); + +const textResponse = (status: number, body: string): HttpResponse => ({ + ok: status >= 200 && status < 300, + status, + json: async (): Promise => JSON.parse(body), + text: async (): Promise => body, +}); + +const invalidJsonResponse = (status: number): HttpResponse => ({ + ok: status >= 200 && status < 300, + status, + json: async (): Promise => { + throw new Error('invalid json'); + }, + text: async (): Promise => 'not json', +}); + +const buildService = (fetchImpl: FetchLike): WalletRegistrationService => + new WalletRegistrationService({ + fetch: fetchImpl, + baseUrl: BASE_URL, + getAuthToken: async (): Promise => AUTH_TOKEN, + }); + +const verifiedAddress = ( + overrides: Record = {}, +): Record => ({ + id: 'addr-1', + wallet_address: '0xAbC0000000000000000000000000000000000001', + blockchain: 'Monad', + address_type: 'SelfHosted', + disabled: false, + is_self: true, + proof_message: 'I am verifying ownership...', + proof_signature: '0xsig', + created_at: '2026-08-12T10:00:00Z', + ...overrides, +}); + +const EVM_ADDRESS = '0xAbC0000000000000000000000000000000000001'; + +describe('WalletRegistrationService.getMoonpayCustomerId', () => { + it('returns the Iron customer id from the authenticated proxy lookup', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, { customerId: 'iron-customer-1' }), + ); + + expect(await buildService(fetchMock).getMoonpayCustomerId()).toBe( + 'iron-customer-1', + ); + + expect(fetchMock).toHaveBeenCalledWith( + `${BASE_URL}/vendors/moonpay/customer`, + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + authorization: `Bearer ${AUTH_TOKEN}`, + }), + }), + ); + }); + + it('maps a failed customer lookup to a typed HTTP error', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(404, { code: 'iron_error', message: 'not found' }), + ); + + await expect( + buildService(fetchMock).getMoonpayCustomerId(), + ).rejects.toMatchObject({ kind: 'notFound', httpStatus: 404 }); + }); + + it('rejects malformed customer lookup responses', async () => { + await expect( + buildService( + jest.fn(async (): Promise => invalidJsonResponse(200)), + ).getMoonpayCustomerId(), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + + await expect( + buildService( + jest.fn(async (): Promise => jsonResponse(200, {})), + ).getMoonpayCustomerId(), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); +}); + +describe('WalletRegistrationService.getRegistrationStatus', () => { + it('calls the MetaMask proxy list endpoint (not Iron) with the session token', async () => { + const fetchMock = jest.fn( + async (): Promise => jsonResponse(200, []), + ); + const service = buildService(fetchMock); + + await service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, FetchInit]; + expect(url).toBe(`${BASE_URL}/vendors/moonpay/self-hosted-wallets`); + expect(url).not.toContain('iron.xyz'); + expect(init.method).toBe('GET'); + expect(init.headers.authorization).toBe(`Bearer ${AUTH_TOKEN}`); + }); + + it('returns an active match parsed from wallet_address', async () => { + const fetchMock = jest.fn( + async (): Promise => jsonResponse(200, [verifiedAddress()]), + ); + const service = buildService(fetchMock); + + const status = await service.getRegistrationStatus({ + address: '0xabc0000000000000000000000000000000000001', + blockchain: 'Monad', + }); + + expect(status).toMatchObject({ + type: 'active', + registration: { address: EVM_ADDRESS, disabled: false }, + }); + }); + + it('returns a disabled result when the matching address is disabled', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, [verifiedAddress({ disabled: true })]), + ); + const service = buildService(fetchMock); + + const status = await service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }); + + expect(status.type).toBe('disabled'); + }); + + it('scopes matching per blockchain (same address, different chain is absent)', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, [verifiedAddress({ blockchain: 'Ethereum' })]), + ); + const service = buildService(fetchMock); + + const status = await service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }); + + expect(status.type).toBe('absent'); + }); + + it('skips entries whose wallet_address is not a string', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, [ + { id: 'junk', wallet_address: 12345, blockchain: 'Monad' }, + verifiedAddress(), + ]), + ); + const service = buildService(fetchMock); + + const status = await service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }); + + expect(status.type).toBe('active'); + }); + + it('throws malformedResponse when the list body is not valid JSON', async () => { + const fetchMock = jest.fn( + async (): Promise => invalidJsonResponse(200), + ); + const service = buildService(fetchMock); + + await expect( + service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); + + it('throws a lookupUnavailable error on a non-2xx list response', async () => { + const fetchMock = jest.fn( + async (): Promise => textResponse(500, 'boom'), + ); + const service = buildService(fetchMock); + + await expect( + service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }), + ).rejects.toMatchObject({ kind: 'lookupUnavailable' }); + }); + + it('throws a lookupUnavailable error when the list body is malformed', async () => { + const fetchMock = jest.fn( + async (): Promise => jsonResponse(200, { nope: true }), + ); + const service = buildService(fetchMock); + + await expect( + service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }), + ).rejects.toBeInstanceOf(WalletRegistrationError); + }); + + it('never converts a network failure during lookup into "absent"', async () => { + const fetchMock = jest + .fn, unknown[]>() + .mockRejectedValue(new Error('network down')); + const service = buildService(fetchMock); + + await expect( + service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }), + ).rejects.toMatchObject({ kind: 'lookupUnavailable' }); + }); + + it('handles a non-Error thrown during lookup', async () => { + const fetchMock = jest + .fn, unknown[]>() + .mockRejectedValue('string failure'); + const service = buildService(fetchMock); + + await expect( + service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }), + ).rejects.toMatchObject({ kind: 'lookupUnavailable' }); + }); +}); + +const registerRequest = { + customerId: '019ff69c-3039-77b0-9d5d-e4a3baefd7b7', + address: EVM_ADDRESS, + blockchain: 'Monad' as const, + message: 'I am verifying ownership ...', + signature: '0xdeadbeef', +}; + +const selfHostedResponse = ( + overrides: Record = {}, +): Record => ({ + id: 'wallet-1', + address: EVM_ADDRESS, + customer_id: '019ff69c-3039-77b0-9d5d-e4a3baefd7b7', + disabled: false, + signature: '0xdeadbeef', + created_at: '2026-08-12T10:00:00Z', + ...overrides, +}); + +const errorEnvelope = (status: number, message: string): HttpResponse => + jsonResponse(status, { + code: 'iron_error', + message, + }); + +describe('WalletRegistrationService.registerSelfHostedWallet', () => { + it('sends the five contract fields via POST and returns registered on 200', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, selfHostedResponse()), + ); + const service = buildService(fetchMock); + + const outcome = await service.registerSelfHostedWallet(registerRequest); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, FetchInit]; + expect(url).toBe(`${BASE_URL}/vendors/moonpay/self-hosted-wallets`); + expect(url).not.toContain('iron.xyz'); + expect(init.method).toBe('POST'); + expect(init.headers.authorization).toBe(`Bearer ${AUTH_TOKEN}`); + expect(JSON.parse(init.body ?? '{}')).toStrictEqual({ + customer_id: registerRequest.customerId, + address: registerRequest.address, + blockchain: 'Monad', + message: registerRequest.message, + signature: registerRequest.signature, + }); + expect(outcome.registration).toMatchObject({ + id: 'wallet-1', + address: registerRequest.address, + disabled: false, + }); + }); + + it('does not send an idempotency key (the backend derives it)', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, selfHostedResponse()), + ); + const service = buildService(fetchMock); + + await service.registerSelfHostedWallet(registerRequest); + + const [, init] = fetchMock.mock.calls[0] as [string, FetchInit]; + const headerKeys = Object.keys(init.headers).map((key) => + key.toLowerCase(), + ); + expect(headerKeys).not.toContain('idempotency-key'); + expect(JSON.parse(init.body ?? '{}')).not.toHaveProperty('idempotencyKey'); + }); + + it('maps any 409 to an ambiguous conflict error carrying the body', async () => { + const fetchMock = jest.fn( + async (): Promise => + errorEnvelope( + 409, + 'A crypto address with this wallet address already exists', + ), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ + kind: 'conflict', + httpStatus: 409, + body: 'A crypto address with this wallet address already exists', + }); + }); + + it('maps 5xx to a transient error', async () => { + const fetchMock = jest.fn( + async (): Promise => errorEnvelope(500, 'internal error'), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'transient', httpStatus: 500 }); + }); + + it('maps a network failure / timeout to a transient error', async () => { + const fetchMock = jest + .fn, unknown[]>() + .mockRejectedValue(new Error('ETIMEDOUT')); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'transient' }); + }); + + it('maps a non-Error thrown during registration to transient', async () => { + const fetchMock = jest + .fn, unknown[]>() + .mockRejectedValue('socket hang up'); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'transient' }); + }); + + it('maps 400 to a validation error', async () => { + const fetchMock = jest.fn( + async (): Promise => errorEnvelope(400, 'bad message'), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'validation', httpStatus: 400 }); + }); + + it('maps an unmapped 4xx (422) to a validation error', async () => { + const fetchMock = jest.fn( + async (): Promise => errorEnvelope(422, 'unprocessable'), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'validation', httpStatus: 422 }); + }); + + it('maps 401 to unauthorized', async () => { + const fetchMock = jest.fn( + async (): Promise => errorEnvelope(401, 'session expired'), + ); + + await expect( + buildService(fetchMock).registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'unauthorized' }); + }); + + it('maps 403 to forbidden and 404 to notFound', async () => { + const forbiddenFetch = jest.fn( + async (): Promise => errorEnvelope(403, 'suspended'), + ); + const notFoundFetch = jest.fn( + async (): Promise => errorEnvelope(404, 'not found'), + ); + + const forbidden = await buildService(forbiddenFetch) + .registerSelfHostedWallet(registerRequest) + .catch((error: unknown): WalletRegistrationError => { + return error as WalletRegistrationError; + }); + const notFound = await buildService(notFoundFetch) + .registerSelfHostedWallet(registerRequest) + .catch((error: unknown): WalletRegistrationError => { + return error as WalletRegistrationError; + }); + + expect(forbidden).toMatchObject({ kind: 'forbidden' }); + expect(notFound).toMatchObject({ kind: 'notFound' }); + }); + + it('maps an error envelope without a code', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(403, { message: 'forbidden' }), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'forbidden' }); + }); + + it('maps 429 to a rateLimited error', async () => { + const fetchMock = jest.fn( + async (): Promise => errorEnvelope(429, 'slow down'), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'rateLimited' }); + }); + + it('maps a malformed 200 body to malformedResponse', async () => { + const fetchMock = jest.fn( + async (): Promise => jsonResponse(200, { nope: true }), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); + + it('rejects a success body that has an id but no address', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, { id: 'wallet-1', disabled: false }), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); + + it('maps a non-JSON success body to malformedResponse', async () => { + const fetchMock = jest.fn( + async (): Promise => invalidJsonResponse(200), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); + + it('maps a non-JSON error body to malformedResponse', async () => { + const fetchMock = jest.fn( + async (): Promise => invalidJsonResponse(400), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); +}); diff --git a/packages/kyc-controller/src/wallet-registration-service.ts b/packages/kyc-controller/src/wallet-registration-service.ts new file mode 100644 index 00000000000..08d118d3b8c --- /dev/null +++ b/packages/kyc-controller/src/wallet-registration-service.ts @@ -0,0 +1,372 @@ +/** The only blockchain supported by the Money Account POC. */ +export type Blockchain = 'Monad'; + +/** Normalized view of a single registered self-hosted address. */ +export type SelfHostedRegistration = { + id: string; + address: string; + blockchain: Blockchain; + disabled: boolean; + isSelf: boolean; +}; + +/** Result of reconciling a wallet against the customer's registered addresses. */ +export type RegistrationStatus = + | { type: 'active'; registration: SelfHostedRegistration } + | { type: 'disabled'; registration: SelfHostedRegistration } + | { type: 'absent' }; + +/** + * Discriminated error kinds surfaced to the state machine. Every non-success + * path maps to exactly one of these so the machine can decide deterministically. + */ +export type WalletRegistrationErrorKind = + | 'validation' + | 'unauthorized' + | 'forbidden' + | 'notFound' + | 'conflict' + | 'rateLimited' + | 'transient' + | 'lookupUnavailable' + | 'malformedResponse'; + +/** Minimal HTTP response shape, so the service is environment-agnostic. */ +type HttpResponse = { + ok: boolean; + status: number; + json: () => Promise; + text: () => Promise; +}; + +/** Minimal `fetch`-like function the service depends on. */ +type FetchLike = ( + url: string, + init?: { + method?: string; + headers?: Record; + body?: string; + }, +) => Promise; + +/** Typed error carrying enough context for state transitions. */ +export class WalletRegistrationError extends Error { + readonly kind: WalletRegistrationErrorKind; + + readonly httpStatus?: number; + + readonly body?: string; + + constructor( + kind: WalletRegistrationErrorKind, + options: { + message?: string; + httpStatus?: number; + body?: string; + }, + ) { + super(options.message ?? `wallet registration failed: ${kind}`); + this.name = 'WalletRegistrationError'; + this.kind = kind; + this.httpStatus = options.httpStatus; + this.body = options.body; + } +} + +export type WalletRegistrationServiceOptions = { + fetch: FetchLike; + baseUrl: string; + getAuthToken: () => Promise; +}; + +export type GetRegistrationStatusRequest = { + address: string; + blockchain: Blockchain; +}; + +export type RegisterSelfHostedWalletRequest = { + customerId: string; + address: string; + blockchain: Blockchain; + message: string; + signature: string; +}; + +/** Successful registration outcome. */ +export type RegistrationOutcome = { + type: 'registered'; + registration: SelfHostedRegistration; +}; + +const SELF_HOSTED_PATH = '/vendors/moonpay/self-hosted-wallets'; +const MOONPAY_CUSTOMER_PATH = '/vendors/moonpay/customer'; + +/** + * Normalizes a Monad EVM address for case-insensitive comparison. + * + * @param address - Raw address string. + * @returns The comparison key for the address. + */ +function normalizeAddress(address: string): string { + return address.toLowerCase(); +} + +/** + * Maps an HTTP status to the typed error kind the state machine reacts to. + * + * @param status - HTTP status code from the proxy/Iron response. + * @returns The corresponding error kind. + */ +function mapStatusToKind(status: number): WalletRegistrationErrorKind { + switch (status) { + case 400: + return 'validation'; + case 401: + return 'unauthorized'; + case 403: + return 'forbidden'; + case 404: + return 'notFound'; + case 409: + return 'conflict'; + case 429: + return 'rateLimited'; + default: + return status >= 500 ? 'transient' : 'validation'; + } +} + +/** + * Data service that talks to the MetaMask backend proxy for MoonPay Iron + * self-hosted wallet registration. It never calls Iron directly, so the Iron + * API key never ships in the client. + */ +export class WalletRegistrationService { + readonly #fetch: FetchLike; + + readonly #baseUrl: string; + + readonly #getAuthToken: () => Promise; + + constructor(options: WalletRegistrationServiceOptions) { + this.#fetch = options.fetch; + this.#baseUrl = options.baseUrl.replace(/\/$/u, ''); + this.#getAuthToken = options.getAuthToken; + } + + /** + * Resolves Iron's internal customer id from the authenticated MetaMask + * profile. Used when the current KYC flow has not already received + * `customer.id` from MoonPay's hosted frame. + * + * @returns Iron's internal customer id. + */ + async getMoonpayCustomerId(): Promise { + const token = await this.#getAuthToken(); + const response = await this.#fetch( + `${this.#baseUrl}${MOONPAY_CUSTOMER_PATH}`, + { + method: 'GET', + headers: { + accept: 'application/json', + authorization: `Bearer ${token}`, + }, + }, + ); + + if (!response.ok) { + throw await this.#toHttpError(response); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new WalletRegistrationError('malformedResponse', { + message: 'MoonPay customer body was not valid JSON', + }); + } + + const { customerId } = payload as { customerId?: unknown }; + if (typeof customerId !== 'string' || customerId.length === 0) { + throw new WalletRegistrationError('malformedResponse', { + message: 'MoonPay customer body missing customerId', + }); + } + return customerId; + } + + /** + * Reconciles a wallet against the customer's registered self-hosted addresses. + * A failed or malformed lookup is reported as `lookupUnavailable` and never + * downgraded to `absent`. + * + * @param request - Monad address to reconcile. + * @returns The active / disabled / absent status for the address. + */ + async getRegistrationStatus( + request: GetRegistrationStatusRequest, + ): Promise { + const { address, blockchain } = request; + + let response: HttpResponse; + try { + const token = await this.#getAuthToken(); + response = await this.#fetch(`${this.#baseUrl}${SELF_HOSTED_PATH}`, { + method: 'GET', + headers: { + accept: 'application/json', + authorization: `Bearer ${token}`, + }, + }); + } catch (error) { + throw new WalletRegistrationError('lookupUnavailable', { + message: 'self-hosted address lookup failed', + body: error instanceof Error ? error.message : undefined, + }); + } + + if (!response.ok) { + const body = await response.text(); + throw new WalletRegistrationError('lookupUnavailable', { + httpStatus: response.status, + body, + }); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new WalletRegistrationError('malformedResponse', { + message: 'self-hosted address list body was not valid JSON', + }); + } + if (!Array.isArray(payload)) { + throw new WalletRegistrationError('malformedResponse', { + message: 'expected an array of registered addresses', + }); + } + + const target = normalizeAddress(address); + const match = payload.find((entry) => { + const record = entry as Record; + const walletAddress = record.wallet_address; + if (typeof walletAddress !== 'string') { + return false; + } + return ( + normalizeAddress(walletAddress) === target && + record.blockchain === blockchain + ); + }) as Record | undefined; + + if (!match) { + return { type: 'absent' }; + } + + const registration = this.#toRegistration(match); + return registration.disabled + ? { type: 'disabled', registration } + : { type: 'active', registration }; + } + + /** + * Registers a self-hosted wallet through the MetaMask proxy. The proxy + * resolves the customer, derives the idempotency key, and attaches the API + * version, so the client never manages those. Every non-2xx response is + * mapped to a typed error; `409` is deliberately surfaced as an ambiguous + * `conflict` that the caller must reconcile with a follow-up status lookup. + * + * @param request - Customer id, address, blockchain, message, and signature. + * @returns The registered outcome on success. + */ + async registerSelfHostedWallet( + request: RegisterSelfHostedWalletRequest, + ): Promise { + let response: HttpResponse; + try { + const token = await this.#getAuthToken(); + response = await this.#fetch(`${this.#baseUrl}${SELF_HOSTED_PATH}`, { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + customer_id: request.customerId, + address: request.address, + blockchain: request.blockchain, + message: request.message, + signature: request.signature, + }), + }); + } catch (error) { + throw new WalletRegistrationError('transient', { + message: 'self-hosted registration request failed', + body: error instanceof Error ? error.message : undefined, + }); + } + + if (!response.ok) { + throw await this.#toHttpError(response); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new WalletRegistrationError('malformedResponse', { + message: 'registration success body was not valid JSON', + }); + } + + const record = payload as Record; + if (typeof record.id !== 'string' || typeof record.address !== 'string') { + throw new WalletRegistrationError('malformedResponse', { + message: 'registration success body missing id/address', + }); + } + + return { + type: 'registered', + registration: { + id: record.id, + address: record.address, + blockchain: request.blockchain, + disabled: Boolean(record.disabled), + isSelf: true, + }, + }; + } + + async #toHttpError(response: HttpResponse): Promise { + let envelope: { message?: string }; + try { + envelope = (await response.json()) as { message?: string }; + } catch { + return new WalletRegistrationError('malformedResponse', { + httpStatus: response.status, + message: 'error body was not valid JSON', + }); + } + + const { status } = response; + const kind = mapStatusToKind(status); + return new WalletRegistrationError(kind, { + httpStatus: status, + body: envelope.message, + }); + } + + #toRegistration(record: Record): SelfHostedRegistration { + return { + id: String(record.id), + address: String(record.wallet_address), + blockchain: 'Monad', + disabled: Boolean(record.disabled), + isSelf: Boolean(record.is_self), + }; + } +} diff --git a/packages/kyc-controller/tsconfig.build.json b/packages/kyc-controller/tsconfig.build.json index 02a0eea03fe..d355169e16c 100644 --- a/packages/kyc-controller/tsconfig.build.json +++ b/packages/kyc-controller/tsconfig.build.json @@ -5,6 +5,13 @@ "outDir": "./dist", "rootDir": "./src" }, - "references": [], + "references": [ + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../base-data-service/tsconfig.build.json" }, + { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../geolocation-controller/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" }, + { "path": "../profile-sync-controller/tsconfig.build.json" } + ], "include": ["../../types", "./src"] } diff --git a/packages/kyc-controller/tsconfig.json b/packages/kyc-controller/tsconfig.json index 025ba2ef7f4..1079229158f 100644 --- a/packages/kyc-controller/tsconfig.json +++ b/packages/kyc-controller/tsconfig.json @@ -3,6 +3,13 @@ "compilerOptions": { "baseUrl": "./" }, - "references": [], - "include": ["../../types", "./src"] + "references": [ + { "path": "../base-controller" }, + { "path": "../base-data-service" }, + { "path": "../controller-utils" }, + { "path": "../geolocation-controller" }, + { "path": "../messenger" }, + { "path": "../profile-sync-controller" } + ], + "include": ["../../types", "./src", "./scripts"] } diff --git a/yarn.lock b/yarn.lock index c6b4cce7c4c..cc94a5d4955 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7684,12 +7684,30 @@ __metadata: resolution: "@metamask/kyc-controller@workspace:packages/kyc-controller" dependencies: "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/base-data-service": "npm:^0.1.3" + "@metamask/controller-utils": "npm:^12.3.0" + "@metamask/geolocation-controller": "npm:^1.0.0" + "@metamask/keyring-controller": "npm:^27.1.1" + "@metamask/messenger": "npm:^2.0.0" + "@metamask/profile-sync-controller": "npm:^29.0.0" + "@metamask/superstruct": "npm:^3.4.1" + "@metamask/utils": "npm:^11.11.0" + "@noble/ciphers": "npm:^1.3.0" + "@noble/curves": "npm:^1.9.2" + "@noble/hashes": "npm:^1.8.0" + "@scure/base": "npm:^1.2.6" + "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" + chokidar-cli: "npm:^3.0.0" deepmerge: "npm:^4.2.2" jest: "npm:^30.4.2" + nock: "npm:^13.3.1" + reselect: "npm:^5.1.1" ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" + tweetnacl: "npm:^1.0.3" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" @@ -11014,7 +11032,7 @@ __metadata: languageName: node linkType: hard -"@scure/base@npm:^1.0.0, @scure/base@npm:^1.1.1, @scure/base@npm:^1.1.3, @scure/base@npm:~1.2.5": +"@scure/base@npm:^1.0.0, @scure/base@npm:^1.1.1, @scure/base@npm:^1.1.3, @scure/base@npm:^1.2.6, @scure/base@npm:~1.2.5": version: 1.2.6 resolution: "@scure/base@npm:1.2.6" checksum: 10/c1a7bd5e0b0c8f94c36fbc220f4a67cc832b00e2d2065c7d8a404ed81ab1c94c5443def6d361a70fc382db3496e9487fb9941728f0584782b274c18a4bed4187 @@ -13739,6 +13757,13 @@ __metadata: languageName: node linkType: hard +"ansi-regex@npm:^4.1.0": + version: 4.1.1 + resolution: "ansi-regex@npm:4.1.1" + checksum: 10/b1a6ee44cb6ecdabaa770b2ed500542714d4395d71c7e5c25baa631f680fb2ad322eb9ba697548d498a6fd366949fc8b5bfcf48d49a32803611f648005b01888 + languageName: node + linkType: hard + "ansi-regex@npm:^5.0.1": version: 5.0.1 resolution: "ansi-regex@npm:5.0.1" @@ -13760,6 +13785,15 @@ __metadata: languageName: node linkType: hard +"ansi-styles@npm:^3.2.0": + version: 3.2.1 + resolution: "ansi-styles@npm:3.2.1" + dependencies: + color-convert: "npm:^1.9.0" + checksum: 10/d85ade01c10e5dd77b6c89f34ed7531da5830d2cb5882c645f330079975b716438cd7ebb81d0d6e6b4f9c577f19ae41ab55f07f19786b02f9dfd9e0377395665 + languageName: node + linkType: hard + "ansi-styles@npm:^4.0.0, ansi-styles@npm:^4.1.0": version: 4.3.0 resolution: "ansi-styles@npm:4.3.0" @@ -14727,7 +14761,7 @@ __metadata: languageName: node linkType: hard -"camelcase@npm:^5.3.1": +"camelcase@npm:^5.0.0, camelcase@npm:^5.3.1": version: 5.3.1 resolution: "camelcase@npm:5.3.1" checksum: 10/e6effce26b9404e3c0f301498184f243811c30dfe6d0b9051863bd8e4034d09c8c2923794f280d6827e5aa055f6c434115ff97864a16a963366fb35fd673024b @@ -14881,7 +14915,21 @@ __metadata: languageName: node linkType: hard -"chokidar@npm:^3.5.3, chokidar@npm:^3.6.0": +"chokidar-cli@npm:^3.0.0": + version: 3.0.0 + resolution: "chokidar-cli@npm:3.0.0" + dependencies: + chokidar: "npm:^3.5.2" + lodash.debounce: "npm:^4.0.8" + lodash.throttle: "npm:^4.1.1" + yargs: "npm:^13.3.0" + bin: + chokidar: index.js + checksum: 10/b486205063d3b2cb2edb2dc05d2c21ad6beac4085ca3cf2d66a83af3c1dbaa4570f6101be733d7b03c6d68c64a39262c856688d7eddba758e063cbd2466e4ae9 + languageName: node + linkType: hard + +"chokidar@npm:^3.5.2, chokidar@npm:^3.5.3, chokidar@npm:^3.6.0": version: 3.6.0 resolution: "chokidar@npm:3.6.0" dependencies: @@ -15069,6 +15117,17 @@ __metadata: languageName: node linkType: hard +"cliui@npm:^5.0.0": + version: 5.0.0 + resolution: "cliui@npm:5.0.0" + dependencies: + string-width: "npm:^3.1.0" + strip-ansi: "npm:^5.2.0" + wrap-ansi: "npm:^5.1.0" + checksum: 10/381264fcc3c8316b77b378ce5471ff9a1974d1f6217e0be8f4f09788482b3e6f7c0894eb21e0a86eab4ce0c68426653a407226dd51997306cb87f734776f5fdc + languageName: node + linkType: hard + "cliui@npm:^8.0.1": version: 8.0.1 resolution: "cliui@npm:8.0.1" @@ -15165,6 +15224,15 @@ __metadata: languageName: node linkType: hard +"color-convert@npm:^1.9.0": + version: 1.9.3 + resolution: "color-convert@npm:1.9.3" + dependencies: + color-name: "npm:1.1.3" + checksum: 10/ffa319025045f2973919d155f25e7c00d08836b6b33ea2d205418c59bd63a665d713c52d9737a9e0fe467fb194b40fbef1d849bae80d674568ee220a31ef3d10 + languageName: node + linkType: hard + "color-convert@npm:^2.0.1": version: 2.0.1 resolution: "color-convert@npm:2.0.1" @@ -15174,6 +15242,13 @@ __metadata: languageName: node linkType: hard +"color-name@npm:1.1.3": + version: 1.1.3 + resolution: "color-name@npm:1.1.3" + checksum: 10/09c5d3e33d2105850153b14466501f2bfb30324a2f76568a408763a3b7433b0e50e5b4ab1947868e65cb101bb7cb75029553f2c333b6d4b8138a73fcc133d69d + languageName: node + linkType: hard + "color-name@npm:~1.1.4": version: 1.1.4 resolution: "color-name@npm:1.1.4" @@ -15931,6 +16006,13 @@ __metadata: languageName: node linkType: hard +"decamelize@npm:^1.2.0": + version: 1.2.0 + resolution: "decamelize@npm:1.2.0" + checksum: 10/ad8c51a7e7e0720c70ec2eeb1163b66da03e7616d7b98c9ef43cce2416395e84c1e9548dd94f5f6ffecfee9f8b94251fc57121a8b021f2ff2469b2bae247b8aa + languageName: node + linkType: hard + "decimal.js@npm:^10.5.0, decimal.js@npm:^10.6.0": version: 10.6.0 resolution: "decimal.js@npm:10.6.0" @@ -16367,6 +16449,13 @@ __metadata: languageName: node linkType: hard +"emoji-regex@npm:^7.0.1": + version: 7.0.3 + resolution: "emoji-regex@npm:7.0.3" + checksum: 10/9159b2228b1511f2870ac5920f394c7e041715429a68459ebe531601555f11ea782a8e1718f969df2711d38c66268174407cbca57ce36485544f695c2dfdc96e + languageName: node + linkType: hard + "emoji-regex@npm:^8.0.0": version: 8.0.0 resolution: "emoji-regex@npm:8.0.0" @@ -17726,6 +17815,15 @@ __metadata: languageName: node linkType: hard +"find-up@npm:^3.0.0": + version: 3.0.0 + resolution: "find-up@npm:3.0.0" + dependencies: + locate-path: "npm:^3.0.0" + checksum: 10/38eba3fe7a66e4bc7f0f5a1366dc25508b7cfc349f852640e3678d26ad9a6d7e2c43eff0a472287de4a9753ef58f066a0ea892a256fa3636ad51b3fe1e17fae9 + languageName: node + linkType: hard + "find-up@npm:^4.0.0, find-up@npm:^4.1.0": version: 4.1.0 resolution: "find-up@npm:4.1.0" @@ -17996,7 +18094,7 @@ __metadata: languageName: node linkType: hard -"get-caller-file@npm:^2.0.5": +"get-caller-file@npm:^2.0.1, get-caller-file@npm:^2.0.5": version: 2.0.5 resolution: "get-caller-file@npm:2.0.5" checksum: 10/b9769a836d2a98c3ee734a88ba712e62703f1df31b94b784762c433c27a386dd6029ff55c2a920c392e33657d80191edbf18c61487e198844844516f843496b9 @@ -19303,6 +19401,13 @@ __metadata: languageName: node linkType: hard +"is-fullwidth-code-point@npm:^2.0.0": + version: 2.0.0 + resolution: "is-fullwidth-code-point@npm:2.0.0" + checksum: 10/eef9c6e15f68085fec19ff6a978a6f1b8f48018fd1265035552078ee945573594933b09bbd6f562553e2a241561439f1ef5339276eba68d272001343084cfab8 + languageName: node + linkType: hard + "is-fullwidth-code-point@npm:^3.0.0": version: 3.0.0 resolution: "is-fullwidth-code-point@npm:3.0.0" @@ -20568,6 +20673,16 @@ __metadata: languageName: node linkType: hard +"locate-path@npm:^3.0.0": + version: 3.0.0 + resolution: "locate-path@npm:3.0.0" + dependencies: + p-locate: "npm:^3.0.0" + path-exists: "npm:^3.0.0" + checksum: 10/53db3996672f21f8b0bf2a2c645ae2c13ffdae1eeecfcd399a583bce8516c0b88dcb4222ca6efbbbeb6949df7e46860895be2c02e8d3219abd373ace3bfb4e11 + languageName: node + linkType: hard + "locate-path@npm:^5.0.0": version: 5.0.0 resolution: "locate-path@npm:5.0.0" @@ -20644,6 +20759,13 @@ __metadata: languageName: node linkType: hard +"lodash.throttle@npm:^4.1.1": + version: 4.1.1 + resolution: "lodash.throttle@npm:4.1.1" + checksum: 10/9be9fb2ffd686c20543167883305542f4564062a5f712a40e8c6f2f0d9fd8254a6e9d801c2470b1b24e0cdf2ae83c1277b55aa0fb4799a2db6daf545f53820e1 + languageName: node + linkType: hard + "lodash.uniq@npm:^4.5.0": version: 4.5.0 resolution: "lodash.uniq@npm:4.5.0" @@ -22793,7 +22915,7 @@ __metadata: languageName: node linkType: hard -"p-limit@npm:^2.2.0": +"p-limit@npm:^2.0.0, p-limit@npm:^2.2.0": version: 2.3.0 resolution: "p-limit@npm:2.3.0" dependencies: @@ -22820,6 +22942,15 @@ __metadata: languageName: node linkType: hard +"p-locate@npm:^3.0.0": + version: 3.0.0 + resolution: "p-locate@npm:3.0.0" + dependencies: + p-limit: "npm:^2.0.0" + checksum: 10/83991734a9854a05fe9dbb29f707ea8a0599391f52daac32b86f08e21415e857ffa60f0e120bfe7ce0cc4faf9274a50239c7895fc0d0579d08411e513b83a4ae + languageName: node + linkType: hard + "p-locate@npm:^4.1.0": version: 4.1.0 resolution: "p-locate@npm:4.1.0" @@ -23066,6 +23197,13 @@ __metadata: languageName: node linkType: hard +"path-exists@npm:^3.0.0": + version: 3.0.0 + resolution: "path-exists@npm:3.0.0" + checksum: 10/96e92643aa34b4b28d0de1cd2eba52a1c5313a90c6542d03f62750d82480e20bfa62bc865d5cfc6165f5fcd5aeb0851043c40a39be5989646f223300021bae0a + languageName: node + linkType: hard + "path-exists@npm:^4.0.0": version: 4.0.0 resolution: "path-exists@npm:4.0.0" @@ -25023,6 +25161,13 @@ __metadata: languageName: node linkType: hard +"require-main-filename@npm:^2.0.0": + version: 2.0.0 + resolution: "require-main-filename@npm:2.0.0" + checksum: 10/8604a570c06a69c9d939275becc33a65676529e1c3e5a9f42d58471674df79357872b96d70bb93a0380a62d60dc9031c98b1a9dad98c946ffdd61b7ac0c8cedd + languageName: node + linkType: hard + "requires-port@npm:^1.0.0": version: 1.0.0 resolution: "requires-port@npm:1.0.0" @@ -25523,6 +25668,13 @@ __metadata: languageName: node linkType: hard +"set-blocking@npm:^2.0.0": + version: 2.0.0 + resolution: "set-blocking@npm:2.0.0" + checksum: 10/8980ebf7ae9eb945bb036b6e283c547ee783a1ad557a82babf758a065e2fb6ea337fd82cac30dd565c1e606e423f30024a19fff7afbf4977d784720c4026a8ef + languageName: node + linkType: hard + "set-function-length@npm:^1.2.2": version: 1.2.2 resolution: "set-function-length@npm:1.2.2" @@ -26119,6 +26271,17 @@ __metadata: languageName: node linkType: hard +"string-width@npm:^3.0.0, string-width@npm:^3.1.0": + version: 3.1.0 + resolution: "string-width@npm:3.1.0" + dependencies: + emoji-regex: "npm:^7.0.1" + is-fullwidth-code-point: "npm:^2.0.0" + strip-ansi: "npm:^5.1.0" + checksum: 10/57f7ca73d201682816d573dc68bd4bb8e1dff8dc9fcf10470fdfc3474135c97175fec12ea6a159e67339b41e86963112355b64529489af6e7e70f94a7caf08b2 + languageName: node + linkType: hard + "string-width@npm:^5.0.1, string-width@npm:^5.1.2": version: 5.1.2 resolution: "string-width@npm:5.1.2" @@ -26178,6 +26341,15 @@ __metadata: languageName: node linkType: hard +"strip-ansi@npm:^5.0.0, strip-ansi@npm:^5.1.0, strip-ansi@npm:^5.2.0": + version: 5.2.0 + resolution: "strip-ansi@npm:5.2.0" + dependencies: + ansi-regex: "npm:^4.1.0" + checksum: 10/bdb5f76ade97062bd88e7723aa019adbfacdcba42223b19ccb528ffb9fb0b89a5be442c663c4a3fb25268eaa3f6ea19c7c3fbae830bd1562d55adccae1fcec46 + languageName: node + linkType: hard + "strip-ansi@npm:^7.0.1": version: 7.1.0 resolution: "strip-ansi@npm:7.1.0" @@ -27865,6 +28037,13 @@ __metadata: languageName: node linkType: hard +"which-module@npm:^2.0.0": + version: 2.0.1 + resolution: "which-module@npm:2.0.1" + checksum: 10/1967b7ce17a2485544a4fdd9063599f0f773959cca24176dbe8f405e55472d748b7c549cd7920ff6abb8f1ab7db0b0f1b36de1a21c57a8ff741f4f1e792c52be + languageName: node + linkType: hard + "which@npm:^1.2.10": version: 1.3.1 resolution: "which@npm:1.3.1" @@ -27959,6 +28138,17 @@ __metadata: languageName: node linkType: hard +"wrap-ansi@npm:^5.1.0": + version: 5.1.0 + resolution: "wrap-ansi@npm:5.1.0" + dependencies: + ansi-styles: "npm:^3.2.0" + string-width: "npm:^3.0.0" + strip-ansi: "npm:^5.0.0" + checksum: 10/f02bbbd13f40169f3d69b8c95126c1d2a340e6f149d04125527c3d501d74a304a434f4329a83bfdc3b9fdb82403e9ae0cdd7b83a99f0da0d5a7e544f6b709914 + languageName: node + linkType: hard + "wrap-ansi@npm:^6.2.0": version: 6.2.0 resolution: "wrap-ansi@npm:6.2.0" @@ -28132,6 +28322,13 @@ __metadata: languageName: node linkType: hard +"y18n@npm:^4.0.0": + version: 4.0.3 + resolution: "y18n@npm:4.0.3" + checksum: 10/392870b2a100bbc643bc035fe3a89cef5591b719c7bdc8721bcdb3d27ab39fa4870acdca67b0ee096e146d769f311d68eda6b8195a6d970f227795061923013f + languageName: node + linkType: hard + "y18n@npm:^5.0.5": version: 5.0.8 resolution: "y18n@npm:5.0.8" @@ -28169,6 +28366,16 @@ __metadata: languageName: node linkType: hard +"yargs-parser@npm:^13.1.2": + version: 13.1.2 + resolution: "yargs-parser@npm:13.1.2" + dependencies: + camelcase: "npm:^5.0.0" + decamelize: "npm:^1.2.0" + checksum: 10/89a84fbb32827832a1d34f596f5efe98027c398af731728304a920c2f9ba03071c694418723df16882ebb646ddb72a8fb1c9567552afcbc2f268e86c4faea5a8 + languageName: node + linkType: hard + "yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1" @@ -28191,6 +28398,24 @@ __metadata: languageName: node linkType: hard +"yargs@npm:^13.3.0": + version: 13.3.2 + resolution: "yargs@npm:13.3.2" + dependencies: + cliui: "npm:^5.0.0" + find-up: "npm:^3.0.0" + get-caller-file: "npm:^2.0.1" + require-directory: "npm:^2.1.1" + require-main-filename: "npm:^2.0.0" + set-blocking: "npm:^2.0.0" + string-width: "npm:^3.0.0" + which-module: "npm:^2.0.0" + y18n: "npm:^4.0.0" + yargs-parser: "npm:^13.1.2" + checksum: 10/608ba2e62ac2c7c4572b9c6f7a2d3ef76e2deaad8c8082788ed29ae3ef33e9f68e087f07eb804ed5641de2bc4eab977405d3833b1d11ae8dbbaf5847584d96be + languageName: node + linkType: hard + "yocto-queue@npm:^0.1.0": version: 0.1.0 resolution: "yocto-queue@npm:0.1.0"