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 };
+}