diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md index d3601027467..2c5b6a0d46b 100644 --- a/packages/kyc-controller/ARCHITECTURE.md +++ b/packages/kyc-controller/ARCHITECTURE.md @@ -22,27 +22,29 @@ This document explains: 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' \| 'money'`) and a phase machine. Identity vendor is a parameterized `KycVendor` (`initialize({ vendor })`), not vendor-branded public methods. | -| **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. | +| Principle | How it shows up in the code | +| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Vendor-neutral surface** | Consumers deal with `KycProduct` (`'ramps' \| 'card' \| 'money'`) and a phase machine. Identity vendor is a parameterized `KycVendor` (`initialize({ vendor })`), not vendor-branded public methods. | +| **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 and crypto, and delegates the vendor frame protocol to `MoonPayFrameHandler`. 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, +**stateless HTTP client** (`KycService`), a **vendor frame protocol handler** +(`MoonPayFrameHandler`), and supporting modules (state, consents, crypto, selectors, types). ```mermaid graph TB subgraph pkg["@metamask/kyc-controller"] direction TB - Controller["KycController
(BaseController)
state + orchestration + frame protocol"] + Controller["KycController
(BaseController)
state + orchestration"] + FrameHandler["vendors/MoonPayFrameHandler.ts
MoonPay Check/Auth frame protocol"] Service["KycService
(stateless)
HTTP + response validation"] Crypto["crypto.ts
X25519 ECDH + AES-256-GCM"] Selectors["selectors.ts
memoized reselect selectors"] @@ -64,10 +66,11 @@ graph TB 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 + Controller -->|"delegates frame protocol"| FrameHandler + FrameHandler -->|"decryptCredentials()"| Crypto + FrameHandler -->|"builds frame URLs
handles frame messages"| Frames Service -->|"createServicePolicy / HttpError"| CU Service -->|"messenger.call(GeolocationController:getGeolocation)"| Geo @@ -84,8 +87,9 @@ graph TB - 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. +- Delegates the MoonPay Check/Auth frame protocol to `MoonPayFrameHandler`, + which owns the ephemeral **X25519 keypair** (`#frameKeypair`) minted when a + MoonPay flow starts — 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:*` @@ -202,7 +206,8 @@ classDiagram > `T | null` in the source; `Record` is `Partial>`. > Types are simplified above for diagram readability. -State metadata highlights (`kycControllerMetadata`): +State metadata highlights (`kycControllerMetadata`, defined alongside the state +type and default-state factories in `src/KycControllerState.ts`): - **Persisted** (`persist: true`): `vendorDisclaimersAccepted`, `providerDisclaimersAccepted`, `idosDisclaimersAccepted`, @@ -221,10 +226,10 @@ State metadata highlights (`kycControllerMetadata`): Switching away from MoonPay (`initialize` / `createVendorCustomer`) drops these MoonPay Check/Auth artifacts immediately so `buildCheckFrameUrl` cannot return a MoonPay URL while `activeVendor` is a consents-path vendor. -- Additional non-state secrets kept **off** the state object entirely: the - X25519 private key (`#keypair`) and the Auth-frame client token - (`#authClientToken`). The auth client token is cleared on the same vendor - switch. +- Additional non-state secrets kept **off** the state object entirely, held by + `MoonPayFrameHandler`: the X25519 private key (`#frameKeypair`) and the + Auth-frame client token (`#authClientToken`). The auth client token is cleared + on the same vendor switch. --- @@ -693,8 +698,12 @@ graph LR | File | Responsibility | | ------------------------------------ | ------------------------------------------------------------------- | | `src/KycController.ts` | Stateful orchestrator, phase machine. | +| `src/KycControllerState.ts` | State type, persistence metadata, and default-state factories. | | `src/vendors/MoonPayFrameHandler.ts` | MoonPay Check/Auth protocol, URLs, and ephemeral frame credentials. | | `src/KycService.ts` | Stateless UKYC HTTP client + superstruct validation. | +| `src/consents.ts` | Consent-record shaping, conflict/error predicates, vendor routing. | +| `src/vendorDisclaimerAcceptance.ts` | Vendor-scoped terms acceptance records. | +| `src/ukyc/` | UKYC crypto protocol: key derivation, JWT chains, token wrapping. | | `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. | diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 893ef6ee44c..3173c18ba59 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -50,6 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/base-data-service` from `^0.1.3` to `^1.0.0` ([#9972](https://github.com/MetaMask/core/pull/9972)) - Replace `KycController` `console.error` tracing with the `@metamask/utils` debug logger (`createProjectLogger` / `createModuleLogger`), so flow diagnostics are opt-in via `DEBUG=kyc-controller*` instead of always printing to the console. ([#10054](https://github.com/MetaMask/core/pull/10054)) - Bump `@metamask/utils` from `^11.11.0` to `^11.12.0` ([#10076](https://github.com/MetaMask/core/pull/10076)) +- Split `KycController.ts` into focused modules, leaving it as the orchestrator and phase machine. The state type, persistence metadata, and default-state factories move to `KycControllerState.ts`; consent-record shaping and the conflict/completion error predicates move to `consents.ts`; UKYC attestation and capability-token wrapping move to `ukyc/sessionAuthorizations.ts`. This is an internal reorganization only — every export remains available from the package root under the same name. ([#XXXX](https://github.com/MetaMask/core/pull/XXXX)) ### Fixed diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index b5c17a6fae9..d4ef5a6aa07 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -9,11 +9,9 @@ import { areUint8ArraysEqual, bytesToString } from '@metamask/utils'; import { x25519 } from '@noble/curves/ed25519'; import { base64UrlToBytes, toBase64Url } from './encoding.js'; -import { - getDefaultKycControllerState, - KycController, -} from './KycController.js'; +import { KycController } from './KycController.js'; import type { KycControllerMessenger } from './KycController.js'; +import { getDefaultKycControllerState } from './KycControllerState.js'; import type { KycConsentRecord, KycDisclaimer, diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index f6b7d5e72c6..a54846ecd6b 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -1,7 +1,6 @@ import type { ControllerGetStateAction, ControllerStateChangeEvent, - StateMetadata, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; @@ -10,43 +9,45 @@ import type { UserStorageControllerPerformSetStorageAction, } from '@metamask/profile-sync-controller/user-storage'; import type { Json } from '@metamask/utils'; -import { stringToBytes } from '@metamask/utils'; import { x25519 } from '@noble/curves/ed25519'; +import { + acceptedCategoryStillMissing, + consentRecordsFromAcceptedList, + isAcceptedCategoryEmpty, + isConsentConflictError, + isSessionAlreadyCompletedError, + isValidConsentRecordList, + usesConsentsFlow, +} from './consents.js'; import { toBase64Url } from './encoding.js'; import type { KycControllerMethodActions } from './KycController-method-action-types.js'; +import { + getDefaultKycControllerState, + getDefaultKycProviderDisclaimersAccepted, + getDefaultKycVendorDisclaimersAccepted, + kycControllerMetadata, +} from './KycControllerState.js'; +import type { KycControllerState } from './KycControllerState.js'; import type { KycServiceMethodActions } from './KycService-method-action-types.js'; -import type { - CreateUkycSessionParams, - EncryptionSchema, -} from './KycService.js'; +import type { CreateUkycSessionParams } from './KycService.js'; import { controllerLog } from './logger.js'; import type { - KycConsentDocument, KycConsentRecord, KycCustomerIdentity, - KycDisclaimer, KycPhase, KycProduct, - KycProviderDisclaimersAccepted, - KycSessionDisclaimers, KycSessionStatus, KycSumSubLauncher, - KycSumSubStatus, KycUserStatus, KycVendor, - KycVendorDisclaimersAccepted, } from './types.js'; -import { deriveClientMaterial } from './ukyc/deriveClientMaterial.js'; -import { verifyJwtChain } from './ukyc/jwtChain.js'; -import type { Jwk } from './ukyc/jwtChain.js'; import { getOrCreateLocalUserSecret } from './ukyc/localUserSecret.js'; import type { UkycLocalUserSecretStore } from './ukyc/localUserSecret.js'; import { - encodeStorageAccessTokenForHeader, - signStorageAccessToken, -} from './ukyc/storageAccessToken.js'; -import { wrapEncryptionKey } from './ukyc/wrapEncryptionKey.js'; + assertAttestedServerPublicKey, + wrapUkycSessionAuthorizations, +} from './ukyc/sessionAuthorizations.js'; import { clearVendorDisclaimerAcceptance, hasVendorDisclaimerAcceptance, @@ -58,6 +59,8 @@ import { MoonPayFrameHandler, } from './vendors/MoonPayFrameHandler.js'; +export type { KycControllerState } from './KycControllerState.js'; + // === GENERAL === export const controllerName = 'KycController'; @@ -66,12 +69,6 @@ export const controllerName = 'KycController'; // 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`. @@ -131,446 +128,10 @@ const SUCCESSFUL_SESSION_STATUSES: ReadonlySet = new Set([ const VENDOR_PROCESSING_MESSAGE = 'Your KYC has been submitted and is being processed by the vendor.'; -// UKYC / relay error indicating the applicant already finished KYC. Mapped to -// the simplified `completed` user status for the Money toast surface. -const SESSION_NOT_IN_VALID_STATE = 'session_not_in_valid_state'; - // How often to refresh the user-keyed `GET /kyc/status` while the simplified // status is still `pending`. Overridable via the constructor. const DEFAULT_USER_STATUS_POLL_INTERVAL_MS = 15_000; -// === 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; - - /** - * Persisted vendor-disclaimer acceptance (T&C1) with fixed `moonpay` and - * `iron` keys. MoonPay stores only `termsAcceptedAt`; Iron stores - * `disclaimerIds`. - */ - vendorDisclaimersAccepted: KycVendorDisclaimersAccepted; - /** - * KYC-provider disclaimer documents the customer accepted during the last - * terms acceptance (persisted `{ key, version }` records under `sumsub`). - * Consents-path vendors require this when resuming a session. `null` for - * acceptance recorded before this field existed (treated as requiring - * reacceptance). - */ - providerDisclaimersAccepted: KycProviderDisclaimersAccepted; - /** - * idOS disclaimer documents the customer accepted during the last terms - * acceptance (persisted `{ key, version }` records). Consents-path vendors - * require this when resuming a session. `null` for acceptance recorded - * before this field existed (treated as requiring reacceptance). - */ - idosDisclaimersAccepted: KycConsentRecord[] | null; - /** - * Whether the customer consented to reuse existing idOS credentials - * during this session. Applied when recording session-scoped disclaimers. - * Not persisted: a new UKYC session must collect reuse consent again. - * `null` when never set (treated as `false`). - */ - credentialReusabilityConsentGiven: boolean | null; - - /** Vendor disclaimers fetched for the current country. */ - vendorDisclaimers: KycDisclaimer[]; - /** Error encountered while loading vendor disclaimers, or `null`. */ - vendorError: string | null; - /** - * idOS / KYC-provider disclaimer catalog from `GET /disclaimers` or - * `GET /sessions/{sessionId}/disclaimers`. `null` until the catalog has - * been fetched (typically after a UKYC session exists). - */ - sessionDisclaimers: KycSessionDisclaimers | null; - - /** Resolved ISO 3166-1 alpha-3 country code. */ - geoCountry: string | null; - - /** MoonPay session token (not persisted, not logged). */ - moonpaySessionToken: string | null; - /** MoonPay access token (not persisted, not logged). */ - moonpayAccessToken: string | null; - /** Vendor customer id, used for the SumSub hand-off. */ - moonpayCustomerId: string | null; - - /** - * The identity vendor driving the current flow. Captured at `initialize`. - * Defaults to `moonpay` when omitted so existing ramps/card callers keep - * the Check/Auth frame path. Non-MoonPay vendors skip those frames. - */ - activeVendor: KycVendor; - - /** - * 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; - - /** - * User-keyed simplified KYC status from `GET /kyc/status` (persisted so the - * Money toast can render across cold starts). `null` until the first - * successful `refreshKycStatus`. - */ - userStatus: KycUserStatus | null; - /** Optional SumSub session id for the retryable error path. */ - userStatusSumsubSessionId: string | null; - /** Optional machine-readable error code for terminal / EDD UX. */ - userStatusErrorCode: 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, - }, - vendorDisclaimersAccepted: { - includeInDebugSnapshot: true, - includeInStateLogs: true, - persist: true, - usedInUi: false, - }, - providerDisclaimersAccepted: { - includeInDebugSnapshot: true, - includeInStateLogs: true, - persist: true, - usedInUi: false, - }, - idosDisclaimersAccepted: { - includeInDebugSnapshot: true, - includeInStateLogs: true, - persist: true, - usedInUi: false, - }, - credentialReusabilityConsentGiven: { - includeInDebugSnapshot: true, - includeInStateLogs: true, - persist: false, - usedInUi: false, - }, - vendorDisclaimers: { - includeInDebugSnapshot: false, - includeInStateLogs: false, - persist: false, - usedInUi: true, - }, - vendorError: { - includeInDebugSnapshot: true, - includeInStateLogs: true, - persist: false, - usedInUi: true, - }, - sessionDisclaimers: { - includeInDebugSnapshot: false, - includeInStateLogs: false, - persist: false, - usedInUi: true, - }, - geoCountry: { - includeInDebugSnapshot: true, - includeInStateLogs: true, - persist: false, - usedInUi: true, - }, - moonpaySessionToken: { - includeInDebugSnapshot: false, - includeInStateLogs: false, - persist: false, - usedInUi: false, - }, - moonpayAccessToken: { - includeInDebugSnapshot: false, - includeInStateLogs: false, - persist: false, - usedInUi: false, - }, - moonpayCustomerId: { - includeInDebugSnapshot: false, - includeInStateLogs: false, - persist: false, - usedInUi: false, - }, - activeVendor: { - includeInDebugSnapshot: true, - includeInStateLogs: true, - persist: false, - usedInUi: true, - }, - 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, - }, - userStatus: { - includeInDebugSnapshot: true, - includeInStateLogs: true, - persist: true, - usedInUi: true, - }, - userStatusSumsubSessionId: { - includeInDebugSnapshot: false, - includeInStateLogs: false, - persist: true, - usedInUi: true, - }, - userStatusErrorCode: { - includeInDebugSnapshot: true, - includeInStateLogs: true, - persist: true, - usedInUi: true, - }, - sumsub: { - includeInDebugSnapshot: false, - includeInStateLogs: false, - persist: false, - usedInUi: true, - }, -} satisfies StateMetadata; - -/** - * Constructs the default {@link KycVendorDisclaimersAccepted} value. - * - * @returns The default vendor-disclaimer acceptance map. - */ -export function getDefaultKycVendorDisclaimersAccepted(): KycVendorDisclaimersAccepted { - return { moonpay: null, iron: null }; -} - -export function getDefaultKycProviderDisclaimersAccepted(): KycProviderDisclaimersAccepted { - return { sumsub: null }; -} - -/** - * Constructs the default {@link KycController} state. - * - * @returns The default state. - */ -export function getDefaultKycControllerState(): KycControllerState { - return { - phase: 'idle', - statusMessage: '', - error: null, - email: null, - vendorDisclaimersAccepted: getDefaultKycVendorDisclaimersAccepted(), - providerDisclaimersAccepted: getDefaultKycProviderDisclaimersAccepted(), - idosDisclaimersAccepted: null, - credentialReusabilityConsentGiven: null, - vendorDisclaimers: [], - vendorError: null, - sessionDisclaimers: null, - geoCountry: null, - moonpaySessionToken: null, - moonpayAccessToken: null, - moonpayCustomerId: null, - activeVendor: 'moonpay', - activeProduct: null, - kycRequiredByProduct: {}, - lastCheckedAt: null, - userStatus: null, - userStatusSumsubSessionId: null, - userStatusErrorCode: null, - sumsub: { - status: 'idle', - result: null, - sessionId: null, - applicantAccessToken: null, - sessionStatus: null, - }, - }; -} - -/** - * Whether an error indicates the applicant already finished KYC — the UKYC / - * relay `session_not_in_valid_state` signal — which the controller maps to the - * simplified `completed` user status. - * - * @param error - The caught error. - * @returns `true` when the error carries the `session_not_in_valid_state` - * marker. - */ -function isSessionAlreadyCompletedError(error: unknown): boolean { - return String(error).includes(SESSION_NOT_IN_VALID_STATE); -} - -/** - * Whether recording session disclaimers failed because those document - * versions were already consented for the session (`409 Conflict`). - * - * @param error - The caught error. - * @returns `true` when the error is an HTTP 409. - */ -function isConsentConflictError(error: unknown): boolean { - return ( - typeof error === 'object' && - error !== null && - typeof (error as { httpStatus?: unknown }).httpStatus === 'number' && - (error as { httpStatus: number }).httpStatus === 409 - ); -} - -/** - * - * @param value - The value to validate. - * @returns `true` when `value` is a valid consent record list. - */ -function isValidConsentRecordList(value: unknown): value is KycConsentRecord[] { - return ( - Array.isArray(value) && - value.every( - (item) => - typeof item === 'object' && - item !== null && - typeof (item as KycConsentRecord).key === 'string' && - typeof (item as KycConsentRecord).version === 'string', - ) - ); -} - -/** - * Maps accepted disclaimer records onto unconsented catalog documents. - * - * @param documents - Catalog documents for one consent category. - * @param accepted - Accepted `{ key, version }` records from the caller. - * @returns Consent records to POST, omitting already-consented documents. - */ -function consentRecordsFromAcceptedList( - documents: KycConsentDocument[], - accepted: KycConsentRecord[], -): KycConsentRecord[] { - if (accepted.length === 0) { - return []; - } - const acceptedKeys = new Set( - accepted.map((record) => `${record.key}:${record.version}`), - ); - return documents - .filter( - (document) => - !document.consented && - acceptedKeys.has(`${document.key}:${document.version}`), - ) - .map(({ key, version }) => ({ key, version })); -} - -/** - * Whether accepted disclaimers reference a missing catalog category. - * - * @param documents - Catalog documents for one consent category. - * @param accepted - Accepted `{ key, version }` records from the caller. - * @returns `true` when the caller accepted docs but the catalog is empty. - */ -function isAcceptedCategoryEmpty( - documents: KycConsentDocument[], - accepted: KycConsentRecord[], -): boolean { - return accepted.length > 0 && documents.length === 0; -} - -/** - * Whether accepted disclaimers are still missing consent after a 409 re-GET: - * empty catalog or any accepted document still unconsented. - * - * @param documents - Latest catalog documents for one consent category. - * @param accepted - Accepted `{ key, version }` records from the caller. - * @returns `true` when accepted documents are not fully consented. - */ -function acceptedCategoryStillMissing( - documents: KycConsentDocument[], - accepted: KycConsentRecord[], -): boolean { - if (accepted.length === 0) { - return false; - } - if (documents.length === 0) { - return true; - } - const acceptedKeys = new Set( - accepted.map((record) => `${record.key}:${record.version}`), - ); - const relevant = documents.filter((document) => - acceptedKeys.has(`${document.key}:${document.version}`), - ); - return ( - relevant.length === 0 || relevant.some((document) => !document.consented) - ); -} - -/** - * Vendors other than MoonPay skip Check/Auth frames and use the empty-shell - * customer + consents path instead. - * - * @param vendor - The identity vendor for the current flow. - * @returns `true` when the vendor uses the consents session path. - */ -function usesConsentsFlow(vendor: KycVendor): boolean { - return vendor !== 'moonpay'; -} - // === MESSENGER === const MESSENGER_EXPOSED_METHODS = [ @@ -1174,11 +735,7 @@ export class KycController extends BaseController< } if (created.vendorProcessing) { - try { - await this.refreshKycStatus(); - } catch (statusError) { - controllerLog('KYC status refresh failed:', statusError); - } + await this.#tryRefreshKycStatus(); this.#updateIfCurrent(generation, (state) => { state.phase = 'done'; state.statusMessage = VENDOR_PROCESSING_MESSAGE; @@ -1211,13 +768,9 @@ export class KycController extends BaseController< ); } // After SumSub, refresh user-keyed status for the Money toast and start - // polling while still pending. Soft-fail: toast refresh must not rewind - // the consent / SumSub outcome. - try { - await this.refreshKycStatus(); - } catch (statusError) { - controllerLog('KYC status refresh failed:', statusError); - } + // polling while still pending. The toast refresh must not rewind the + // consent / SumSub outcome, so its failure is swallowed. + await this.#tryRefreshKycStatus(); this.#updateIfCurrent(generation, (state) => { if (state.phase !== 'error' && state.phase !== 'done') { state.phase = 'done'; @@ -1226,21 +779,7 @@ export class KycController extends BaseController< }); } catch (error) { if (isSessionAlreadyCompletedError(error)) { - if (this.#generation !== generation) { - return; - } - this.#applyUserStatus({ - status: 'completed', - sumsubSessionId: null, - errorCode: null, - }); - this.#updateIfCurrent(generation, (state) => { - state.sumsub.status = 'complete'; - state.sumsub.result = { alreadyCompleted: true }; - state.statusMessage = 'KYC already completed.'; - state.phase = 'done'; - state.error = null; - }); + this.#markAlreadyCompleted(generation); return; } controllerLog('Consents session failed:', error); @@ -1773,8 +1312,8 @@ export class KycController extends BaseController< this.messenger.call('KycService:fetchIdosEnclaveJwks'), this.messenger.call('KycService:fetchIdosRelayJwks'), ]); - this.#assertAttestedServerPublicKey(idosEnclaveKeys, encryptionDataKey); - this.#assertAttestedServerPublicKey(idosRelayKeys, capabilityTokenSchema); + assertAttestedServerPublicKey(idosEnclaveKeys, encryptionDataKey); + assertAttestedServerPublicKey(idosRelayKeys, capabilityTokenSchema); // Derive the data_encryption_key from the local_user_secret, mint a // read-only capability token, and wrap both for the session server. Only @@ -1782,27 +1321,14 @@ export class KycController extends BaseController< const localUserSecret = await getOrCreateLocalUserSecret( this.#localUserSecretStore(), ); - const clientMaterial = deriveClientMaterial(localUserSecret); - const wrappedEncryptionDataKey = wrapEncryptionKey( - sessionClientPrivateKey, - encryptionDataKey.serverPublicKey.x, - clientMaterial.dataEncryptionKey, - ); + const { wrappedEncryptionDataKey, wrappedUkycCapabilityToken } = + wrapUkycSessionAuthorizations({ + sessionClientPrivateKey, + encryptionDataKey, + capabilityTokenSchema, + localUserSecret, + }); - // Only the client holds the signing key derived from `local_user_secret`, - // so only the client can mint the token; scoping it to `read` means it - // authorizes later storage reads without granting write or delete access. - const ukycCapabilityToken = signStorageAccessToken({ - material: clientMaterial, - // TODO: Confirm with idOS when this can be switched back to read and a separate token is sent for write - operations: ['read', 'write'], - expiresAt: new Date(Date.now() + UKYC_CAPABILITY_TOKEN_TTL_MS), - }); - const wrappedUkycCapabilityToken = wrapEncryptionKey( - sessionClientPrivateKey, - capabilityTokenSchema.serverPublicKey.x, - stringToBytes(encodeStorageAccessTokenForHeader(ukycCapabilityToken)), - ); if (this.#generation !== generation) { return null; } @@ -2002,21 +1528,7 @@ export class KycController extends BaseController< // A reset() may have landed while `launch` was in flight; forcing // `completed` (and publishing `statusChanged`) on an idle controller // would resurrect a flow the consumer already tore down. - if (this.#generation !== generation) { - return { alreadyCompleted: true }; - } - this.#applyUserStatus({ - status: 'completed', - sumsubSessionId: null, - errorCode: null, - }); - this.#updateIfCurrent(generation, (state) => { - state.sumsub.status = 'complete'; - state.sumsub.result = { alreadyCompleted: true }; - state.statusMessage = 'KYC already completed.'; - state.phase = 'done'; - state.error = null; - }); + this.#markAlreadyCompleted(generation); return { alreadyCompleted: true }; } const result = { error: String(error) }; @@ -2394,20 +1906,38 @@ export class KycController extends BaseController< } /** - * Confirms that an encryption schema's `serverPublicKey.x` matches the - * `sessionServerPublicKeyX` attested inside its verified `jwtChain`. Rejects - * a key that was swapped out-of-band after the chain was signed. + * Maps the UKYC `session_not_in_valid_state` signal onto simplified + * `completed` status. No-op when a `reset()` superseded `generation`. * - * @param keys - The issuer JWKS used to verify the chain (idOS enclave for - * `encryptionDataKey`, idOS relay for `ukycCapabilityToken`). - * @param schema - The encryption schema returned by session creation. + * @param generation - Flow generation captured by the caller. */ - #assertAttestedServerPublicKey(keys: Jwk[], schema: EncryptionSchema): void { - const jwtChainPayload = verifyJwtChain(keys, schema.jwtChain); - if (jwtChainPayload.sessionServerPublicKeyX !== schema.serverPublicKey.x) { - throw new Error( - 'sessionServerPublicKey does not match the verified jwtChain payload (sessionServerPublicKeyX).', - ); + #markAlreadyCompleted(generation: number): void { + if (this.#generation !== generation) { + return; + } + this.#applyUserStatus({ + status: 'completed', + sumsubSessionId: null, + errorCode: null, + }); + this.#updateIfCurrent(generation, (state) => { + state.sumsub.status = 'complete'; + state.sumsub.result = { alreadyCompleted: true }; + state.statusMessage = 'KYC already completed.'; + state.phase = 'done'; + state.error = null; + }); + } + + /** + * Calls {@link refreshKycStatus} for toast surfaces, logging rather than + * rethrowing so a failed refresh does not rewind the current flow. + */ + async #tryRefreshKycStatus(): Promise { + try { + await this.refreshKycStatus(); + } catch (statusError) { + controllerLog('KYC status refresh failed:', statusError); } } diff --git a/packages/kyc-controller/src/KycControllerState.ts b/packages/kyc-controller/src/KycControllerState.ts new file mode 100644 index 00000000000..c28f93c34b5 --- /dev/null +++ b/packages/kyc-controller/src/KycControllerState.ts @@ -0,0 +1,324 @@ +import type { StateMetadata } from '@metamask/base-controller'; +import type { Json } from '@metamask/utils'; + +import type { + KycConsentRecord, + KycDisclaimer, + KycPhase, + KycProduct, + KycProviderDisclaimersAccepted, + KycSessionDisclaimers, + KycSessionStatus, + KycSumSubStatus, + KycUserStatus, + KycVendor, + KycVendorDisclaimersAccepted, +} from './types.js'; + +/** + * 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; + + /** + * Persisted vendor-disclaimer acceptance (T&C1) with fixed `moonpay` and + * `iron` keys. MoonPay stores only `termsAcceptedAt`; Iron stores + * `disclaimerIds`. + */ + vendorDisclaimersAccepted: KycVendorDisclaimersAccepted; + /** + * KYC-provider disclaimer documents the customer accepted during the last + * terms acceptance (persisted `{ key, version }` records under `sumsub`). + * Consents-path vendors require this when resuming a session. `null` for + * acceptance recorded before this field existed (treated as requiring + * reacceptance). + */ + providerDisclaimersAccepted: KycProviderDisclaimersAccepted; + /** + * idOS disclaimer documents the customer accepted during the last terms + * acceptance (persisted `{ key, version }` records). Consents-path vendors + * require this when resuming a session. `null` for acceptance recorded + * before this field existed (treated as requiring reacceptance). + */ + idosDisclaimersAccepted: KycConsentRecord[] | null; + /** + * Whether the customer consented to reuse existing idOS credentials + * during this session. Applied when recording session-scoped disclaimers. + * Not persisted: a new UKYC session must collect reuse consent again. + * `null` when never set (treated as `false`). + */ + credentialReusabilityConsentGiven: boolean | null; + + /** Vendor disclaimers fetched for the current country. */ + vendorDisclaimers: KycDisclaimer[]; + /** Error encountered while loading vendor disclaimers, or `null`. */ + vendorError: string | null; + /** + * idOS / KYC-provider disclaimer catalog from `GET /disclaimers` or + * `GET /sessions/{sessionId}/disclaimers`. `null` until the catalog has + * been fetched (typically after a UKYC session exists). + */ + sessionDisclaimers: KycSessionDisclaimers | null; + + /** Resolved ISO 3166-1 alpha-3 country code. */ + geoCountry: string | null; + + /** MoonPay session token (not persisted, not logged). */ + moonpaySessionToken: string | null; + /** MoonPay access token (not persisted, not logged). */ + moonpayAccessToken: string | null; + /** Vendor customer id, used for the SumSub hand-off. */ + moonpayCustomerId: string | null; + + /** + * The identity vendor driving the current flow. Captured at `initialize`. + * Defaults to `moonpay` when omitted so existing ramps/card callers keep + * the Check/Auth frame path. Non-MoonPay vendors skip those frames. + */ + activeVendor: KycVendor; + + /** + * 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; + + /** + * User-keyed simplified KYC status from `GET /kyc/status` (persisted so the + * Money toast can render across cold starts). `null` until the first + * successful `refreshKycStatus`. + */ + userStatus: KycUserStatus | null; + /** Optional SumSub session id for the retryable error path. */ + userStatusSumsubSessionId: string | null; + /** Optional machine-readable error code for terminal / EDD UX. */ + userStatusErrorCode: 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; + }; +}; + +export 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, + }, + vendorDisclaimersAccepted: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: false, + }, + providerDisclaimersAccepted: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: false, + }, + idosDisclaimersAccepted: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: false, + }, + credentialReusabilityConsentGiven: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: false, + }, + vendorDisclaimers: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: true, + }, + vendorError: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, + sessionDisclaimers: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: true, + }, + geoCountry: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, + moonpaySessionToken: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + }, + moonpayAccessToken: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + }, + moonpayCustomerId: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + }, + activeVendor: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, + 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, + }, + userStatus: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: true, + }, + userStatusSumsubSessionId: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: true, + usedInUi: true, + }, + userStatusErrorCode: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: true, + }, + sumsub: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: true, + }, +} satisfies StateMetadata; + +/** + * Constructs the default {@link KycVendorDisclaimersAccepted} value. + * + * @returns The default vendor-disclaimer acceptance map. + */ +export function getDefaultKycVendorDisclaimersAccepted(): KycVendorDisclaimersAccepted { + return { moonpay: null, iron: null }; +} + +/** + * Constructs the default {@link KycProviderDisclaimersAccepted} value. + * + * @returns The default provider-disclaimer acceptance map. + */ +export function getDefaultKycProviderDisclaimersAccepted(): KycProviderDisclaimersAccepted { + return { sumsub: null }; +} + +/** + * Constructs the default {@link KycController} state. + * + * @returns The default state. + */ +export function getDefaultKycControllerState(): KycControllerState { + return { + phase: 'idle', + statusMessage: '', + error: null, + email: null, + vendorDisclaimersAccepted: getDefaultKycVendorDisclaimersAccepted(), + providerDisclaimersAccepted: getDefaultKycProviderDisclaimersAccepted(), + idosDisclaimersAccepted: null, + credentialReusabilityConsentGiven: null, + vendorDisclaimers: [], + vendorError: null, + sessionDisclaimers: null, + geoCountry: null, + moonpaySessionToken: null, + moonpayAccessToken: null, + moonpayCustomerId: null, + activeVendor: 'moonpay', + activeProduct: null, + kycRequiredByProduct: {}, + lastCheckedAt: null, + userStatus: null, + userStatusSumsubSessionId: null, + userStatusErrorCode: null, + sumsub: { + status: 'idle', + result: null, + sessionId: null, + applicantAccessToken: null, + sessionStatus: null, + }, + }; +} diff --git a/packages/kyc-controller/src/consents.test.ts b/packages/kyc-controller/src/consents.test.ts new file mode 100644 index 00000000000..0dd32b52a64 --- /dev/null +++ b/packages/kyc-controller/src/consents.test.ts @@ -0,0 +1,141 @@ +import { + acceptedCategoryStillMissing, + consentRecordKey, + consentRecordsFromAcceptedList, + isAcceptedCategoryEmpty, + isConsentConflictError, + isSessionAlreadyCompletedError, + isValidConsentRecordList, + usesConsentsFlow, +} from './consents.js'; +import type { KycConsentDocument } from './types.js'; + +const DOCUMENTS: KycConsentDocument[] = [ + { + key: 'a', + version: '1', + title: 'A', + url: 'https://example.com/a', + consented: false, + }, + { + key: 'b', + version: '2', + title: 'B', + url: 'https://example.com/b', + consented: true, + }, +]; + +describe('consents', () => { + describe('consentRecordKey', () => { + it('joins key and version', () => { + expect(consentRecordKey({ key: 'tos', version: '3' })).toBe('tos:3'); + }); + }); + + describe('isSessionAlreadyCompletedError', () => { + it('detects the UKYC session_not_in_valid_state marker', () => { + expect( + isSessionAlreadyCompletedError( + new Error('session_not_in_valid_state: already done'), + ), + ).toBe(true); + }); + + it('returns false for unrelated errors', () => { + expect(isSessionAlreadyCompletedError(new Error('network'))).toBe(false); + }); + }); + + describe('isConsentConflictError', () => { + it('returns true for HTTP 409', () => { + expect(isConsentConflictError({ httpStatus: 409 })).toBe(true); + }); + + it('returns false for other statuses, non-objects, and missing httpStatus', () => { + expect(isConsentConflictError({ httpStatus: 400 })).toBe(false); + expect(isConsentConflictError(null)).toBe(false); + expect(isConsentConflictError('409')).toBe(false); + expect(isConsentConflictError({ httpStatus: '409' })).toBe(false); + }); + }); + + describe('isValidConsentRecordList', () => { + it('accepts an array of key/version records', () => { + expect(isValidConsentRecordList([])).toBe(true); + expect(isValidConsentRecordList([{ key: 'a', version: '1' }])).toBe(true); + }); + + it('rejects non-arrays and malformed items', () => { + expect(isValidConsentRecordList(undefined)).toBe(false); + expect(isValidConsentRecordList([{ key: 'a' }])).toBe(false); + expect(isValidConsentRecordList([{ version: '1' }])).toBe(false); + expect(isValidConsentRecordList([null])).toBe(false); + }); + }); + + describe('consentRecordsFromAcceptedList', () => { + it('returns nothing when the caller accepted no documents', () => { + expect(consentRecordsFromAcceptedList(DOCUMENTS, [])).toStrictEqual([]); + }); + + it('posts only unconsented catalog rows the caller accepted', () => { + expect( + consentRecordsFromAcceptedList(DOCUMENTS, [ + { key: 'a', version: '1' }, + { key: 'b', version: '2' }, + { key: 'missing', version: '1' }, + ]), + ).toStrictEqual([{ key: 'a', version: '1' }]); + }); + }); + + describe('isAcceptedCategoryEmpty', () => { + it('is true only when the caller accepted docs but the catalog is empty', () => { + expect(isAcceptedCategoryEmpty([], [{ key: 'a', version: '1' }])).toBe( + true, + ); + expect( + isAcceptedCategoryEmpty(DOCUMENTS, [{ key: 'a', version: '1' }]), + ).toBe(false); + expect(isAcceptedCategoryEmpty([], [])).toBe(false); + }); + }); + + describe('acceptedCategoryStillMissing', () => { + it('returns false when nothing was accepted', () => { + expect(acceptedCategoryStillMissing([], [])).toBe(false); + }); + + it('returns true when the catalog is empty or has no matching rows', () => { + expect( + acceptedCategoryStillMissing([], [{ key: 'a', version: '1' }]), + ).toBe(true); + expect( + acceptedCategoryStillMissing(DOCUMENTS, [ + { key: 'missing', version: '1' }, + ]), + ).toBe(true); + }); + + it('returns true when a matching catalog row is still unconsented', () => { + expect( + acceptedCategoryStillMissing(DOCUMENTS, [{ key: 'a', version: '1' }]), + ).toBe(true); + }); + + it('returns false when every accepted document is consented', () => { + expect( + acceptedCategoryStillMissing(DOCUMENTS, [{ key: 'b', version: '2' }]), + ).toBe(false); + }); + }); + + describe('usesConsentsFlow', () => { + it('is true for non-MoonPay vendors', () => { + expect(usesConsentsFlow('moonpay')).toBe(false); + expect(usesConsentsFlow('iron')).toBe(true); + }); + }); +}); diff --git a/packages/kyc-controller/src/consents.ts b/packages/kyc-controller/src/consents.ts new file mode 100644 index 00000000000..786b84c45df --- /dev/null +++ b/packages/kyc-controller/src/consents.ts @@ -0,0 +1,146 @@ +import type { + KycConsentDocument, + KycConsentRecord, + KycVendor, +} from './types.js'; + +/** + * UKYC / relay error indicating the applicant already finished KYC. Mapped to + * the simplified `completed` user status for the Money toast surface. + */ +const SESSION_NOT_IN_VALID_STATE = 'session_not_in_valid_state'; + +/** + * Stable identity for a consent document version. + * + * @param record - A `{ key, version }` consent record. + * @returns `key:version`. + */ +export function consentRecordKey( + record: Pick, +): string { + return `${record.key}:${record.version}`; +} + +/** + * Whether an error indicates the applicant already finished KYC — the UKYC / + * relay `session_not_in_valid_state` signal — which the controller maps to the + * simplified `completed` user status. + * + * @param error - The caught error. + * @returns `true` when the error carries the `session_not_in_valid_state` + * marker. + */ +export function isSessionAlreadyCompletedError(error: unknown): boolean { + return String(error).includes(SESSION_NOT_IN_VALID_STATE); +} + +/** + * Whether recording session disclaimers failed because those document + * versions were already consented for the session (`409 Conflict`). + * + * @param error - The caught error. + * @returns `true` when the error is an HTTP 409. + */ +export function isConsentConflictError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + typeof (error as { httpStatus?: unknown }).httpStatus === 'number' && + (error as { httpStatus: number }).httpStatus === 409 + ); +} + +/** + * @param value - The value to validate. + * @returns `true` when `value` is a valid consent record list. + */ +export function isValidConsentRecordList( + value: unknown, +): value is KycConsentRecord[] { + return ( + Array.isArray(value) && + value.every( + (item) => + typeof item === 'object' && + item !== null && + typeof (item as KycConsentRecord).key === 'string' && + typeof (item as KycConsentRecord).version === 'string', + ) + ); +} + +/** + * Maps accepted disclaimer records onto unconsented catalog documents. + * + * @param documents - Catalog documents for one consent category. + * @param accepted - Accepted `{ key, version }` records from the caller. + * @returns Consent records to POST, omitting already-consented documents. + */ +export function consentRecordsFromAcceptedList( + documents: KycConsentDocument[], + accepted: KycConsentRecord[], +): KycConsentRecord[] { + if (accepted.length === 0) { + return []; + } + const acceptedKeys = new Set(accepted.map(consentRecordKey)); + return documents + .filter( + (document) => + !document.consented && acceptedKeys.has(consentRecordKey(document)), + ) + .map(({ key, version }) => ({ key, version })); +} + +/** + * Whether accepted disclaimers reference a missing catalog category. + * + * @param documents - Catalog documents for one consent category. + * @param accepted - Accepted `{ key, version }` records from the caller. + * @returns `true` when the caller accepted docs but the catalog is empty. + */ +export function isAcceptedCategoryEmpty( + documents: KycConsentDocument[], + accepted: KycConsentRecord[], +): boolean { + return accepted.length > 0 && documents.length === 0; +} + +/** + * Whether accepted disclaimers are still missing consent after a 409 re-GET: + * empty catalog or any accepted document still unconsented. + * + * @param documents - Latest catalog documents for one consent category. + * @param accepted - Accepted `{ key, version }` records from the caller. + * @returns `true` when accepted documents are not fully consented. + */ +export function acceptedCategoryStillMissing( + documents: KycConsentDocument[], + accepted: KycConsentRecord[], +): boolean { + if (accepted.length === 0) { + return false; + } + if (documents.length === 0) { + return true; + } + const acceptedKeys = new Set(accepted.map(consentRecordKey)); + const relevant = documents.filter((document) => + acceptedKeys.has(consentRecordKey(document)), + ); + return ( + relevant.length === 0 || relevant.some((document) => !document.consented) + ); +} + +/** + * Vendors other than MoonPay skip Check/Auth frames and use the empty-shell + * customer + consents path instead. + * + * @param vendor - The identity vendor for the current flow. + * @returns `true` when the vendor uses the consents session path. + */ +export function usesConsentsFlow(vendor: KycVendor): boolean { + return vendor !== 'moonpay'; +} diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index 31b705c53ab..60dd22dbd9e 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -1,20 +1,19 @@ +export { KycController, controllerName } from './KycController.js'; export { - KycController, getDefaultKycControllerState, getDefaultKycProviderDisclaimersAccepted, getDefaultKycVendorDisclaimersAccepted, - controllerName, -} from './KycController.js'; +} from './KycControllerState.js'; export type { KycControllerActions, KycControllerEvents, KycControllerGetStateAction, KycControllerMessenger, KycControllerOptions, - KycControllerState, KycControllerStateChangeEvent, KycControllerStatusChangedEvent, } from './KycController.js'; +export type { KycControllerState } from './KycControllerState.js'; export type { KycControllerAcceptTermsAndStartSessionAction, KycControllerBuildAuthFrameUrlAction, diff --git a/packages/kyc-controller/src/selectors.test.ts b/packages/kyc-controller/src/selectors.test.ts index 5eee934acd0..baceae8e1e9 100644 --- a/packages/kyc-controller/src/selectors.test.ts +++ b/packages/kyc-controller/src/selectors.test.ts @@ -1,4 +1,4 @@ -import { getDefaultKycControllerState } from './KycController.js'; +import { getDefaultKycControllerState } from './KycControllerState.js'; import { selectIsKycRequiredForProduct, selectKycPhase, diff --git a/packages/kyc-controller/src/selectors.ts b/packages/kyc-controller/src/selectors.ts index 6247e01796f..d44de322409 100644 --- a/packages/kyc-controller/src/selectors.ts +++ b/packages/kyc-controller/src/selectors.ts @@ -1,6 +1,6 @@ import { createSelector } from 'reselect'; -import type { KycControllerState } from './KycController.js'; +import type { KycControllerState } from './KycControllerState.js'; import type { KycProduct } from './types.js'; const selectKycRequiredByProduct = ( diff --git a/packages/kyc-controller/src/ukyc/sessionAuthorizations.test.ts b/packages/kyc-controller/src/ukyc/sessionAuthorizations.test.ts new file mode 100644 index 00000000000..ac0054eaf3d --- /dev/null +++ b/packages/kyc-controller/src/ukyc/sessionAuthorizations.test.ts @@ -0,0 +1,147 @@ +import { areUint8ArraysEqual, stringToBytes } from '@metamask/utils'; +import { ed25519 } from '@noble/curves/ed25519'; +import { box } from 'tweetnacl'; + +import { toBase64Url, base64UrlToBytes } from '../encoding.js'; +import { UKYC_LOCAL_USER_SECRET_SIZE_BYTES } from './constants.js'; +import { deriveClientMaterial } from './deriveClientMaterial.js'; +import type { Jwk } from './jwtChain.js'; +import { + assertAttestedServerPublicKey, + wrapUkycSessionAuthorizations, +} from './sessionAuthorizations.js'; + +const KID = 'key-1'; +const SERVER_PUBLIC_KEY_X = 'spk-x'; + +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 payload - JWT payload. + * @returns The compact-serialized JWT. + */ +function buildJwt(payload: Record): string { + const headerSegment = toBase64Url( + stringToBytes(JSON.stringify({ alg: 'EdDSA', kid: KID })), + ); + const payloadSegment = toBase64Url(stringToBytes(JSON.stringify(payload))); + const signature = ed25519.sign( + new TextEncoder().encode(`${headerSegment}.${payloadSegment}`), + SIGNING_PRIVATE_KEY, + ); + return `${headerSegment}.${payloadSegment}.${toBase64Url(signature)}`; +} + +/** + * Opens a wrapped authorization from the session server's perspective. + * + * @param serverPrivateKey - Server X25519 private key. + * @param clientPublicKey - Client X25519 public key. + * @param data - Base64url ciphertext. + * @param nonce - Base64url nonce. + * @returns Recovered plaintext. + */ +function unwrap( + serverPrivateKey: Uint8Array, + clientPublicKey: Uint8Array, + data: string, + nonce: string, +): Uint8Array { + const recovered = box.open( + base64UrlToBytes(data), + base64UrlToBytes(nonce), + clientPublicKey, + serverPrivateKey, + ); + if (recovered === null) { + throw new Error('Failed to open NaCl box'); + } + return recovered; +} + +describe('UKYC sessionAuthorizations', () => { + describe('assertAttestedServerPublicKey', () => { + it('accepts a schema whose server public key matches the jwtChain', () => { + const jwtChain = buildJwt({ + sessionServerPublicKeyX: SERVER_PUBLIC_KEY_X, + nonce: 'n', + }); + + expect(() => + assertAttestedServerPublicKey([JWK], { + serverPublicKey: { x: SERVER_PUBLIC_KEY_X }, + jwtChain, + }), + ).not.toThrow(); + }); + + it('rejects a schema whose server public key was swapped after signing', () => { + const jwtChain = buildJwt({ + sessionServerPublicKeyX: SERVER_PUBLIC_KEY_X, + nonce: 'n', + }); + + expect(() => + assertAttestedServerPublicKey([JWK], { + serverPublicKey: { x: 'tampered' }, + jwtChain, + }), + ).toThrow('sessionServerPublicKey does not match'); + }); + }); + + describe('wrapUkycSessionAuthorizations', () => { + it('wraps the derived encryption key so the session server can recover it', () => { + const encryptionServer = box.keyPair(); + const capabilityServer = box.keyPair(); + const sessionClient = box.keyPair(); + const localUserSecret = new Uint8Array( + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, + ).fill(9); + + const wrapped = wrapUkycSessionAuthorizations({ + sessionClientPrivateKey: sessionClient.secretKey, + encryptionDataKey: { + serverPublicKey: { x: toBase64Url(encryptionServer.publicKey) }, + jwtChain: 'unused', + }, + capabilityTokenSchema: { + serverPublicKey: { x: toBase64Url(capabilityServer.publicKey) }, + jwtChain: 'unused', + }, + localUserSecret, + }); + + const recoveredKey = unwrap( + encryptionServer.secretKey, + sessionClient.publicKey, + wrapped.wrappedEncryptionDataKey.data, + wrapped.wrappedEncryptionDataKey.nonce, + ); + expect( + areUint8ArraysEqual( + recoveredKey, + deriveClientMaterial(localUserSecret).dataEncryptionKey, + ), + ).toBe(true); + + const recoveredToken = unwrap( + capabilityServer.secretKey, + sessionClient.publicKey, + wrapped.wrappedUkycCapabilityToken.data, + wrapped.wrappedUkycCapabilityToken.nonce, + ); + expect(recoveredToken.byteLength).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/kyc-controller/src/ukyc/sessionAuthorizations.ts b/packages/kyc-controller/src/ukyc/sessionAuthorizations.ts new file mode 100644 index 00000000000..ddceae00b6b --- /dev/null +++ b/packages/kyc-controller/src/ukyc/sessionAuthorizations.ts @@ -0,0 +1,99 @@ +import { stringToBytes } from '@metamask/utils'; + +import { deriveClientMaterial } from './deriveClientMaterial.js'; +import { verifyJwtChain } from './jwtChain.js'; +import type { Jwk } from './jwtChain.js'; +import { + encodeStorageAccessTokenForHeader, + signStorageAccessToken, +} from './storageAccessToken.js'; +import { wrapEncryptionKey } from './wrapEncryptionKey.js'; +import type { WrappedEncryptionKeyParts } from './wrapEncryptionKey.js'; + +/** + * 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. + */ +export const UKYC_CAPABILITY_TOKEN_TTL_MS = 4 * 60 * 60 * 1000; + +/** + * Per-secret encryption schema from `createUkycSession`. Only the attested + * server public key and jwtChain are needed to wrap authorizations. + */ +export type UkycEncryptionSchema = { + serverPublicKey: { x: string }; + jwtChain: string; +}; + +/** + * Confirms that an encryption schema's `serverPublicKey.x` matches the + * `sessionServerPublicKeyX` attested inside its verified `jwtChain`. Rejects + * a key that was swapped out-of-band after the chain was signed. + * + * @param keys - The issuer JWKS used to verify the chain (idOS enclave for + * `encryptionDataKey`, idOS relay for `ukycCapabilityToken`). + * @param schema - The encryption schema returned by session creation. + */ +export function assertAttestedServerPublicKey( + keys: Jwk[], + schema: UkycEncryptionSchema, +): void { + const jwtChainPayload = verifyJwtChain(keys, schema.jwtChain); + if (jwtChainPayload.sessionServerPublicKeyX !== schema.serverPublicKey.x) { + throw new Error( + 'sessionServerPublicKey does not match the verified jwtChain payload (sessionServerPublicKeyX).', + ); + } +} + +/** + * Derives the `data_encryption_key` from `local_user_secret`, mints a + * read-only capability token, and wraps both for the session server. Only the + * wrapped (encrypted) material should leave the device. + * + * @param params - Wrapping inputs. + * @param params.sessionClientPrivateKey - Per-session X25519 private key. + * @param params.encryptionDataKey - Schema used to wrap the encryption key. + * @param params.capabilityTokenSchema - Schema used to wrap the capability token. + * @param params.localUserSecret - Wallet UKYC `local_user_secret`. + * @param params.now - Clock used for the token `expires_at`. Defaults to `Date.now`. + * @returns Wrapped authorizations ready for `setAuthorizations`. + */ +export function wrapUkycSessionAuthorizations(params: { + sessionClientPrivateKey: Uint8Array; + encryptionDataKey: UkycEncryptionSchema; + capabilityTokenSchema: UkycEncryptionSchema; + localUserSecret: Uint8Array; +}): { + wrappedEncryptionDataKey: WrappedEncryptionKeyParts; + wrappedUkycCapabilityToken: WrappedEncryptionKeyParts; +} { + const { + sessionClientPrivateKey, + encryptionDataKey, + capabilityTokenSchema, + localUserSecret, + } = params; + const clientMaterial = deriveClientMaterial(localUserSecret); + + const wrappedEncryptionDataKey = wrapEncryptionKey( + sessionClientPrivateKey, + encryptionDataKey.serverPublicKey.x, + clientMaterial.dataEncryptionKey, + ); + const ukycCapabilityToken = signStorageAccessToken({ + material: clientMaterial, + // TODO: Confirm with idOS when this can be switched back to read and a separate token is sent for write + operations: ['read', 'write'], + expiresAt: new Date(Date.now() + UKYC_CAPABILITY_TOKEN_TTL_MS), + }); + const wrappedUkycCapabilityToken = wrapEncryptionKey( + sessionClientPrivateKey, + capabilityTokenSchema.serverPublicKey.x, + stringToBytes(encodeStorageAccessTokenForHeader(ukycCapabilityToken)), + ); + + return { wrappedEncryptionDataKey, wrappedUkycCapabilityToken }; +}