diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md index a385581efe..6394aa1981 100644 --- a/packages/kyc-controller/ARCHITECTURE.md +++ b/packages/kyc-controller/ARCHITECTURE.md @@ -181,6 +181,7 @@ classDiagram +KycDisclaimer[] vendorDisclaimers +string vendorError +string geoCountry + +string idosSessionClientPrivateKey [persisted, secret] +string moonpaySessionToken [secret] +string moonpayAccessToken [secret] +string moonpayCustomerId @@ -206,8 +207,9 @@ State metadata highlights (`kycControllerMetadata`): - **Persisted** (`persist: true`): `vendorDisclaimersAccepted`, `providerDisclaimersAccepted`, `idosDisclaimersAccepted`, - `kycRequiredByProduct`, `lastCheckedAt`. These survive restarts so the flow - can skip already-accepted terms and reuse cached results. Session-scoped + `kycRequiredByProduct`, `lastCheckedAt`, `idosSessionClientPrivateKey`. These + survive restarts so the flow can skip already-accepted terms, reuse cached + results, and keep the UKYC wrapping key for the same session. Session-scoped `sessionDisclaimers` and `credentialReusabilityConsentGiven` are in-memory only (`persist: false`) and are cleared on `reset()`. Acceptance is vendor-scoped: `initialize` (and `createVendorCustomer`) drops @@ -216,11 +218,16 @@ State metadata highlights (`kycControllerMetadata`): vendor switch commits (`createVendorCustomer` succeeds, or the MoonPay path proceeds); a failed or reset switch leaves the previous vendor's acceptance in place. -- **Secrets, never persisted / never logged**: `moonpaySessionToken`, `moonpayAccessToken`, - `moonpayCustomerId`, `email`, `vendorDisclaimers`, and the whole `sumsub` sub-tree. - 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. +- **Secrets, never logged**: `idosSessionClientPrivateKey` (persisted so wrapping + can resume after a cold start), `moonpaySessionToken`, `moonpayAccessToken`, + `moonpayCustomerId`, `email`, `vendorDisclaimers`, and the whole `sumsub` + sub-tree. 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. `idosSessionClientPrivateKey` is the per-session X25519 + wrapping key: generated on first UKYC session create, reused while that + session exists, and cleared with the session (`reset()`, `clearState()`, + consents-path rewind). - 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 diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index ac2cd4cb49..128838f957 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -1885,6 +1885,9 @@ describe('KycController', () => { expect(bytesToString(mockWrapEncryptionKey.mock.calls[1][2])).toMatch( /^[A-Za-z0-9\-_]+$/u, ); + expect(controller.state.idosSessionClientPrivateKey).toBe( + toBase64Url(mockWrapEncryptionKey.mock.calls[0][0]), + ); expect(handlers.setAuthorizations).toHaveBeenCalledWith({ sessionId: 'sid', wrappedEncryptionDataKey: { data: 'enc', nonce: 'nonce' }, @@ -1896,6 +1899,75 @@ describe('KycController', () => { ); }); + it('reuses a stored idosSessionClientPrivateKey instead of generating a new one', async () => { + const storedPrivateKey = new Uint8Array(32).fill(7); + const storedPrivateKeyB64 = toBase64Url(storedPrivateKey); + const storedPublicKeyB64 = toBase64Url( + x25519.getPublicKey(storedPrivateKey), + ); + const randomSecretKey = jest.spyOn(x25519.utils, 'randomSecretKey'); + + try { + await withController( + { + options: { + state: { + geoCountry: 'USA', + idosSessionClientPrivateKey: storedPrivateKeyB64, + }, + }, + }, + async ({ controller, handlers }) => { + randomSecretKey.mockClear(); + + await controller.startSumSub(); + + expect(randomSecretKey).not.toHaveBeenCalled(); + expect(controller.state.idosSessionClientPrivateKey).toBe( + storedPrivateKeyB64, + ); + expect(handlers.createUkycSession).toHaveBeenCalledWith( + expect.objectContaining({ + sessionClientPublicKey: storedPublicKeyB64, + }), + ); + expect( + areUint8ArraysEqual( + mockWrapEncryptionKey.mock.calls[0][0], + storedPrivateKey, + ), + ).toBe(true); + }, + ); + } finally { + randomSecretKey.mockRestore(); + } + }); + + it('reuses the idosSessionClientPrivateKey when session creation is retried after a failure', async () => { + await withController(async ({ controller, handlers }) => { + handlers.createUkycSession.mockRejectedValueOnce( + new Error('ukyc down'), + ); + + const failed = await controller.startSumSub(); + expect(failed).toMatchObject({ + error: expect.stringContaining('ukyc down'), + }); + const storedKey = controller.state.idosSessionClientPrivateKey; + expect(storedKey).toStrictEqual(expect.any(String)); + + await controller.startSumSub(); + + expect(controller.state.idosSessionClientPrivateKey).toBe(storedKey); + const { sessionClientPublicKey } = handlers.createUkycSession.mock + .calls[1][0] as { sessionClientPublicKey: string }; + expect(sessionClientPublicKey).toBe( + toBase64Url(x25519.getPublicKey(base64UrlToBytes(storedKey ?? ''))), + ); + }); + }); + it('forwards the resolved geo country as residenceCountry', async () => { await withController( { options: { state: { geoCountry: 'FRA' } } }, @@ -1997,6 +2069,7 @@ describe('KycController', () => { expect(result).toStrictEqual({}); expect(controller.state.sumsub.status).toBe('idle'); expect(controller.state.sumsub.sessionId).toBeNull(); + expect(controller.state.idosSessionClientPrivateKey).toBeNull(); expect(launcher.launch).not.toHaveBeenCalled(); expect(handlers.setAuthorizations).not.toHaveBeenCalled(); }); @@ -2235,6 +2308,7 @@ describe('KycController', () => { // The interrupted step must not write stale sub-flow state. expect(controller.state.sumsub.status).toBe('idle'); expect(controller.state.sumsub.sessionId).toBeNull(); + expect(controller.state.idosSessionClientPrivateKey).toBeNull(); expect(controller.state.phase).toBe('idle'); }); }); @@ -2567,6 +2641,7 @@ describe('KycController', () => { moonpaySessionToken: 'tok', moonpayAccessToken: 'a', activeProduct: 'ramps', + idosSessionClientPrivateKey: 'stored-session-key', ...VENDOR_TERMS_MOONPAY, kycRequiredByProduct: { ramps: true }, }, @@ -2577,6 +2652,7 @@ describe('KycController', () => { expect(controller.state.phase).toBe('idle'); expect(controller.state.moonpaySessionToken).toBeNull(); expect(controller.state.moonpayAccessToken).toBeNull(); + expect(controller.state.idosSessionClientPrivateKey).toBeNull(); expect(controller.state.activeProduct).toBeNull(); expect( controller.state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt, @@ -2632,6 +2708,7 @@ describe('KycController', () => { moonpaySessionToken: 'tok', moonpayAccessToken: 'a', moonpayCustomerId: 'cus-1', + idosSessionClientPrivateKey: 'stored-session-key', activeVendor: 'iron', activeProduct: 'ramps', kycRequiredByProduct: { ramps: true }, @@ -3823,6 +3900,8 @@ describe('KycController', () => { expect(controller.state.phase).toBe('terms'); expect(controller.state.vendorDisclaimersAccepted.iron).toBeNull(); + expect(controller.state.sumsub.sessionId).toBeNull(); + expect(controller.state.idosSessionClientPrivateKey).toBeNull(); expect(controller.state.error).toMatch(/Consents session failed/u); }, ); @@ -3881,6 +3960,7 @@ describe('KycController', () => { expect(controller.state.phase).toBe('terms'); expect(controller.state.sumsub.status).toBe('idle'); expect(controller.state.sumsub.sessionId).toBeNull(); + expect(controller.state.idosSessionClientPrivateKey).toBeNull(); expect(controller.state.vendorDisclaimersAccepted.iron).toBeNull(); expect(controller.state.error).toMatch(/Consents session failed/u); expect(handlers.fetchKycStatus).not.toHaveBeenCalled(); diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 38c8dc3a0d..a6bdd494ed 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -15,7 +15,7 @@ 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 { base64UrlToBytes, toBase64Url } from './encoding.js'; import type { KycControllerMethodActions } from './KycController-method-action-types.js'; import type { KycServiceMethodActions } from './KycService-method-action-types.js'; import type { @@ -201,6 +201,14 @@ export type KycControllerState = { /** Resolved ISO 3166-1 alpha-3 country code. */ geoCountry: string | null; + /** + * Per-session X25519 private key used to wrap UKYC / idOS authorizations + * (unpadded base64url). Generated on first session create, reused while the + * session is alive (including across cold starts), and cleared when the + * session is dropped. Persisted; never logged. + */ + idosSessionClientPrivateKey: string | null; + /** MoonPay session token (not persisted, not logged). */ moonpaySessionToken: string | null; /** MoonPay access token (not persisted, not logged). */ @@ -327,6 +335,12 @@ const kycControllerMetadata = { persist: false, usedInUi: true, }, + idosSessionClientPrivateKey: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: true, + usedInUi: false, + }, moonpaySessionToken: { includeInDebugSnapshot: false, includeInStateLogs: false, @@ -427,6 +441,7 @@ export function getDefaultKycControllerState(): KycControllerState { vendorError: null, sessionDisclaimers: null, geoCountry: null, + idosSessionClientPrivateKey: null, moonpaySessionToken: null, moonpayAccessToken: null, moonpayCustomerId: null, @@ -1276,7 +1291,7 @@ export class KycController extends BaseController< state.sessionDisclaimers = null; // Session create ran before recording disclaimers. Drop the leftover // UKYC session so a later `startSumSub` cannot skip consent recording. - state.sumsub = { ...getDefaultKycControllerState().sumsub }; + this.#clearUkycSession(state); state.error = `Consents session failed: ${String(error)}`; state.statusMessage = 'Consent / verification failed — accept the terms to try again.'; @@ -1523,6 +1538,44 @@ export class KycController extends BaseController< state.moonpayAccessToken = null; } + /** + * Drops the UKYC / SumSub session and the per-session wrapping key. Used by + * {@link reset} and when the consents path fails after creating a session. + * + * @param state - The state to mutate. + */ + #clearUkycSession(state: KycControllerState): void { + state.idosSessionClientPrivateKey = null; + state.sumsub = { ...getDefaultKycControllerState().sumsub }; + } + + /** + * Returns the per-session X25519 private key, generating and storing one + * when the current flow has none. Returns `null` when a concurrent + * {@link reset} superseded the flow before the new key could be written. + * + * @param generation - Flow generation captured by the caller. + * @returns The private key bytes, or `null` if superseded. + */ + #getOrCreateIdosSessionClientPrivateKey( + generation: number, + ): Uint8Array | null { + const existing = this.state.idosSessionClientPrivateKey; + if (existing !== null) { + return base64UrlToBytes(existing); + } + const idosSessionClientPrivateKey = x25519.utils.randomSecretKey(); + const stillCurrent = this.#updateIfCurrent(generation, (state) => { + state.idosSessionClientPrivateKey = toBase64Url( + idosSessionClientPrivateKey, + ); + }); + if (!stillCurrent) { + return null; + } + return idosSessionClientPrivateKey; + } + /** * Handles a message posted by a Check/Auth frame and advances the flow. * @@ -1911,8 +1964,9 @@ export class KycController extends BaseController< /** * Creates a UKYC session, wraps the `data_encryption_key` and * `ukyc_capability_token` against the returned encryption schemas, and - * submits both via authorizations. Stores `sumsub.sessionId`. Returns `null` - * when a `reset()` superseded the flow. + * submits both via authorizations. Stores `sumsub.sessionId` and + * `idosSessionClientPrivateKey`. Returns `null` when a `reset()` superseded + * the flow. * * @param generation - Flow generation captured by the caller. * @returns The created session, or `null` if superseded. @@ -1926,12 +1980,17 @@ export class KycController extends BaseController< const jwtToken = MOCK_JWT_TOKEN; // Establish a per-session X25519 keypair used to seal both secrets. The - // private half stays on the device; the public half is registered on the - // session so the server can open later authorizations. Each encryption - // schema from session creation supplies the matching server public key. - const sessionClientPrivateKey = x25519.utils.randomSecretKey(); + // private half stays on the device (in state, reused for the life of the + // session); the public half is registered on the session so the server + // can open later authorizations. Each encryption schema from session + // creation supplies the matching server public key. + const idosSessionClientPrivateKey = + this.#getOrCreateIdosSessionClientPrivateKey(generation); + if (!idosSessionClientPrivateKey) { + return null; + } const sessionClientPublicKey = toBase64Url( - x25519.getPublicKey(sessionClientPrivateKey), + x25519.getPublicKey(idosSessionClientPrivateKey), ); // Residence is the ISO 3166-1 alpha-3 country already resolved for // disclaimers / KYC-required; fetch it if this sub-flow started without @@ -1983,7 +2042,7 @@ export class KycController extends BaseController< ); const clientMaterial = deriveClientMaterial(localUserSecret); const wrappedEncryptionDataKey = wrapEncryptionKey( - sessionClientPrivateKey, + idosSessionClientPrivateKey, encryptionDataKey.serverPublicKey.x, clientMaterial.dataEncryptionKey, ); @@ -1997,7 +2056,7 @@ export class KycController extends BaseController< expiresAt: new Date(Date.now() + UKYC_CAPABILITY_TOKEN_TTL_MS), }); const wrappedUkycCapabilityToken = wrapEncryptionKey( - sessionClientPrivateKey, + idosSessionClientPrivateKey, capabilityTokenSchema.serverPublicKey.x, stringToBytes(encodeStorageAccessTokenForHeader(ukycCapabilityToken)), ); @@ -2511,13 +2570,7 @@ export class KycController extends BaseController< state.moonpayCustomerId = null; state.activeVendor = 'moonpay'; state.activeProduct = null; - state.sumsub = { - status: 'idle', - result: null, - sessionId: null, - applicantAccessToken: null, - sessionStatus: null, - }; + this.#clearUkycSession(state); }); }