Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions packages/kyc-controller/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ classDiagram
+KycDisclaimer[] vendorDisclaimers
+string vendorError
+string geoCountry
+string idosSessionClientPrivateKey [persisted, secret]
+string moonpaySessionToken [secret]
+string moonpayAccessToken [secret]
+string moonpayCustomerId
Expand All @@ -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
Expand All @@ -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
Expand Down
80 changes: 80 additions & 0 deletions packages/kyc-controller/src/KycController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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' } } },
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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');
});
});
Expand Down Expand Up @@ -2567,6 +2641,7 @@ describe('KycController', () => {
moonpaySessionToken: 'tok',
moonpayAccessToken: 'a',
activeProduct: 'ramps',
idosSessionClientPrivateKey: 'stored-session-key',
...VENDOR_TERMS_MOONPAY,
kycRequiredByProduct: { ramps: true },
},
Expand All @@ -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,
Expand Down Expand Up @@ -2632,6 +2708,7 @@ describe('KycController', () => {
moonpaySessionToken: 'tok',
moonpayAccessToken: 'a',
moonpayCustomerId: 'cus-1',
idosSessionClientPrivateKey: 'stored-session-key',
activeVendor: 'iron',
activeProduct: 'ramps',
kycRequiredByProduct: { ramps: true },
Expand Down Expand Up @@ -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);
},
);
Expand Down Expand Up @@ -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();
Expand Down
89 changes: 71 additions & 18 deletions packages/kyc-controller/src/KycController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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). */
Expand Down Expand Up @@ -327,6 +335,12 @@ const kycControllerMetadata = {
persist: false,
usedInUi: true,
},
idosSessionClientPrivateKey: {
includeInDebugSnapshot: false,
includeInStateLogs: false,
persist: true,
usedInUi: false,
},
moonpaySessionToken: {
includeInDebugSnapshot: false,
includeInStateLogs: false,
Expand Down Expand Up @@ -427,6 +441,7 @@ export function getDefaultKycControllerState(): KycControllerState {
vendorError: null,
sessionDisclaimers: null,
geoCountry: null,
idosSessionClientPrivateKey: null,
moonpaySessionToken: null,
moonpayAccessToken: null,
moonpayCustomerId: null,
Expand Down Expand Up @@ -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.';
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -1983,7 +2042,7 @@ export class KycController extends BaseController<
);
const clientMaterial = deriveClientMaterial(localUserSecret);
const wrappedEncryptionDataKey = wrapEncryptionKey(
sessionClientPrivateKey,
idosSessionClientPrivateKey,
encryptionDataKey.serverPublicKey.x,
clientMaterial.dataEncryptionKey,
);
Expand All @@ -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)),
);
Expand Down Expand Up @@ -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);
});
}

Expand Down
Loading