From 572b4d1db32a254c39c0f4c3facf7cccae0dd317 Mon Sep 17 00:00:00 2001 From: Jiexi Luan-Huang Date: Wed, 2 Sep 2026 17:33:47 -0700 Subject: [PATCH 01/13] move Moonpay frame handling out of KycController --- packages/kyc-controller/src/KycController.ts | 274 +++------------- .../src/moonpay/MoonPayFrameHandler.ts | 304 ++++++++++++++++++ 2 files changed, 343 insertions(+), 235 deletions(-) create mode 100644 packages/kyc-controller/src/moonpay/MoonPayFrameHandler.ts diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 38c8dc3a0d..001c72db27 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -13,8 +13,6 @@ import type { Json } from '@metamask/utils'; import { stringToBytes } from '@metamask/utils'; import { x25519 } from '@noble/curves/ed25519'; -import { decryptCredentials, generateKeyPair } from './crypto.js'; -import type { EncryptedCredentialsEnvelope, X25519KeyPair } from './crypto.js'; import { toBase64Url } from './encoding.js'; import type { KycControllerMethodActions } from './KycController-method-action-types.js'; import type { KycServiceMethodActions } from './KycService-method-action-types.js'; @@ -39,6 +37,10 @@ import type { KycVendor, KycVendorDisclaimersAccepted, } from './types.js'; +import { + clearMoonPaySession, + MoonPayFrameHandler, +} from './moonpay/MoonPayFrameHandler.js'; import { deriveClientMaterial } from './ukyc/deriveClientMaterial.js'; import { verifyJwtChain } from './ukyc/jwtChain.js'; import type { Jwk } from './ukyc/jwtChain.js'; @@ -60,11 +62,6 @@ import { export const controllerName = 'KycController'; -const FRAMES_BASE_URL = 'https://blocks.moonpay.com/platform/v1'; -const CHANNEL_CHECK = 'ch_1'; -const CHANNEL_AUTH = 'ch_2'; -const CHANNEL_RESET = 'ch_reset'; - // Placeholder credentials for the SumSub sub-flow. These are demo values that // must be replaced with real UKYC-issued material before production use. const MOCK_JWT_TOKEN = 'mock-jwt-token'; @@ -666,33 +663,14 @@ export type KycControllerOptions = { userStatusPollIntervalMs?: number; }; -/** - * The shape of a message posted by a Check/Auth frame. - */ -type FrameMessage = { - meta?: { channelId?: string }; - kind?: string; - payload?: { - status?: - | 'active' - | 'connectionRequired' - | 'termsAcceptanceRequired' - | 'pending' - | 'unavailable' - | 'failed'; - credentials?: EncryptedCredentialsEnvelope | string; - customer?: { id?: string }; - }; -}; - // === CONTROLLER DEFINITION === /** * `KycController` orchestrates the vendor-backed KYC / identity-verification * flow (MoonPay identity + SumSub documents) behind a vendor-neutral, per - * product surface used by ramps and card. It owns all state, HTTP - * orchestration (via `KycService`), crypto, and the frame message protocol; - * platform-specific presentation (WebView/iframe, SumSub SDK) is injected. + * product surface used by ramps and card. It owns all state and HTTP + * orchestration (via `KycService`), while vendor protocol handling and + * platform-specific presentation (WebView/iframe, SumSub SDK) are delegated. */ export class KycController extends BaseController< typeof controllerName, @@ -701,11 +679,8 @@ export class KycController extends BaseController< > { readonly #sumsubLauncher: KycSumSubLauncher; - /** MoonPay Check/Auth frame X25519 keypair (never persisted). */ - #moonpayFrameKeypair: X25519KeyPair | null = null; - - /** Auth-frame client token, kept out of state. */ - #authClientToken: string | null = null; + /** MoonPay-specific frame protocol and non-persisted credentials. */ + readonly #moonPayFrames: MoonPayFrameHandler; /** * Monotonic flow generation. Incremented by {@link reset} and @@ -777,6 +752,14 @@ export class KycController extends BaseController< this.#sumsubLauncher = sumsubLauncher; this.#sessionStatusPollIntervalMs = sessionStatusPollIntervalMs; this.#userStatusPollIntervalMs = userStatusPollIntervalMs; + this.#moonPayFrames = new MoonPayFrameHandler({ + getState: (): KycControllerState => this.state, + update: (updater): void => this.#applyUpdate(updater), + fail: (message): void => this.#fail(message), + onAuthenticated: async (): Promise => + this.#continueAfterAuthentication(), + requireTermsReacceptance: (): void => this.#requireTermsReacceptance(), + }); this.messenger.registerMethodActionHandlers( this, @@ -843,16 +826,16 @@ export class KycController extends BaseController< const vendor = params?.vendor ?? 'moonpay'; if (IN_PROGRESS_PHASES.includes(this.state.phase)) { - if (vendor === 'moonpay' && !this.#moonpayFrameKeypair) { - this.#moonpayFrameKeypair = generateKeyPair(); + if (vendor === 'moonpay') { + this.#moonPayFrames.ensureKeypair(); } return; } if (vendor === 'moonpay') { - this.#moonpayFrameKeypair = generateKeyPair(); + this.#moonPayFrames.startFlow(); } else { - this.#moonpayFrameKeypair = null; + this.#moonPayFrames.clear(); } // `initialize` starts a fresh flow, so `activeProduct` is always reset to @@ -866,12 +849,11 @@ export class KycController extends BaseController< state.activeVendor = vendor; // MoonPay Check/Auth artifacts must not survive a switch to another // vendor: leftover `moonpaySessionToken` would keep `buildCheckFrameUrl` alive, - // leftover `moonpayAccessToken` / `#authClientToken` would keep Auth / KYC - // calls bound to MoonPay, and leftover `moonpayCustomerId` would make + // leftover access/auth tokens would keep Auth / KYC calls bound to + // MoonPay, and leftover `moonpayCustomerId` would make // `getCustomerIdentity` report a MoonPay id under the wrong vendor. if (vendor !== 'moonpay') { - this.#authClientToken = null; - this.#clearMoonPaySession(state); + clearMoonPaySession(state); } state.activeProduct = params?.product ?? null; }); @@ -997,9 +979,8 @@ export class KycController extends BaseController< // after this request succeeds. state.activeVendor = params.vendor; if (params.vendor !== 'moonpay') { - this.#authClientToken = null; - this.#moonpayFrameKeypair = null; - this.#clearMoonPaySession(state); + this.#moonPayFrames.clear(); + clearMoonPaySession(state); } }); const generation = this.#generation; @@ -1166,7 +1147,7 @@ export class KycController extends BaseController< state.sumsub.result = null; state.sumsub.sessionStatus = null; // Consents-path vendors have no MoonPay session/access tokens. - this.#clearMoonPaySession(state); + clearMoonPaySession(state); }); try { @@ -1430,7 +1411,7 @@ export class KycController extends BaseController< // the now-idle controller (failure). The synchronous update below runs // before any `await`, so it needs no guard. const generation = this.#generation; - this.#authClientToken = null; + this.#moonPayFrames.clearAuthentication(); this.#applyUpdate((state) => { state.error = null; state.phase = 'session'; @@ -1509,20 +1490,6 @@ export class KycController extends BaseController< state.credentialReusabilityConsentGiven = null; } - /** - * Drops MoonPay Check/Auth artifacts from the draft. Used when switching - * away from MoonPay (and again when the consents path starts) so leftover - * tokens cannot keep `buildCheckFrameUrl` / `buildAuthFrameUrl` alive for - * a consents-path vendor. - * - * @param state - The state to mutate. - */ - #clearMoonPaySession(state: KycControllerState): void { - state.moonpayCustomerId = null; - state.moonpaySessionToken = null; - state.moonpayAccessToken = null; - } - /** * Handles a message posted by a Check/Auth frame and advances the flow. * @@ -1536,144 +1503,7 @@ export class KycController extends BaseController< async handleFrameMessage(params: { message: unknown; }): Promise<{ reply?: unknown }> { - const payload = params.message as FrameMessage | undefined; - - if (!payload) { - return {}; - } - - if (payload.kind === 'handshake') { - const channelId = payload.meta?.channelId; - return { reply: { version: 2, meta: { channelId }, kind: 'ack' } }; - } - - if (payload.kind !== 'complete') { - return {}; - } - - const channelId = payload.meta?.channelId; - - // Only honor a Check/Auth `complete` for the MoonPay frame the flow is - // currently waiting on. This drops stale or duplicate messages — e.g. a - // late post after `reset()` (phase `idle`), after the flow already - // advanced past this frame, or after a vendor switch — so they cannot - // resurrect tokens, rewind `phase`, or recapture `moonpayCustomerId` on a - // controller that has moved on. Frame messages are external input and, - // unlike the async steps, are not covered by the `#generation` guard. - let expectedPhase: KycPhase | null = null; - if (channelId === CHANNEL_CHECK) { - expectedPhase = 'check'; - } else if (channelId === CHANNEL_AUTH) { - expectedPhase = 'auth'; - } - if ( - !expectedPhase || - this.state.phase !== expectedPhase || - this.state.activeVendor !== 'moonpay' - ) { - return {}; - } - - const status = payload.payload?.status; - const credsEnvelope = payload.payload?.credentials; - - const customerId = payload.payload?.customer?.id ?? null; - if (customerId) { - this.#applyUpdate((state) => { - state.moonpayCustomerId = customerId; - }); - } - - if (!status) { - return {}; - } - - let accessToken: string | undefined; - let clientToken: string | undefined; - if (credsEnvelope && this.#moonpayFrameKeypair) { - try { - const { credentials } = decryptCredentials( - credsEnvelope, - this.#moonpayFrameKeypair.privateKey, - ); - accessToken = credentials.accessToken; - clientToken = credentials.clientToken; - } catch (error) { - this.#fail(`Failed to decrypt frame credentials: ${String(error)}`); - return {}; - } - } - - if (channelId === CHANNEL_CHECK) { - await this.#handleCheckOutcome(status, accessToken, clientToken); - return {}; - } - - // channelId === CHANNEL_AUTH, guaranteed by the expectedPhase guard above. - await this.#handleAuthOutcome(status, accessToken); - return {}; - } - - /** - * Applies a Check-frame outcome. - * - * @param status - The frame status. - * @param accessToken - The decrypted access token, if any. - * @param clientToken - The decrypted client token, if any. - */ - async #handleCheckOutcome( - status: NonNullable['status'], - accessToken?: string, - clientToken?: string, - ): Promise { - if (status === 'active' && accessToken) { - this.#applyUpdate((state) => { - state.moonpayAccessToken = accessToken; - state.phase = 'form'; - state.statusMessage = 'Already authenticated. Review to submit.'; - }); - await this.#continueAfterAuthentication(); - return; - } - if (status === 'connectionRequired' && clientToken) { - this.#authClientToken = clientToken; - this.#applyUpdate((state) => { - state.phase = 'auth'; - state.statusMessage = 'Verify your email via OTP in the Auth frame.'; - }); - return; - } - if (status === 'termsAcceptanceRequired') { - this.#requireTermsReacceptance(); - return; - } - this.#fail(`Check frame returned status: ${status}`); - } - - /** - * Applies an Auth-frame outcome. - * - * @param status - The frame status. - * @param accessToken - The decrypted access token, if any. - */ - async #handleAuthOutcome( - status: NonNullable['status'], - accessToken?: string, - ): Promise { - if (status === 'active' && accessToken) { - this.#applyUpdate((state) => { - state.moonpayAccessToken = accessToken; - state.phase = 'form'; - state.statusMessage = 'Authenticated. Review to submit.'; - }); - await this.#continueAfterAuthentication(); - return; - } - if (status === 'termsAcceptanceRequired') { - this.#requireTermsReacceptance(); - return; - } - this.#fail(`Auth frame returned status: ${status}`); + return await this.#moonPayFrames.handleMessage(params.message); } /** @@ -1735,19 +1565,7 @@ export class KycController extends BaseController< * @returns The Check-frame URL or `null`. */ buildCheckFrameUrl(): string | null { - if ( - this.state.activeVendor !== 'moonpay' || - !this.state.moonpaySessionToken || - !this.#moonpayFrameKeypair - ) { - return null; - } - const url = new URL(`${FRAMES_BASE_URL}/check-connection`); - url.searchParams.set('sessionToken', this.state.moonpaySessionToken); - url.searchParams.set('publicKey', this.#moonpayFrameKeypair.publicKeyHex); - url.searchParams.set('channelId', CHANNEL_CHECK); - url.searchParams.set('skipKyc', 'true'); - return url.toString(); + return this.#moonPayFrames.buildCheckFrameUrl(); } /** @@ -1756,18 +1574,7 @@ export class KycController extends BaseController< * @returns The Auth-frame URL or `null`. */ buildAuthFrameUrl(): string | null { - if ( - this.state.activeVendor !== 'moonpay' || - !this.#authClientToken || - !this.#moonpayFrameKeypair - ) { - return null; - } - const url = new URL(`${FRAMES_BASE_URL}/auth`); - url.searchParams.set('clientToken', this.#authClientToken); - url.searchParams.set('publicKey', this.#moonpayFrameKeypair.publicKeyHex); - url.searchParams.set('channelId', CHANNEL_AUTH); - return url.toString(); + return this.#moonPayFrames.buildAuthFrameUrl(); } /** @@ -1776,9 +1583,7 @@ export class KycController extends BaseController< * @returns The Reset-frame URL. */ buildResetFrameUrl(): string { - const url = new URL(`${FRAMES_BASE_URL}/reset`); - url.searchParams.set('channelId', CHANNEL_RESET); - return url.toString(); + return this.#moonPayFrames.buildResetFrameUrl(); } /** @@ -2506,9 +2311,7 @@ export class KycController extends BaseController< state.vendorError = null; state.sessionDisclaimers = null; state.credentialReusabilityConsentGiven = null; - state.moonpaySessionToken = null; - state.moonpayAccessToken = null; - state.moonpayCustomerId = null; + clearMoonPaySession(state); state.activeVendor = 'moonpay'; state.activeProduct = null; state.sumsub = { @@ -2537,13 +2340,14 @@ export class KycController extends BaseController< } /** - * Tears down everything that lives outside state: drops the auth-frame - * client token, stops both polling loops, and bumps the flow generation so - * async steps started earlier discard their results instead of writing them - * onto the controller. Shared by {@link reset} and {@link clearState}. + * Tears down everything that lives outside state: drops the MoonPay frame + * keypair and auth client token, stops both polling loops, and bumps the flow + * generation so async steps started earlier discard their results instead + * of writing them onto the controller. Shared by {@link reset} and + * {@link clearState}. */ #cancelPendingSession(): void { - this.#authClientToken = null; + this.#moonPayFrames.clear(); this.#stopPolling(); this.#stopUserStatusPolling(); this.#generation += 1; diff --git a/packages/kyc-controller/src/moonpay/MoonPayFrameHandler.ts b/packages/kyc-controller/src/moonpay/MoonPayFrameHandler.ts new file mode 100644 index 0000000000..e03bdc31bf --- /dev/null +++ b/packages/kyc-controller/src/moonpay/MoonPayFrameHandler.ts @@ -0,0 +1,304 @@ +import { decryptCredentials, generateKeyPair } from '../crypto.js'; +import type { EncryptedCredentialsEnvelope, X25519KeyPair } from '../crypto.js'; +import type { KycPhase, KycVendor } from '../types.js'; + +const FRAMES_BASE_URL = 'https://blocks.moonpay.com/platform/v1'; +const CHANNEL_CHECK = 'ch_1'; +const CHANNEL_AUTH = 'ch_2'; +const CHANNEL_RESET = 'ch_reset'; + +type MoonPayFrameState = { + activeVendor: KycVendor; + moonpayAccessToken: string | null; + moonpayCustomerId: string | null; + moonpaySessionToken: string | null; + phase: KycPhase; + statusMessage: string; +}; + +type FrameMessage = { + meta?: { channelId?: string }; + kind?: string; + payload?: { + status?: + | 'active' + | 'connectionRequired' + | 'termsAcceptanceRequired' + | 'pending' + | 'unavailable' + | 'failed'; + credentials?: EncryptedCredentialsEnvelope | string; + customer?: { id?: string }; + }; +}; + +type FrameStatus = NonNullable['status']; + +export type MoonPayFrameHandlerOptions = { + getState: () => MoonPayFrameState; + update: (updater: (state: MoonPayFrameState) => void) => void; + fail: (message: string) => void; + onAuthenticated: () => Promise; + requireTermsReacceptance: () => void; +}; + +/** + * Owns MoonPay Check/Auth frame state and protocol handling. + */ +export class MoonPayFrameHandler { + readonly #getState: MoonPayFrameHandlerOptions['getState']; + + readonly #update: MoonPayFrameHandlerOptions['update']; + + readonly #fail: MoonPayFrameHandlerOptions['fail']; + + readonly #onAuthenticated: MoonPayFrameHandlerOptions['onAuthenticated']; + + readonly #requireTermsReacceptance: MoonPayFrameHandlerOptions['requireTermsReacceptance']; + + /** MoonPay Check/Auth frame X25519 keypair (never persisted). */ + #frameKeypair: X25519KeyPair | null = null; + + /** Auth-frame client token, kept out of controller state. */ + #authClientToken: string | null = null; + + constructor({ + getState, + update, + fail, + onAuthenticated, + requireTermsReacceptance, + }: MoonPayFrameHandlerOptions) { + this.#getState = getState; + this.#update = update; + this.#fail = fail; + this.#onAuthenticated = onAuthenticated; + this.#requireTermsReacceptance = requireTermsReacceptance; + } + + /** + * Creates a fresh keypair for a new MoonPay flow. + */ + startFlow(): void { + this.#frameKeypair = generateKeyPair(); + } + + /** + * Ensures an in-progress MoonPay flow has a frame keypair. + */ + ensureKeypair(): void { + this.#frameKeypair ??= generateKeyPair(); + } + + /** + * Clears all non-persisted MoonPay frame artifacts. + */ + clear(): void { + this.#frameKeypair = null; + this.#authClientToken = null; + } + + /** + * Clears authentication associated with an earlier MoonPay session. + */ + clearAuthentication(): void { + this.#authClientToken = null; + } + + /** + * Handles a message posted by a MoonPay Check/Auth frame. + * + * @param message - The raw message posted by the frame. + * @returns An object whose optional `reply` should be posted back. + */ + async handleMessage(message: unknown): Promise<{ reply?: unknown }> { + const payload = message as FrameMessage | undefined; + + if (!payload) { + return {}; + } + + if (payload.kind === 'handshake') { + const channelId = payload.meta?.channelId; + return { reply: { version: 2, meta: { channelId }, kind: 'ack' } }; + } + + if (payload.kind !== 'complete') { + return {}; + } + + const channelId = payload.meta?.channelId; + const state = this.#getState(); + + // Only honor a completion for the MoonPay frame the flow is currently + // waiting on. This drops stale or duplicate messages after reset, phase + // advancement, or a vendor switch so they cannot restore tokens or rewind + // the flow. Unlike awaited controller work, external frame messages are + // not protected by the controller's generation guard. + let expectedPhase: KycPhase | null = null; + if (channelId === CHANNEL_CHECK) { + expectedPhase = 'check'; + } else if (channelId === CHANNEL_AUTH) { + expectedPhase = 'auth'; + } + + if ( + !expectedPhase || + state.phase !== expectedPhase || + state.activeVendor !== 'moonpay' + ) { + return {}; + } + + const status = payload.payload?.status; + const credentialsEnvelope = payload.payload?.credentials; + const customerId = payload.payload?.customer?.id ?? null; + + if (customerId) { + this.#update((draft) => { + draft.moonpayCustomerId = customerId; + }); + } + + if (!status) { + return {}; + } + + let accessToken: string | undefined; + let clientToken: string | undefined; + if (credentialsEnvelope && this.#frameKeypair) { + try { + const { credentials } = decryptCredentials( + credentialsEnvelope, + this.#frameKeypair.privateKey, + ); + accessToken = credentials.accessToken; + clientToken = credentials.clientToken; + } catch (error) { + this.#fail(`Failed to decrypt frame credentials: ${String(error)}`); + return {}; + } + } + + if (channelId === CHANNEL_CHECK) { + await this.#handleCheckOutcome(status, accessToken, clientToken); + } else { + await this.#handleAuthOutcome(status, accessToken); + } + return {}; + } + + /** + * Builds the Check-frame URL, or `null` when no session exists yet. + * + * @returns The Check-frame URL or `null`. + */ + buildCheckFrameUrl(): string | null { + const state = this.#getState(); + if ( + state.activeVendor !== 'moonpay' || + !state.moonpaySessionToken || + !this.#frameKeypair + ) { + return null; + } + const url = new URL(`${FRAMES_BASE_URL}/check-connection`); + url.searchParams.set('sessionToken', state.moonpaySessionToken); + url.searchParams.set('publicKey', this.#frameKeypair.publicKeyHex); + url.searchParams.set('channelId', CHANNEL_CHECK); + url.searchParams.set('skipKyc', 'true'); + return url.toString(); + } + + /** + * Builds the Auth-frame URL, or `null` when no client token is available. + * + * @returns The Auth-frame URL or `null`. + */ + buildAuthFrameUrl(): string | null { + const state = this.#getState(); + if ( + state.activeVendor !== 'moonpay' || + !this.#authClientToken || + !this.#frameKeypair + ) { + return null; + } + const url = new URL(`${FRAMES_BASE_URL}/auth`); + url.searchParams.set('clientToken', this.#authClientToken); + url.searchParams.set('publicKey', this.#frameKeypair.publicKeyHex); + url.searchParams.set('channelId', CHANNEL_AUTH); + return url.toString(); + } + + /** + * Builds the Reset-frame URL. + * + * @returns The Reset-frame URL. + */ + buildResetFrameUrl(): string { + const url = new URL(`${FRAMES_BASE_URL}/reset`); + url.searchParams.set('channelId', CHANNEL_RESET); + return url.toString(); + } + + async #handleCheckOutcome( + status: FrameStatus, + accessToken?: string, + clientToken?: string, + ): Promise { + if (status === 'active' && accessToken) { + this.#update((state) => { + state.moonpayAccessToken = accessToken; + state.phase = 'form'; + state.statusMessage = 'Already authenticated. Review to submit.'; + }); + await this.#onAuthenticated(); + return; + } + if (status === 'connectionRequired' && clientToken) { + this.#authClientToken = clientToken; + this.#update((state) => { + state.phase = 'auth'; + state.statusMessage = 'Verify your email via OTP in the Auth frame.'; + }); + return; + } + if (status === 'termsAcceptanceRequired') { + this.#requireTermsReacceptance(); + return; + } + this.#fail(`Check frame returned status: ${status}`); + } + + async #handleAuthOutcome( + status: FrameStatus, + accessToken?: string, + ): Promise { + if (status === 'active' && accessToken) { + this.#update((state) => { + state.moonpayAccessToken = accessToken; + state.phase = 'form'; + state.statusMessage = 'Authenticated. Review to submit.'; + }); + await this.#onAuthenticated(); + return; + } + if (status === 'termsAcceptanceRequired') { + this.#requireTermsReacceptance(); + return; + } + this.#fail(`Auth frame returned status: ${status}`); + } +} + +/** + * Drops persisted-in-memory MoonPay session artifacts from controller state. + * + * @param state - The state to mutate. + */ +export function clearMoonPaySession(state: MoonPayFrameState): void { + state.moonpayCustomerId = null; + state.moonpaySessionToken = null; + state.moonpayAccessToken = null; +} From 728aabc0c59d84a84f6f71520b09c75026eec297 Mon Sep 17 00:00:00 2001 From: Jiexi Luan-Huang Date: Wed, 2 Sep 2026 17:45:51 -0700 Subject: [PATCH 02/13] Update specs. Rename moonpay folder to vendors --- .../kyc-controller/src/KycController.test.ts | 740 +++++------------- packages/kyc-controller/src/KycController.ts | 2 +- .../src/vendors/MoonPayFrameHandler.test.ts | 462 +++++++++++ .../MoonPayFrameHandler.ts | 0 4 files changed, 638 insertions(+), 566 deletions(-) create mode 100644 packages/kyc-controller/src/vendors/MoonPayFrameHandler.test.ts rename packages/kyc-controller/src/{moonpay => vendors}/MoonPayFrameHandler.ts (100%) diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index ac2cd4cb49..3dfb3d17f8 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -6,11 +6,7 @@ import type { MessengerEvents, } from '@metamask/messenger'; import { areUint8ArraysEqual, bytesToString } from '@metamask/utils'; -import { gcm } from '@noble/ciphers/aes'; import { x25519 } from '@noble/curves/ed25519'; -import { hkdf } from '@noble/hashes/hkdf'; -import { sha256 } from '@noble/hashes/sha2'; -import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils'; import { base64UrlToBytes, toBase64Url } from './encoding.js'; import { @@ -18,6 +14,8 @@ import { KycController, } from './KycController.js'; import type { KycControllerMessenger } from './KycController.js'; +import { MoonPayFrameHandler } from './vendors/MoonPayFrameHandler.js'; +import type { MoonPayFrameHandlerOptions } from './vendors/MoonPayFrameHandler.js'; import type { KycConsentRecord, KycDisclaimer, @@ -47,6 +45,17 @@ jest.mock('./ukyc/wrapEncryptionKey', () => { wrapEncryptionKey: jest.fn(), }; }); +jest.mock('./vendors/MoonPayFrameHandler', () => { + const actual = jest.requireActual('./vendors/MoonPayFrameHandler'); + return { + ...actual, + MoonPayFrameHandler: jest.fn(), + }; +}); + +const MockMoonPayFrameHandler = MoonPayFrameHandler as jest.MockedClass< + typeof MoonPayFrameHandler +>; const mockVerifyJwtChain = verifyJwtChain as jest.MockedFunction< typeof verifyJwtChain @@ -119,68 +128,45 @@ const VENDOR_TERMS_IRON_D1 = { }, }; +type MockMoonPayFrames = { + options: MoonPayFrameHandlerOptions; + startFlow: jest.Mock; + ensureKeypair: jest.Mock; + clear: jest.Mock; + clearAuthentication: jest.Mock; + handleMessage: jest.Mock; + buildCheckFrameUrl: jest.Mock; + buildAuthFrameUrl: jest.Mock; + buildResetFrameUrl: jest.Mock; +}; + /** - * Builds an encrypted envelope for a recipient's X25519 public key. + * Builds a mocked {@link MoonPayFrameHandler} that records constructor options + * so tests can invoke the controller callbacks it receives. * - * @param publicKey - The recipient's public key bytes. - * @param credentials - The plaintext credentials to encrypt. - * @returns The encrypted envelope. + * @param options - The options the controller passes into the handler. + * @returns The mock instance. */ -function makeEnvelope( - publicKey: Uint8Array, - credentials: Record, -): { ephemeralPublicKey: string; iv: string; ciphertext: string } { - const ephemeralPrivate = x25519.utils.randomSecretKey(); - const ephemeralPublic = x25519.getPublicKey(ephemeralPrivate); - const shared = x25519.getSharedSecret(ephemeralPrivate, publicKey); - const key = hkdf(sha256, shared, undefined, undefined, 32); - const iv = new Uint8Array(12).fill(7); - const ciphertext = gcm(key, iv).encrypt( - utf8ToBytes(JSON.stringify(credentials)), - ); +function createMockMoonPayFrames( + options: MoonPayFrameHandlerOptions, +): MockMoonPayFrames { return { - ephemeralPublicKey: bytesToHex(ephemeralPublic), - iv: bytesToHex(iv), - ciphertext: bytesToHex(ciphertext), + options, + startFlow: jest.fn(), + ensureKeypair: jest.fn(), + clear: jest.fn(), + clearAuthentication: jest.fn(), + handleMessage: jest.fn().mockResolvedValue({}), + buildCheckFrameUrl: jest.fn().mockReturnValue(null), + buildAuthFrameUrl: jest.fn().mockReturnValue(null), + buildResetFrameUrl: jest + .fn() + .mockReturnValue( + 'https://blocks.moonpay.com/platform/v1/reset?channelId=ch_reset', + ), }; } -/** - * Extracts the controller's ephemeral public key from the Check-frame URL and - * builds a decryptable credentials envelope for it. - * - * @param controller - The controller under test (must have a session token). - * @param credentials - The plaintext credentials to encrypt. - * @returns The encrypted envelope. - */ -async function envelopeFor( - controller: KycController, - credentials: Record, -): Promise<{ ephemeralPublicKey: string; iv: string; ciphertext: string }> { - let url = controller.buildCheckFrameUrl(); - if (!url) { - const inProgressPhases: KycController['state']['phase'][] = [ - 'session', - 'check', - 'auth', - 'form', - 'submit', - ]; - if (!inProgressPhases.includes(controller.state.phase)) { - throw new Error( - 'Controller needs a MoonPay frame keypair; call initialize({ vendor: "moonpay" }) first', - ); - } - await controller.initialize({ vendor: 'moonpay' }); - url = controller.buildCheckFrameUrl(); - } - if (!url) { - throw new Error('Could not build Check frame URL for envelope'); - } - const publicKeyHex = new URL(url).searchParams.get('publicKey') as string; - return makeEnvelope(hexToBytes(publicKeyHex), credentials); -} - describe('KycController', () => { describe('constructor', () => { it('accepts initial state merged over defaults', async () => { @@ -295,7 +281,7 @@ describe('KycController', () => { }, }, }, - async ({ controller, handlers }) => { + async ({ controller, handlers, moonPayFrames }) => { await controller.initialize({ email: 'other@b.co', product: 'card', @@ -314,6 +300,28 @@ describe('KycController', () => { expect(controller.state.email).toBe('a@b.co'); expect(controller.state.activeVendor).toBe('moonpay'); expect(controller.state.moonpayCustomerId).toBe('cust-1'); + expect(moonPayFrames.startFlow).not.toHaveBeenCalled(); + expect(moonPayFrames.clear).not.toHaveBeenCalled(); + }, + ); + }); + + it('ensures a MoonPay frame keypair when re-initialized mid-flow', async () => { + await withController( + { + options: { + state: { + phase: 'check', + moonpaySessionToken: 'live-session', + activeVendor: 'moonpay', + }, + }, + }, + async ({ controller, moonPayFrames }) => { + await controller.initialize({ vendor: 'moonpay' }); + + expect(moonPayFrames.ensureKeypair).toHaveBeenCalled(); + expect(moonPayFrames.startFlow).not.toHaveBeenCalled(); }, ); }); @@ -593,34 +601,18 @@ describe('KycController', () => { }, }, }, - async ({ controller, handlers }) => { + async ({ controller, handlers, moonPayFrames }) => { handlers.createSession.mockResolvedValue({ sessionToken: 'new-session', }); - // Establish an auth-frame client token from a prior authentication. - const envelope = await envelopeFor(controller, { - clientToken: 'old-client', - }); - await controller.handleFrameMessage({ - message: { - kind: 'complete', - meta: { channelId: 'ch_1' }, - payload: { status: 'connectionRequired', credentials: envelope }, - }, - }); - expect(controller.buildAuthFrameUrl()).toContain( - 'clientToken=old-client', - ); - - // Creating a new session must invalidate the carried-over auth. await controller.acceptTermsAndStartSession({ providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); + expect(moonPayFrames.clearAuthentication).toHaveBeenCalled(); expect(controller.state.moonpayAccessToken).toBeNull(); - expect(controller.buildAuthFrameUrl()).toBeNull(); expect(controller.state.moonpaySessionToken).toBe('new-session'); }, ); @@ -658,16 +650,11 @@ describe('KycController', () => { // must already be gone so no Check frame URL can be built for it. expect(controller.state.phase).toBe('session'); expect(controller.state.moonpaySessionToken).toBeNull(); - expect(controller.buildCheckFrameUrl()).toBeNull(); releaseSession({ sessionToken: 'new-session' }); await pending; expect(controller.state.moonpaySessionToken).toBe('new-session'); - await controller.initialize({ vendor: 'moonpay' }); - expect(controller.buildCheckFrameUrl()).toContain( - 'sessionToken=new-session', - ); }, ); }); @@ -698,7 +685,6 @@ describe('KycController', () => { // A failed creation must not leave the old session token behind, so // the Check frame cannot be built against an invalid session. expect(controller.state.moonpaySessionToken).toBeNull(); - expect(controller.buildCheckFrameUrl()).toBeNull(); }, ); }); @@ -880,282 +866,19 @@ describe('KycController', () => { }); describe('handleFrameMessage', () => { - it('acks a handshake', async () => { - await withController(async ({ controller }) => { - const result = await controller.handleFrameMessage({ - message: { kind: 'handshake', meta: { channelId: 'ch_1' } }, - }); - expect(result).toStrictEqual({ + it('forwards the raw message to MoonPayFrameHandler', async () => { + await withController(async ({ controller, moonPayFrames }) => { + moonPayFrames.handleMessage.mockResolvedValue({ reply: { version: 2, meta: { channelId: 'ch_1' }, kind: 'ack' }, }); - }); - }); - - it('ignores undefined and non-complete messages', async () => { - await withController(async ({ controller }) => { - expect( - await controller.handleFrameMessage({ message: undefined }), - ).toStrictEqual({}); - expect( - await controller.handleFrameMessage({ message: { kind: 'other' } }), - ).toStrictEqual({}); - }); - }); + const message = { kind: 'handshake', meta: { channelId: 'ch_1' } }; - it('captures the customer id and ignores a status-less complete message', async () => { - await withController( - { options: { state: { phase: 'check' } } }, - async ({ controller }) => { - const result = await controller.handleFrameMessage({ - message: { - kind: 'complete', - meta: { channelId: 'ch_1' }, - payload: { customer: { id: 'cust-1' } }, - }, - }); - expect(result).toStrictEqual({}); - expect(controller.state.moonpayCustomerId).toBe('cust-1'); - }, - ); - }); + const result = await controller.handleFrameMessage({ message }); - it('ignores messages on an unknown channel', async () => { - await withController(async ({ controller }) => { - const result = await controller.handleFrameMessage({ - message: { - kind: 'complete', - meta: { channelId: 'ch_unknown' }, - payload: { status: 'active' }, - }, + expect(moonPayFrames.handleMessage).toHaveBeenCalledWith(message); + expect(result).toStrictEqual({ + reply: { version: 2, meta: { channelId: 'ch_1' }, kind: 'ack' }, }); - expect(result).toStrictEqual({}); - }); - }); - - it('ignores a stale completion for a frame the flow is no longer waiting on', async () => { - // Phase `done` (e.g. after a completed flow or a `reset()` that returns - // to an idle phase) means the Check frame is no longer active; a late or - // duplicate `ch_1` completion must not resurrect tokens or rewind phase. - await withController( - { options: { state: { phase: 'done', moonpaySessionToken: 'tok' } } }, - async ({ controller }) => { - const result = await controller.handleFrameMessage({ - message: { - kind: 'complete', - meta: { channelId: 'ch_1' }, - payload: { - status: 'active', - credentials: 'not-used', - customer: { id: 'cust-late' }, - }, - }, - }); - expect(result).toStrictEqual({}); - expect(controller.state.phase).toBe('done'); - expect(controller.state.moonpayAccessToken).toBeNull(); - expect(controller.state.moonpayCustomerId).toBeNull(); - }, - ); - }); - - it('ignores a Check complete when the active vendor is not MoonPay', async () => { - await withController( - { - options: { - state: { - phase: 'check', - activeVendor: 'iron', - moonpaySessionToken: 'tok', - }, - }, - }, - async ({ controller }) => { - const result = await controller.handleFrameMessage({ - message: { - kind: 'complete', - meta: { channelId: 'ch_1' }, - payload: { - status: 'active', - credentials: 'not-decryptable', - customer: { id: 'cust-late' }, - }, - }, - }); - - expect(result).toStrictEqual({}); - expect(controller.state.phase).toBe('check'); - expect(controller.state.moonpayAccessToken).toBeNull(); - expect(controller.state.moonpayCustomerId).toBeNull(); - expect(controller.getCustomerIdentity()).toBeNull(); - }, - ); - }); - - it('fails when credential decryption throws', async () => { - await withController( - { options: { state: { phase: 'check', moonpaySessionToken: 'tok' } } }, - async ({ controller }) => { - await controller.initialize({ vendor: 'moonpay' }); - await controller.handleFrameMessage({ - message: { - kind: 'complete', - meta: { channelId: 'ch_1' }, - payload: { status: 'active', credentials: 'not-decryptable' }, - }, - }); - expect(controller.state.phase).toBe('error'); - expect(controller.state.error).toMatch(/Failed to decrypt/u); - }, - ); - }); - - describe('check frame', () => { - it('moves to form on an active status with an access token', async () => { - await withController( - { - options: { state: { phase: 'check', moonpaySessionToken: 'tok' } }, - }, - async ({ controller }) => { - const envelope = await envelopeFor(controller, { - accessToken: 'access-1', - }); - await controller.handleFrameMessage({ - message: { - kind: 'complete', - meta: { channelId: 'ch_1' }, - payload: { status: 'active', credentials: envelope }, - }, - }); - expect(controller.state.phase).toBe('form'); - expect(controller.state.moonpayAccessToken).toBe('access-1'); - }, - ); - }); - - it('moves to auth on connectionRequired and enables the auth frame URL', async () => { - await withController( - { - options: { state: { phase: 'check', moonpaySessionToken: 'tok' } }, - }, - async ({ controller }) => { - const envelope = await envelopeFor(controller, { - clientToken: 'client-1', - }); - await controller.handleFrameMessage({ - message: { - kind: 'complete', - meta: { channelId: 'ch_1' }, - payload: { - status: 'connectionRequired', - credentials: envelope, - }, - }, - }); - expect(controller.state.phase).toBe('auth'); - expect(controller.buildAuthFrameUrl()).toContain( - 'clientToken=client-1', - ); - }, - ); - }); - - it('requires re-acceptance on termsAcceptanceRequired', async () => { - await withController( - { - options: { - state: { - phase: 'check', - moonpaySessionToken: 'tok', - ...VENDOR_TERMS_MOONPAY, - }, - }, - }, - async ({ controller }) => { - await controller.handleFrameMessage({ - message: { - kind: 'complete', - meta: { channelId: 'ch_1' }, - payload: { status: 'termsAcceptanceRequired' }, - }, - }); - expect(controller.state.phase).toBe('terms'); - expect( - controller.state.vendorDisclaimersAccepted.moonpay, - ).toBeNull(); - }, - ); - }); - - it('fails on an unexpected status', async () => { - await withController( - { - options: { state: { phase: 'check', moonpaySessionToken: 'tok' } }, - }, - async ({ controller }) => { - await controller.handleFrameMessage({ - message: { - kind: 'complete', - meta: { channelId: 'ch_1' }, - payload: { status: 'failed' }, - }, - }); - expect(controller.state.phase).toBe('error'); - }, - ); - }); - }); - - describe('auth frame', () => { - it('moves to form on an active status with an access token', async () => { - await withController( - { options: { state: { phase: 'auth', moonpaySessionToken: 'tok' } } }, - async ({ controller }) => { - const envelope = await envelopeFor(controller, { - accessToken: 'access-2', - }); - await controller.handleFrameMessage({ - message: { - kind: 'complete', - meta: { channelId: 'ch_2' }, - payload: { status: 'active', credentials: envelope }, - }, - }); - expect(controller.state.phase).toBe('form'); - expect(controller.state.moonpayAccessToken).toBe('access-2'); - }, - ); - }); - - it('requires re-acceptance on termsAcceptanceRequired', async () => { - await withController( - { options: { state: { phase: 'auth' } } }, - async ({ controller }) => { - await controller.handleFrameMessage({ - message: { - kind: 'complete', - meta: { channelId: 'ch_2' }, - payload: { status: 'termsAcceptanceRequired' }, - }, - }); - expect(controller.state.phase).toBe('terms'); - }, - ); - }); - - it('fails on an unexpected status', async () => { - await withController( - { options: { state: { phase: 'auth' } } }, - async ({ controller }) => { - await controller.handleFrameMessage({ - message: { - kind: 'complete', - meta: { channelId: 'ch_2' }, - payload: { status: 'unavailable' }, - }, - }); - expect(controller.state.phase).toBe('error'); - }, - ); }); }); }); @@ -1166,26 +889,15 @@ describe('KycController', () => { { options: { state: { - phase: 'check', - moonpaySessionToken: 'tok', + phase: 'form', + moonpayAccessToken: 'access-1', geoCountry: 'USA', }, }, }, - async ({ controller, handlers }) => { - const envelope = await envelopeFor(controller, { - accessToken: 'access-1', - }); - - await controller.handleFrameMessage({ - message: { - kind: 'complete', - meta: { channelId: 'ch_1' }, - payload: { status: 'active', credentials: envelope }, - }, - }); + async ({ handlers, moonPayFrames }) => { + await moonPayFrames.options.onAuthenticated(); - expect(controller.state.phase).toBe('form'); expect(handlers.checkKycRequired).not.toHaveBeenCalled(); }, ); @@ -1196,26 +908,17 @@ describe('KycController', () => { { options: { state: { - phase: 'check', - moonpaySessionToken: 'tok', + phase: 'form', + moonpayAccessToken: 'access-1', activeProduct: 'ramps', geoCountry: 'USA', }, }, }, - async ({ controller, handlers, launcher }) => { + async ({ controller, handlers, launcher, moonPayFrames }) => { handlers.checkKycRequired.mockResolvedValue({ kycRequired: false }); - const envelope = await envelopeFor(controller, { - accessToken: 'access-1', - }); - await controller.handleFrameMessage({ - message: { - kind: 'complete', - meta: { channelId: 'ch_1' }, - payload: { status: 'active', credentials: envelope }, - }, - }); + await moonPayFrames.options.onAuthenticated(); expect(handlers.checkKycRequired).toHaveBeenCalledWith({ accessToken: 'access-1', @@ -1229,35 +932,26 @@ describe('KycController', () => { ); }); - it('auto-chains into document verification when KYC is required (via the auth frame)', async () => { + it('auto-chains into document verification when KYC is required', async () => { await withController( { options: { state: { - phase: 'auth', - moonpaySessionToken: 'tok', + phase: 'form', + moonpayAccessToken: 'access-2', activeProduct: 'card', geoCountry: 'FRA', }, }, }, - async ({ controller, handlers, launcher }) => { + async ({ controller, handlers, launcher, moonPayFrames }) => { handlers.checkKycRequired.mockResolvedValue({ kycRequired: true }); launcher.launch.mockImplementation(async ({ onStatusChange }) => { onStatusChange?.('InProgress', 'Completed'); return { ok: true }; }); - const envelope = await envelopeFor(controller, { - accessToken: 'access-2', - }); - await controller.handleFrameMessage({ - message: { - kind: 'complete', - meta: { channelId: 'ch_2' }, - payload: { status: 'active', credentials: envelope }, - }, - }); + await moonPayFrames.options.onAuthenticated(); expect(controller.state.kycRequiredByProduct.card).toBe(true); expect(launcher.launch).toHaveBeenCalledTimes(1); @@ -1271,119 +965,39 @@ describe('KycController', () => { { options: { state: { - phase: 'check', - moonpaySessionToken: 'tok', + phase: 'form', + moonpayAccessToken: 'access-1', activeProduct: 'ramps', geoCountry: 'USA', }, }, }, - async ({ controller, handlers, launcher }) => { + async ({ controller, handlers, launcher, moonPayFrames }) => { handlers.checkKycRequired.mockResolvedValue({ kycRequired: true }); launcher.isAvailable.mockReturnValue(false); - const envelope = await envelopeFor(controller, { - accessToken: 'access-1', - }); - const result = await controller.handleFrameMessage({ - message: { - kind: 'complete', - meta: { channelId: 'ch_1' }, - payload: { status: 'active', credentials: envelope }, - }, - }); + await moonPayFrames.options.onAuthenticated(); - expect(result).toStrictEqual({}); expect(controller.state.sumsub.status).toBe('failed'); }, ); }); - it('ignores a duplicate completion while a prior continuation is in flight', async () => { - await withController( - { - options: { - state: { - phase: 'auth', - moonpaySessionToken: 'tok', - activeProduct: 'card', - geoCountry: 'FRA', - }, - }, - }, - async ({ controller, handlers, launcher }) => { - // Hold the KYC-required check open so the first continuation is still - // in flight when the second (duplicate) completion arrives. The first - // completion moves `phase` to `form` synchronously, so the duplicate - // is dropped by the frame-phase guard before it can re-run the check. - let releaseCheck: (value: { kycRequired: boolean }) => void = () => { - // no-op placeholder until the deferred promise is wired up - }; - handlers.checkKycRequired.mockReturnValue( - new Promise<{ kycRequired: boolean }>((resolve) => { - releaseCheck = resolve; - }), - ); - launcher.launch.mockImplementation(async ({ onStatusChange }) => { - onStatusChange?.('InProgress', 'Completed'); - return { ok: true }; - }); - const envelope = await envelopeFor(controller, { - accessToken: 'access-1', - }); - const message = { - kind: 'complete', - meta: { channelId: 'ch_2' }, - payload: { status: 'active', credentials: envelope }, - }; - - const first = controller.handleFrameMessage({ message }); - const second = controller.handleFrameMessage({ message }); - - releaseCheck({ kycRequired: true }); - await Promise.all([first, second]); - - expect(handlers.checkKycRequired).toHaveBeenCalledTimes(1); - expect(launcher.launch).toHaveBeenCalledTimes(1); - expect(controller.state.sumsub.status).toBe('complete'); - }, - ); - }); - it('allows a fresh flow to continue after a reset interrupts an in-flight continuation', async () => { await withController( { options: { state: { - phase: 'check', + phase: 'form', email: 'a@b.co', - moonpaySessionToken: 'tok', + moonpayAccessToken: 'access-1', activeProduct: 'ramps', geoCountry: 'USA', - // Persisted terms so a post-reset `initialize` auto-recreates the - // session (reaching phase `check`) for the second completion. ...VENDOR_TERMS_MOONPAY, }, }, }, - async ({ controller, handlers }) => { - const envelope1 = await envelopeFor(controller, { - accessToken: 'access-1', - }); - const messageFor = ( - credentials: unknown, - ): { - kind: string; - meta: { channelId: string }; - payload: { status: string; credentials: unknown }; - } => ({ - kind: 'complete', - meta: { channelId: 'ch_1' }, - payload: { status: 'active', credentials }, - }); - - // Hold the first continuation open so a reset can land while it is - // still in flight. + async ({ controller, handlers, moonPayFrames }) => { let releaseCheck: (value: { kycRequired: boolean }) => void = () => { // no-op placeholder until the deferred promise is wired up }; @@ -1393,32 +1007,22 @@ describe('KycController', () => { }), ); - const first = controller.handleFrameMessage({ - message: messageFor(envelope1), - }); + const first = moonPayFrames.options.onAuthenticated(); - // Reset while the continuation is awaiting the check. Its result is - // discarded by the generation guard (the check belongs to the - // superseded generation) rather than written onto the idle flow. controller.reset(); releaseCheck({ kycRequired: false }); await first; - // Re-establish a product-scoped flow (auto-creates a session and - // returns to phase `check`) and confirm the next completion continues - // again rather than being blocked forever by a stuck guard. handlers.fetchVendorDisclaimers.mockResolvedValue([ { id: '1', display_name: 'T', url: 'u' }, ]); handlers.createSession.mockResolvedValue({ sessionToken: 'tok-2' }); await controller.initialize({ product: 'ramps' }); - const envelope2 = await envelopeFor(controller, { - accessToken: 'access-2', + moonPayFrames.options.update((state) => { + state.moonpayAccessToken = 'access-2'; }); handlers.checkKycRequired.mockResolvedValue({ kycRequired: false }); - await controller.handleFrameMessage({ - message: messageFor(envelope2), - }); + await moonPayFrames.options.onAuthenticated(); expect(handlers.checkKycRequired).toHaveBeenCalledTimes(2); }, @@ -1430,26 +1034,17 @@ describe('KycController', () => { { options: { state: { - phase: 'check', - moonpaySessionToken: 'tok', + phase: 'form', + moonpayAccessToken: 'access-1', activeProduct: 'ramps', geoCountry: 'USA', }, }, }, - async ({ controller, handlers, launcher }) => { + async ({ controller, handlers, launcher, moonPayFrames }) => { handlers.checkKycRequired.mockRejectedValue(new Error('down')); - const envelope = await envelopeFor(controller, { - accessToken: 'access-1', - }); - await controller.handleFrameMessage({ - message: { - kind: 'complete', - meta: { channelId: 'ch_1' }, - payload: { status: 'active', credentials: envelope }, - }, - }); + await moonPayFrames.options.onAuthenticated(); expect(controller.state.phase).toBe('error'); expect(launcher.launch).not.toHaveBeenCalled(); @@ -1459,47 +1054,60 @@ describe('KycController', () => { }); describe('frame URL builders', () => { - it('returns null for the check frame without a session', async () => { - await withController(({ controller }) => { - expect(controller.buildCheckFrameUrl()).toBeNull(); + it('delegates Check/Auth/Reset URL construction to MoonPayFrameHandler', async () => { + await withController(({ controller, moonPayFrames }) => { + moonPayFrames.buildCheckFrameUrl.mockReturnValue( + 'https://example.test/check', + ); + moonPayFrames.buildAuthFrameUrl.mockReturnValue( + 'https://example.test/auth', + ); + + expect(controller.buildCheckFrameUrl()).toBe( + 'https://example.test/check', + ); + expect(controller.buildAuthFrameUrl()).toBe( + 'https://example.test/auth', + ); + expect(controller.buildResetFrameUrl()).toContain('channelId=ch_reset'); + expect(moonPayFrames.buildCheckFrameUrl).toHaveBeenCalled(); + expect(moonPayFrames.buildAuthFrameUrl).toHaveBeenCalled(); + expect(moonPayFrames.buildResetFrameUrl).toHaveBeenCalled(); }); }); + }); - it('builds the check frame URL with a session', async () => { - await withController( - { options: { state: { moonpaySessionToken: 'tok' } } }, - async ({ controller }) => { - await controller.initialize({ vendor: 'moonpay' }); - const url = controller.buildCheckFrameUrl() as string; - expect(url).toContain('sessionToken=tok'); - expect(url).toContain('channelId=ch_1'); - expect(url).toContain('skipKyc=true'); - }, - ); + describe('MoonPay frame handler callbacks', () => { + it('provides the current controller state to the handler', async () => { + await withController(({ controller, moonPayFrames }) => { + expect(moonPayFrames.options.getState()).toBe(controller.state); + }); }); - it('returns null for the check frame when the active vendor is not MoonPay', async () => { + it('invalidates terms when the handler requests re-acceptance', async () => { await withController( { options: { - state: { moonpaySessionToken: 'tok', activeVendor: 'iron' }, + state: { phase: 'check', ...VENDOR_TERMS_MOONPAY }, }, }, - ({ controller }) => { - expect(controller.buildCheckFrameUrl()).toBeNull(); + ({ controller, moonPayFrames }) => { + moonPayFrames.options.requireTermsReacceptance(); + + expect(controller.state.phase).toBe('terms'); + expect(controller.state.vendorDisclaimersAccepted.moonpay).toBeNull(); }, ); }); - it('returns null for the auth frame without a client token', async () => { - await withController(({ controller }) => { - expect(controller.buildAuthFrameUrl()).toBeNull(); - }); - }); + it('records an error when the handler reports a failure', async () => { + await withController(({ controller, moonPayFrames }) => { + moonPayFrames.options.fail('Check frame returned status: failed'); - it('builds the reset frame URL', async () => { - await withController(({ controller }) => { - expect(controller.buildResetFrameUrl()).toContain('channelId=ch_reset'); + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toBe( + 'Check frame returned status: failed', + ); }); }); }); @@ -1679,13 +1287,13 @@ describe('KycController', () => { }, }, }, - async ({ controller }) => { + async ({ controller, moonPayFrames }) => { await controller.initialize({ vendor: 'iron' }); expect(controller.state.moonpayCustomerId).toBeNull(); expect(controller.state.moonpaySessionToken).toBeNull(); expect(controller.state.moonpayAccessToken).toBeNull(); - expect(controller.buildCheckFrameUrl()).toBeNull(); + expect(moonPayFrames.clear).toHaveBeenCalled(); expect(controller.getCustomerIdentity()).toBeNull(); }, ); @@ -1703,13 +1311,13 @@ describe('KycController', () => { }, }, }, - async ({ controller }) => { + async ({ controller, moonPayFrames }) => { await controller.initialize({ vendor: 'moonpay' }); expect(controller.state.moonpayCustomerId).toBe('cust-1'); expect(controller.state.moonpaySessionToken).toBe('tok'); expect(controller.state.moonpayAccessToken).toBe('access-1'); - expect(controller.buildCheckFrameUrl()).toContain('sessionToken=tok'); + expect(moonPayFrames.startFlow).toHaveBeenCalled(); }, ); }); @@ -1726,7 +1334,7 @@ describe('KycController', () => { }, }, }, - async ({ controller }) => { + async ({ controller, moonPayFrames }) => { await controller.createVendorCustomer({ vendor: 'iron', email: 'a@b.co', @@ -1735,7 +1343,7 @@ describe('KycController', () => { expect(controller.state.moonpayCustomerId).toBeNull(); expect(controller.state.moonpaySessionToken).toBeNull(); expect(controller.state.moonpayAccessToken).toBeNull(); - expect(controller.buildCheckFrameUrl()).toBeNull(); + expect(moonPayFrames.clear).toHaveBeenCalled(); expect(controller.getCustomerIdentity()).toBeNull(); }, ); @@ -2682,28 +2290,11 @@ describe('KycController', () => { }); it('drops the auth-frame client token', async () => { - await withController( - { options: { state: { phase: 'check', moonpaySessionToken: 'tok' } } }, - async ({ controller }) => { - const envelope = await envelopeFor(controller, { - clientToken: 'client-1', - }); - await controller.handleFrameMessage({ - message: { - kind: 'complete', - meta: { channelId: 'ch_1' }, - payload: { status: 'connectionRequired', credentials: envelope }, - }, - }); - expect(controller.buildAuthFrameUrl()).toContain( - 'clientToken=client-1', - ); - - controller.clearState(); + await withController(async ({ controller, moonPayFrames }) => { + controller.clearState(); - expect(controller.buildAuthFrameUrl()).toBeNull(); - }, - ); + expect(moonPayFrames.clear).toHaveBeenCalled(); + }); }); it('stops session-status polling', async () => { @@ -4928,6 +4519,7 @@ type WithControllerCallback = (payload: { rootMessenger: RootMessenger; handlers: ServiceHandlers; launcher: Launcher; + moonPayFrames: MockMoonPayFrames; }) => Promise | ReturnValue; type WithControllerOptions = { @@ -5162,11 +4754,29 @@ function withController( launch: jest.fn().mockResolvedValue({ ok: true }), }; + let moonPayFrames: MockMoonPayFrames | undefined; + MockMoonPayFrameHandler.mockImplementation( + (handlerOptions: MoonPayFrameHandlerOptions) => { + moonPayFrames = createMockMoonPayFrames(handlerOptions); + return moonPayFrames as unknown as MoonPayFrameHandler; + }, + ); + const controller = new KycController({ messenger, sumsubLauncher: launcher as unknown as KycSumSubLauncher, ...options, }); - return testFunction({ controller, rootMessenger, handlers, launcher }); + if (!moonPayFrames) { + throw new Error('MoonPayFrameHandler mock was not constructed'); + } + + return testFunction({ + controller, + rootMessenger, + handlers, + launcher, + moonPayFrames, + }); } diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 001c72db27..c369cd562d 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -40,7 +40,7 @@ import type { import { clearMoonPaySession, MoonPayFrameHandler, -} from './moonpay/MoonPayFrameHandler.js'; +} from './vendors/MoonPayFrameHandler.js'; import { deriveClientMaterial } from './ukyc/deriveClientMaterial.js'; import { verifyJwtChain } from './ukyc/jwtChain.js'; import type { Jwk } from './ukyc/jwtChain.js'; diff --git a/packages/kyc-controller/src/vendors/MoonPayFrameHandler.test.ts b/packages/kyc-controller/src/vendors/MoonPayFrameHandler.test.ts new file mode 100644 index 0000000000..d3e859bd22 --- /dev/null +++ b/packages/kyc-controller/src/vendors/MoonPayFrameHandler.test.ts @@ -0,0 +1,462 @@ +import { gcm } from '@noble/ciphers/aes'; +import { x25519 } from '@noble/curves/ed25519'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha2'; +import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils'; + +import { + clearMoonPaySession, + MoonPayFrameHandler, +} from './MoonPayFrameHandler.js'; +import type { MoonPayFrameHandlerOptions } from './MoonPayFrameHandler.js'; +import type { KycPhase, KycVendor } from '../types.js'; + +type FrameState = { + activeVendor: KycVendor; + moonpayAccessToken: string | null; + moonpayCustomerId: string | null; + moonpaySessionToken: string | null; + phase: KycPhase; + statusMessage: string; +}; + +/** + * Builds an encrypted envelope for a recipient's X25519 public key. + * + * @param publicKey - The recipient's public key bytes. + * @param credentials - The plaintext credentials to encrypt. + * @returns The encrypted envelope. + */ +function makeEnvelope( + publicKey: Uint8Array, + credentials: Record, +): { ephemeralPublicKey: string; iv: string; ciphertext: string } { + const ephemeralPrivate = x25519.utils.randomSecretKey(); + const ephemeralPublic = x25519.getPublicKey(ephemeralPrivate); + const shared = x25519.getSharedSecret(ephemeralPrivate, publicKey); + const key = hkdf(sha256, shared, undefined, undefined, 32); + const iv = new Uint8Array(12).fill(7); + const ciphertext = gcm(key, iv).encrypt( + utf8ToBytes(JSON.stringify(credentials)), + ); + return { + ephemeralPublicKey: bytesToHex(ephemeralPublic), + iv: bytesToHex(iv), + ciphertext: bytesToHex(ciphertext), + }; +} + +/** + * Builds a decryptable credentials envelope for the handler's Check-frame key. + * + * @param handler - Handler that already has a frame keypair and session. + * @param credentials - The plaintext credentials to encrypt. + * @returns The encrypted envelope. + */ +function envelopeFor( + handler: MoonPayFrameHandler, + credentials: Record, +): { ephemeralPublicKey: string; iv: string; ciphertext: string } { + const url = handler.buildCheckFrameUrl(); + if (!url) { + throw new Error('Could not build Check frame URL for envelope'); + } + const publicKeyHex = new URL(url).searchParams.get('publicKey') as string; + return makeEnvelope(hexToBytes(publicKeyHex), credentials); +} + +function createHandler(stateOverrides: Partial = {}): { + handler: MoonPayFrameHandler; + state: FrameState; + fail: jest.MockedFunction; + onAuthenticated: jest.MockedFunction< + MoonPayFrameHandlerOptions['onAuthenticated'] + >; + requireTermsReacceptance: jest.MockedFunction< + MoonPayFrameHandlerOptions['requireTermsReacceptance'] + >; +} { + const state: FrameState = { + activeVendor: 'moonpay', + moonpayAccessToken: null, + moonpayCustomerId: null, + moonpaySessionToken: 'tok', + phase: 'check', + statusMessage: '', + ...stateOverrides, + }; + const fail = jest.fn(); + const onAuthenticated = jest.fn().mockResolvedValue(undefined); + const requireTermsReacceptance = jest.fn(); + const handler = new MoonPayFrameHandler({ + getState: (): FrameState => state, + update: (updater): void => { + updater(state); + }, + fail, + onAuthenticated, + requireTermsReacceptance, + }); + return { handler, state, fail, onAuthenticated, requireTermsReacceptance }; +} + +describe('MoonPayFrameHandler', () => { + describe('startFlow / ensureKeypair / clear', () => { + it('creates a keypair that enables the Check-frame URL', () => { + const { handler } = createHandler(); + + expect(handler.buildCheckFrameUrl()).toBeNull(); + handler.startFlow(); + + const url = handler.buildCheckFrameUrl() as string; + expect(url).toContain('sessionToken=tok'); + expect(url).toContain('channelId=ch_1'); + expect(url).toContain('skipKyc=true'); + }); + + it('does not replace an existing keypair on ensureKeypair', () => { + const { handler } = createHandler(); + handler.startFlow(); + const first = handler.buildCheckFrameUrl() as string; + + handler.ensureKeypair(); + + expect(handler.buildCheckFrameUrl()).toBe(first); + }); + + it('creates a keypair on ensureKeypair when none exists', () => { + const { handler } = createHandler(); + + handler.ensureKeypair(); + + expect(handler.buildCheckFrameUrl()).toContain('sessionToken=tok'); + }); + + it('drops the keypair and auth client token on clear', () => { + const { handler } = createHandler(); + handler.startFlow(); + expect(handler.buildCheckFrameUrl()).not.toBeNull(); + + handler.clear(); + + expect(handler.buildCheckFrameUrl()).toBeNull(); + expect(handler.buildAuthFrameUrl()).toBeNull(); + }); + + it('drops only the auth client token on clearAuthentication', async () => { + const { handler } = createHandler(); + handler.startFlow(); + await handler.handleMessage({ + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { + status: 'connectionRequired', + credentials: envelopeFor(handler, { clientToken: 'client-1' }), + }, + }); + expect(handler.buildAuthFrameUrl()).toContain('clientToken=client-1'); + + handler.clearAuthentication(); + + expect(handler.buildAuthFrameUrl()).toBeNull(); + expect(handler.buildCheckFrameUrl()).toContain('sessionToken=tok'); + }); + }); + + describe('handleMessage', () => { + it('acks a handshake', async () => { + const { handler } = createHandler(); + + const result = await handler.handleMessage({ + kind: 'handshake', + meta: { channelId: 'ch_1' }, + }); + + expect(result).toStrictEqual({ + reply: { version: 2, meta: { channelId: 'ch_1' }, kind: 'ack' }, + }); + }); + + it('ignores undefined and non-complete messages', async () => { + const { handler } = createHandler(); + + expect(await handler.handleMessage(undefined)).toStrictEqual({}); + expect(await handler.handleMessage({ kind: 'other' })).toStrictEqual({}); + }); + + it('captures the customer id and ignores a status-less complete message', async () => { + const { handler, state } = createHandler({ phase: 'check' }); + + const result = await handler.handleMessage({ + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { customer: { id: 'cust-1' } }, + }); + + expect(result).toStrictEqual({}); + expect(state.moonpayCustomerId).toBe('cust-1'); + }); + + it('ignores messages on an unknown channel', async () => { + const { handler, state } = createHandler(); + + const result = await handler.handleMessage({ + kind: 'complete', + meta: { channelId: 'ch_unknown' }, + payload: { status: 'active' }, + }); + + expect(result).toStrictEqual({}); + expect(state.phase).toBe('check'); + }); + + it('ignores a stale completion for a frame the flow is no longer waiting on', async () => { + const { handler, state, onAuthenticated } = createHandler({ + phase: 'done', + }); + + const result = await handler.handleMessage({ + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { + status: 'active', + credentials: 'not-used', + customer: { id: 'cust-late' }, + }, + }); + + expect(result).toStrictEqual({}); + expect(state.phase).toBe('done'); + expect(state.moonpayAccessToken).toBeNull(); + expect(state.moonpayCustomerId).toBeNull(); + expect(onAuthenticated).not.toHaveBeenCalled(); + }); + + it('ignores a Check complete when the active vendor is not MoonPay', async () => { + const { handler, state, onAuthenticated } = createHandler({ + phase: 'check', + activeVendor: 'iron', + }); + + const result = await handler.handleMessage({ + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { + status: 'active', + credentials: 'not-decryptable', + customer: { id: 'cust-late' }, + }, + }); + + expect(result).toStrictEqual({}); + expect(state.phase).toBe('check'); + expect(state.moonpayAccessToken).toBeNull(); + expect(state.moonpayCustomerId).toBeNull(); + expect(onAuthenticated).not.toHaveBeenCalled(); + }); + + it('fails when credential decryption throws', async () => { + const { handler, fail } = createHandler(); + handler.startFlow(); + + await handler.handleMessage({ + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials: 'not-decryptable' }, + }); + + expect(fail).toHaveBeenCalledWith( + expect.stringMatching(/Failed to decrypt/u), + ); + }); + + it('ignores a duplicate completion while a prior continuation is in flight', async () => { + const { handler, onAuthenticated } = createHandler({ phase: 'auth' }); + handler.startFlow(); + let releaseAuthenticated: () => void = () => { + // no-op placeholder until the deferred promise is wired up + }; + onAuthenticated.mockReturnValue( + new Promise((resolve) => { + releaseAuthenticated = resolve; + }), + ); + const envelope = envelopeFor(handler, { accessToken: 'access-1' }); + const message = { + kind: 'complete', + meta: { channelId: 'ch_2' }, + payload: { status: 'active', credentials: envelope }, + }; + + const first = handler.handleMessage(message); + const second = handler.handleMessage(message); + + releaseAuthenticated(); + await Promise.all([first, second]); + + expect(onAuthenticated).toHaveBeenCalledTimes(1); + }); + + describe('check frame', () => { + it('moves to form on an active status with an access token', async () => { + const { handler, state, onAuthenticated } = createHandler(); + handler.startFlow(); + + await handler.handleMessage({ + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { + status: 'active', + credentials: envelopeFor(handler, { accessToken: 'access-1' }), + }, + }); + + expect(state.phase).toBe('form'); + expect(state.moonpayAccessToken).toBe('access-1'); + expect(onAuthenticated).toHaveBeenCalledTimes(1); + }); + + it('moves to auth on connectionRequired and enables the auth frame URL', async () => { + const { handler, state, onAuthenticated } = createHandler(); + handler.startFlow(); + + await handler.handleMessage({ + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { + status: 'connectionRequired', + credentials: envelopeFor(handler, { clientToken: 'client-1' }), + }, + }); + + expect(state.phase).toBe('auth'); + expect(handler.buildAuthFrameUrl()).toContain('clientToken=client-1'); + expect(onAuthenticated).not.toHaveBeenCalled(); + }); + + it('requires re-acceptance on termsAcceptanceRequired', async () => { + const { handler, requireTermsReacceptance } = createHandler(); + + await handler.handleMessage({ + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'termsAcceptanceRequired' }, + }); + + expect(requireTermsReacceptance).toHaveBeenCalledTimes(1); + }); + + it('fails on an unexpected status', async () => { + const { handler, fail } = createHandler(); + + await handler.handleMessage({ + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'failed' }, + }); + + expect(fail).toHaveBeenCalledWith( + 'Check frame returned status: failed', + ); + }); + }); + + describe('auth frame', () => { + it('moves to form on an active status with an access token', async () => { + const { handler, state, onAuthenticated } = createHandler({ + phase: 'auth', + }); + handler.startFlow(); + + await handler.handleMessage({ + kind: 'complete', + meta: { channelId: 'ch_2' }, + payload: { + status: 'active', + credentials: envelopeFor(handler, { accessToken: 'access-2' }), + }, + }); + + expect(state.phase).toBe('form'); + expect(state.moonpayAccessToken).toBe('access-2'); + expect(onAuthenticated).toHaveBeenCalledTimes(1); + }); + + it('requires re-acceptance on termsAcceptanceRequired', async () => { + const { handler, requireTermsReacceptance } = createHandler({ + phase: 'auth', + }); + + await handler.handleMessage({ + kind: 'complete', + meta: { channelId: 'ch_2' }, + payload: { status: 'termsAcceptanceRequired' }, + }); + + expect(requireTermsReacceptance).toHaveBeenCalledTimes(1); + }); + + it('fails on an unexpected status', async () => { + const { handler, fail } = createHandler({ phase: 'auth' }); + + await handler.handleMessage({ + kind: 'complete', + meta: { channelId: 'ch_2' }, + payload: { status: 'unavailable' }, + }); + + expect(fail).toHaveBeenCalledWith( + 'Auth frame returned status: unavailable', + ); + }); + }); + }); + + describe('frame URL builders', () => { + it('returns null for the check frame without a session', () => { + const { handler } = createHandler({ moonpaySessionToken: null }); + handler.startFlow(); + + expect(handler.buildCheckFrameUrl()).toBeNull(); + }); + + it('returns null for the check frame when the active vendor is not MoonPay', () => { + const { handler } = createHandler({ activeVendor: 'iron' }); + handler.startFlow(); + + expect(handler.buildCheckFrameUrl()).toBeNull(); + }); + + it('returns null for the auth frame without a client token', () => { + const { handler } = createHandler(); + handler.startFlow(); + + expect(handler.buildAuthFrameUrl()).toBeNull(); + }); + + it('builds the reset frame URL', () => { + const { handler } = createHandler(); + + expect(handler.buildResetFrameUrl()).toContain('channelId=ch_reset'); + }); + }); + + describe('clearMoonPaySession', () => { + it('clears MoonPay session artifacts from a state draft', () => { + const state: FrameState = { + activeVendor: 'moonpay', + moonpayAccessToken: 'access', + moonpayCustomerId: 'cust-1', + moonpaySessionToken: 'tok', + phase: 'form', + statusMessage: '', + }; + + clearMoonPaySession(state); + + expect(state.moonpayAccessToken).toBeNull(); + expect(state.moonpayCustomerId).toBeNull(); + expect(state.moonpaySessionToken).toBeNull(); + }); + }); +}); diff --git a/packages/kyc-controller/src/moonpay/MoonPayFrameHandler.ts b/packages/kyc-controller/src/vendors/MoonPayFrameHandler.ts similarity index 100% rename from packages/kyc-controller/src/moonpay/MoonPayFrameHandler.ts rename to packages/kyc-controller/src/vendors/MoonPayFrameHandler.ts From d516412b4c914c7b85734516b4d96892b8e820f8 Mon Sep 17 00:00:00 2001 From: Jiexi Luan-Huang Date: Wed, 2 Sep 2026 17:45:58 -0700 Subject: [PATCH 03/13] doc --- packages/kyc-controller/ARCHITECTURE.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md index a385581efe..9d0dac1825 100644 --- a/packages/kyc-controller/ARCHITECTURE.md +++ b/packages/kyc-controller/ARCHITECTURE.md @@ -690,9 +690,10 @@ graph LR ### Appendix — key source files -| File | Responsibility | -| ---------------------- | ----------------------------------------------------- | -| `src/KycController.ts` | Stateful orchestrator, phase machine, frame protocol. | +| File | Responsibility | +| ----------------------------------------- | ----------------------------------------------------------------- | +| `src/KycController.ts` | Stateful orchestrator, phase machine. | +| `src/vendors/MoonPayFrameHandler.ts` | MoonPay Check/Auth protocol, URLs, and ephemeral frame credentials. | | `src/KycService.ts` | Stateless UKYC HTTP client + superstruct validation. | | `src/crypto.ts` | X25519 ECDH + AES-256-GCM credential decryption. | | `src/selectors.ts` | Memoized selectors over controller state. | From 12a33b512ac0bd213b533dabfe443ffb09e5f357 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Thu, 3 Sep 2026 15:54:55 +0200 Subject: [PATCH 04/13] fix: linting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- packages/kyc-controller/ARCHITECTURE.md | 20 +++++++++---------- .../kyc-controller/src/KycController.test.ts | 4 ++-- packages/kyc-controller/src/KycController.ts | 8 ++++---- .../src/vendors/MoonPayFrameHandler.test.ts | 2 +- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md index 9d0dac1825..d360102746 100644 --- a/packages/kyc-controller/ARCHITECTURE.md +++ b/packages/kyc-controller/ARCHITECTURE.md @@ -690,16 +690,16 @@ graph LR ### Appendix — key source files -| File | Responsibility | -| ----------------------------------------- | ----------------------------------------------------------------- | -| `src/KycController.ts` | Stateful orchestrator, phase machine. | -| `src/vendors/MoonPayFrameHandler.ts` | MoonPay Check/Auth protocol, URLs, and ephemeral frame credentials. | -| `src/KycService.ts` | Stateless UKYC HTTP client + superstruct validation. | -| `src/crypto.ts` | X25519 ECDH + AES-256-GCM credential decryption. | -| `src/selectors.ts` | Memoized selectors over controller state. | -| `src/types.ts` | `KycPhase`, `KycProduct`, `KycSumSubLauncher`, etc. | -| `src/countryCodes.ts` | ISO alpha-2 → alpha-3 mapping. | -| `src/index.ts` | Public exports (no barrel wildcards). | +| File | Responsibility | +| ------------------------------------ | ------------------------------------------------------------------- | +| `src/KycController.ts` | Stateful orchestrator, phase machine. | +| `src/vendors/MoonPayFrameHandler.ts` | MoonPay Check/Auth protocol, URLs, and ephemeral frame credentials. | +| `src/KycService.ts` | Stateless UKYC HTTP client + superstruct validation. | +| `src/crypto.ts` | X25519 ECDH + AES-256-GCM credential decryption. | +| `src/selectors.ts` | Memoized selectors over controller state. | +| `src/types.ts` | `KycPhase`, `KycProduct`, `KycSumSubLauncher`, etc. | +| `src/countryCodes.ts` | ISO alpha-2 → alpha-3 mapping. | +| `src/index.ts` | Public exports (no barrel wildcards). | Reference client (metamask-mobile): diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 3dfb3d17f8..b5c17a6fae 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -14,8 +14,6 @@ import { KycController, } from './KycController.js'; import type { KycControllerMessenger } from './KycController.js'; -import { MoonPayFrameHandler } from './vendors/MoonPayFrameHandler.js'; -import type { MoonPayFrameHandlerOptions } from './vendors/MoonPayFrameHandler.js'; import type { KycConsentRecord, KycDisclaimer, @@ -24,6 +22,8 @@ import type { } from './types.js'; import { verifyJwtChain } from './ukyc/jwtChain.js'; import { wrapEncryptionKey } from './ukyc/wrapEncryptionKey.js'; +import { MoonPayFrameHandler } from './vendors/MoonPayFrameHandler.js'; +import type { MoonPayFrameHandlerOptions } from './vendors/MoonPayFrameHandler.js'; // `verifyJwtChain` (JWKS attestation) and `wrapEncryptionKey` (X25519 sealing) // need a real signed chain / valid keys, so they are stubbed here; the rest of diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index c369cd562d..e566776329 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -37,10 +37,6 @@ import type { KycVendor, KycVendorDisclaimersAccepted, } from './types.js'; -import { - clearMoonPaySession, - MoonPayFrameHandler, -} from './vendors/MoonPayFrameHandler.js'; import { deriveClientMaterial } from './ukyc/deriveClientMaterial.js'; import { verifyJwtChain } from './ukyc/jwtChain.js'; import type { Jwk } from './ukyc/jwtChain.js'; @@ -57,6 +53,10 @@ import { ironDisclaimerIds, recordVendorDisclaimerAcceptance, } from './vendorDisclaimerAcceptance.js'; +import { + clearMoonPaySession, + MoonPayFrameHandler, +} from './vendors/MoonPayFrameHandler.js'; // === GENERAL === diff --git a/packages/kyc-controller/src/vendors/MoonPayFrameHandler.test.ts b/packages/kyc-controller/src/vendors/MoonPayFrameHandler.test.ts index d3e859bd22..e7c7d8b14b 100644 --- a/packages/kyc-controller/src/vendors/MoonPayFrameHandler.test.ts +++ b/packages/kyc-controller/src/vendors/MoonPayFrameHandler.test.ts @@ -4,12 +4,12 @@ import { hkdf } from '@noble/hashes/hkdf'; import { sha256 } from '@noble/hashes/sha2'; import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils'; +import type { KycPhase, KycVendor } from '../types.js'; import { clearMoonPaySession, MoonPayFrameHandler, } from './MoonPayFrameHandler.js'; import type { MoonPayFrameHandlerOptions } from './MoonPayFrameHandler.js'; -import type { KycPhase, KycVendor } from '../types.js'; type FrameState = { activeVendor: KycVendor; From 1b197cc576616ae3629434b05206d14d4fac7fb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Thu, 3 Sep 2026 15:35:14 +0200 Subject: [PATCH 05/13] feat: add consents dedicated files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- packages/kyc-controller/src/consents.test.ts | 141 ++++++++++++++++++ packages/kyc-controller/src/consents.ts | 146 +++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 packages/kyc-controller/src/consents.test.ts create mode 100644 packages/kyc-controller/src/consents.ts diff --git a/packages/kyc-controller/src/consents.test.ts b/packages/kyc-controller/src/consents.test.ts new file mode 100644 index 0000000000..0dd32b52a6 --- /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 0000000000..786b84c45d --- /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'; +} From a03500f3f0a8a8fc39a3c89db9a67a9039f04b2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Thu, 3 Sep 2026 15:45:33 +0200 Subject: [PATCH 06/13] feat: add state dedicated file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- .../kyc-controller/src/KycControllerState.ts | 324 ++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 packages/kyc-controller/src/KycControllerState.ts diff --git a/packages/kyc-controller/src/KycControllerState.ts b/packages/kyc-controller/src/KycControllerState.ts new file mode 100644 index 0000000000..c28f93c34b --- /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, + }, + }; +} From 5ce894cdb25b239d5fd8df0018511c4256fc0412 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Thu, 3 Sep 2026 15:46:42 +0200 Subject: [PATCH 07/13] feat: add session related dedicated files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- .../src/ukyc/sessionAuthorizations.test.ts | 147 ++++++++++++++++++ .../src/ukyc/sessionAuthorizations.ts | 97 ++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 packages/kyc-controller/src/ukyc/sessionAuthorizations.test.ts create mode 100644 packages/kyc-controller/src/ukyc/sessionAuthorizations.ts 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 0000000000..ac0054eaf3 --- /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 0000000000..be6f5797eb --- /dev/null +++ b/packages/kyc-controller/src/ukyc/sessionAuthorizations.ts @@ -0,0 +1,97 @@ +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; + now?: number; +}): { + wrappedEncryptionDataKey: WrappedEncryptionKeyParts; + wrappedUkycCapabilityToken: WrappedEncryptionKeyParts; +} { + const now = params.now ?? Date.now(); + const clientMaterial = deriveClientMaterial(params.localUserSecret); + const wrappedEncryptionDataKey = wrapEncryptionKey( + params.sessionClientPrivateKey, + params.encryptionDataKey.serverPublicKey.x, + clientMaterial.dataEncryptionKey, + ); + + // 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, + operations: ['read'], + expiresAt: new Date(now + UKYC_CAPABILITY_TOKEN_TTL_MS), + }); + const wrappedUkycCapabilityToken = wrapEncryptionKey( + params.sessionClientPrivateKey, + params.capabilityTokenSchema.serverPublicKey.x, + stringToBytes(encodeStorageAccessTokenForHeader(ukycCapabilityToken)), + ); + + return { wrappedEncryptionDataKey, wrappedUkycCapabilityToken }; +} From fee998a037e4c395e853a393fec1a5fe51f58df8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Thu, 3 Sep 2026 15:47:32 +0200 Subject: [PATCH 08/13] feat: apply controller file splitting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- .../kyc-controller/src/KycController.test.ts | 6 +- packages/kyc-controller/src/KycController.ts | 600 ++---------------- packages/kyc-controller/src/index.ts | 7 +- packages/kyc-controller/src/selectors.test.ts | 2 +- packages/kyc-controller/src/selectors.ts | 2 +- 5 files changed, 72 insertions(+), 545 deletions(-) diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index b5c17a6fae..d4ef5a6aa0 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 e566776329..779b5130c6 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 = [ @@ -1178,11 +739,7 @@ export class KycController extends BaseController< } if (created.vendorProcessing) { - try { - await this.refreshKycStatus(); - } catch (statusError) { - controllerLog('KYC status refresh failed:', statusError); - } + await this.#refreshUserStatusSoft(); this.#updateIfCurrent(generation, (state) => { state.phase = 'done'; state.statusMessage = VENDOR_PROCESSING_MESSAGE; @@ -1217,11 +774,7 @@ 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); - } + await this.#refreshUserStatusSoft(); this.#updateIfCurrent(generation, (state) => { if (state.phase !== 'error' && state.phase !== 'done') { state.phase = 'done'; @@ -1230,21 +783,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); @@ -1777,8 +1316,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 @@ -1786,26 +1325,13 @@ export class KycController extends BaseController< const localUserSecret = await getOrCreateLocalUserSecret( this.#localUserSecretStore(), ); - const clientMaterial = deriveClientMaterial(localUserSecret); - const wrappedEncryptionDataKey = wrapEncryptionKey( - sessionClientPrivateKey, - encryptionDataKey.serverPublicKey.x, - clientMaterial.dataEncryptionKey, - ); - - // 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, - operations: ['read'], - expiresAt: new Date(Date.now() + UKYC_CAPABILITY_TOKEN_TTL_MS), - }); - const wrappedUkycCapabilityToken = wrapEncryptionKey( - sessionClientPrivateKey, - capabilityTokenSchema.serverPublicKey.x, - stringToBytes(encodeStorageAccessTokenForHeader(ukycCapabilityToken)), - ); + const { wrappedEncryptionDataKey, wrappedUkycCapabilityToken } = + wrapUkycSessionAuthorizations({ + sessionClientPrivateKey, + encryptionDataKey, + capabilityTokenSchema, + localUserSecret, + }); if (this.#generation !== generation) { return null; } @@ -2005,21 +1531,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) }; @@ -2397,20 +1909,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; + }); + } + + /** + * Refreshes user-keyed KYC status for toast surfaces without rewinding the + * current flow when the request fails. + */ + async #refreshUserStatusSoft(): Promise { + try { + await this.refreshKycStatus(); + } catch (statusError) { + controllerLog('KYC status refresh failed:', statusError); } } diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index 31b705c53a..60dd22dbd9 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 5eee934acd..baceae8e1e 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 6247e01796..d44de32240 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 = ( From 9aa9179afbc8b76da3f23375f35d1ad4e30629ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Thu, 3 Sep 2026 18:06:36 +0200 Subject: [PATCH 09/13] docs: list extracted modules in ARCHITECTURE appendix --- packages/kyc-controller/ARCHITECTURE.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md index d360102746..8218b2d6f5 100644 --- a/packages/kyc-controller/ARCHITECTURE.md +++ b/packages/kyc-controller/ARCHITECTURE.md @@ -202,7 +202,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`, @@ -693,8 +694,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. | From 2b24a7117822986a798a292f241dddd49b3bd4da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Thu, 3 Sep 2026 18:12:42 +0200 Subject: [PATCH 10/13] docs: correct frame-protocol ownership after MoonPay handler extraction --- packages/kyc-controller/ARCHITECTURE.md | 38 ++++++++++++++----------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md index 8218b2d6f5..2c5b6a0d46 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:*` @@ -222,10 +226,10 @@ type and default-state factories in `src/KycControllerState.ts`): 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. --- From efe733cec0ccdb85b47fb1a0f0f2cae52d78e4dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Thu, 3 Sep 2026 18:15:44 +0200 Subject: [PATCH 11/13] docs: add changelog entry for controller module split --- packages/kyc-controller/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 893ef6ee44..3173c18ba5 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 From 2c6f2f112799eaea4c29d3fb56b3982f7094bc6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Thu, 3 Sep 2026 23:42:15 +0200 Subject: [PATCH 12/13] fix: naming and trainling imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- packages/kyc-controller/src/KycController.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 1364d6549c..a54846ecd6 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -58,8 +58,6 @@ import { clearMoonPaySession, MoonPayFrameHandler, } from './vendors/MoonPayFrameHandler.js'; -import { signStorageAccessToken } from './ukyc/storageAccessToken.js'; -import { wrapEncryptionKey } from './ukyc/wrapEncryptionKey.js'; export type { KycControllerState } from './KycControllerState.js'; @@ -737,7 +735,7 @@ export class KycController extends BaseController< } if (created.vendorProcessing) { - await this.#refreshUserStatusSoft(); + await this.#tryRefreshKycStatus(); this.#updateIfCurrent(generation, (state) => { state.phase = 'done'; state.statusMessage = VENDOR_PROCESSING_MESSAGE; @@ -770,9 +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. - await this.#refreshUserStatusSoft(); + // 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'; @@ -1932,10 +1930,10 @@ export class KycController extends BaseController< } /** - * Refreshes user-keyed KYC status for toast surfaces without rewinding the - * current flow when the request fails. + * Calls {@link refreshKycStatus} for toast surfaces, logging rather than + * rethrowing so a failed refresh does not rewind the current flow. */ - async #refreshUserStatusSoft(): Promise { + async #tryRefreshKycStatus(): Promise { try { await this.refreshKycStatus(); } catch (statusError) { From 2ab43a7cd0c78153aaa3155d4557e7295e34f0cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Thu, 3 Sep 2026 23:47:16 +0200 Subject: [PATCH 13/13] fix: remove unused param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- packages/kyc-controller/src/ukyc/sessionAuthorizations.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/kyc-controller/src/ukyc/sessionAuthorizations.ts b/packages/kyc-controller/src/ukyc/sessionAuthorizations.ts index 59b24ca6fa..ddceae00b6 100644 --- a/packages/kyc-controller/src/ukyc/sessionAuthorizations.ts +++ b/packages/kyc-controller/src/ukyc/sessionAuthorizations.ts @@ -66,7 +66,6 @@ export function wrapUkycSessionAuthorizations(params: { encryptionDataKey: UkycEncryptionSchema; capabilityTokenSchema: UkycEncryptionSchema; localUserSecret: Uint8Array; - now?: number; }): { wrappedEncryptionDataKey: WrappedEncryptionKeyParts; wrappedUkycCapabilityToken: WrappedEncryptionKeyParts;