Skip to content
Open
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
30 changes: 15 additions & 15 deletions packages/kyc-controller/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,20 +127,20 @@ Exposed messenger actions (`MESSENGER_EXPOSED_METHODS`):

Endpoints:

| Method | HTTP | Endpoint | Purpose |
| -------------------------- | ------ | ---------------------------------------- | -------------------------------------------------------------------------- |
| `getGeoCountry` | — | (geolocation action) | Resolve alpha-3 country |
| `fetchDisclaimers` | `GET` | `/vendors/{vendor}/disclaimers?country=` | Vendor T&Cs to accept (`vendor` defaults to `moonpay`) |
| `createSession` | `POST` | `/vendors/moonpay/sessions` | Create MoonPay vendor session |
| `checkKycRequired` | `POST` | `/vendors/{vendor}/kyc-required` | Is KYC required? (normalizes `required` → `kycRequired`) |
| `createVendorCustomer` | `POST` | `/vendors/{vendor}/customers` | Create or resume an empty-shell vendor customer |
| `submitVendorDisclaimers` | `POST` | `/vendors/{vendor}/disclaimers` | Record vendor T&C signings (`disclaimerIds`) |
| `fetchSessionDisclaimers` | `GET` | `/sessions/{id}/disclaimers` | Session-scoped idOS + KYC-provider catalog |
| `submitSessionDisclaimers` | `POST` | `/sessions/{id}/disclaimers` | Record `{ idOS, kycProvider, credentialReusabilityConsentGiven }` consents |
| `fetchKycStatus` | `GET` | `/kyc/status` | User-keyed simplified KYC status |
| `createUkycSession` | `POST` | `/sessions` | Start SumSub sub-flow; returns encryption schemas for wrapping |
| `setAuthorizations` | `POST` | `/sessions/{id}/authorizations` | Submit wrapped `data_encryption_key` and wrapped `ukyc_capability_token` |
| `createJourney` | `POST` | `/sessions/{id}/journey` | Create verification journey → applicant token |
| Method | HTTP | Endpoint | Purpose |
| -------------------------- | ------ | ---------------------------------------- | -------------------------------------------------------------------------------------- |
| `getGeoCountry` | — | (geolocation action) | Resolve alpha-3 country |
| `fetchDisclaimers` | `GET` | `/vendors/{vendor}/disclaimers?country=` | Vendor T&Cs to accept (`vendor` defaults to `moonpay`) |
| `createSession` | `POST` | `/vendors/moonpay/sessions` | Create MoonPay vendor session |
| `checkKycRequired` | `POST` | `/vendors/{vendor}/kyc-required` | Is KYC required? (normalizes `required` → `kycRequired`) |
| `createVendorCustomer` | `POST` | `/vendors/{vendor}/customers` | Create or resume an empty-shell vendor customer |
| `submitVendorDisclaimers` | `POST` | `/vendors/{vendor}/disclaimers` | Record vendor T&C signings (`disclaimerIds`) |
| `fetchSessionDisclaimers` | `GET` | `/sessions/{id}/disclaimers` | Session-scoped idOS + KYC-provider catalog |
| `submitSessionDisclaimers` | `POST` | `/sessions/{id}/disclaimers` | Record `{ idOS, kycProvider, credentialReusabilityConsentGiven }` consents |
| `fetchKycStatus` | `GET` | `/kyc/status` | User-keyed simplified KYC status |
| `createUkycSession` | `POST` | `/sessions` | Start SumSub sub-flow; registers session client public key; returns encryption schemas |
| `setAuthorizations` | `POST` | `/sessions/{id}/authorizations` | Submit wrapped `data_encryption_key` and wrapped `ukyc_capability_token` |
| `createJourney` | `POST` | `/sessions/{id}/journey` | Create verification journey → applicant token |

### 2.3 `crypto.ts`

Expand Down Expand Up @@ -374,7 +374,7 @@ sequenceDiagram
Ctrl-->>UI: phase = done (kycRequiredByProduct[product])

opt kycRequired === true → auto-launch document verification
Ctrl->>Svc: createUkycSession({ jwtToken, vendorMetadata })
Ctrl->>Svc: createUkycSession({ jwtToken, sessionClientPublicKey, residenceCountry, vendorMetadata })
Svc->>API: POST /sessions
Note over Ctrl: wrap data_encryption_key and ukyc_capability_token
Ctrl->>Svc: setAuthorizations({ sessionId, wrappedEncryptionDataKey, wrappedUkycCapabilityToken })
Expand Down
1 change: 1 addition & 0 deletions packages/kyc-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **BREAKING:** Require `sessionClientPublicKey` (unpadded base64url X25519 public key) and `residenceCountry` (ISO 3166-1 alpha-3) on `KycService.createUkycSession` (`POST /sessions`). The controller generates the per-session keypair before creating the session and uses the private half to wrap authorizations; residence country is taken from the resolved geo country. ([#9993](https://github.com/MetaMask/core/pull/9993))
- **BREAKING:** Replace wrapping-key exchange (`KycService.getWrappingKey`) and sending wrapped keys at session creation with encryption schemas from `createUkycSession` plus `setAuthorizations` (`POST /sessions/{id}/authorizations`). `createUkycSession` no longer accepts `wrappedEncryptionKey` or `ukycCapabilityToken`; both secrets are wrapped on the client against per-secret schemas and posted separately. ([#9944](https://github.com/MetaMask/core/pull/9944))
- **BREAKING:** Replace `KycService.submitConsents` (`POST /consents`) with session-scoped `fetchSessionDisclaimers` / `submitSessionDisclaimers` plus vendor T&C recording via `submitVendorDisclaimers`. Consents now use `{ key, version }` document records plus `credentialReusabilityConsentGiven` instead of Iron disclaimer ids and boolean T&C flags, and they require a UKYC session id. Iron content ids are posted separately to `POST /vendors/{vendor}/disclaimers`. The consents path records vendor T&Cs, then creates the UKYC session, then records session disclaimers. A 409 conflict is re-checked with a GET and only treated as success when every accepted document is consented. ([#9979](https://github.com/MetaMask/core/pull/9979))
- Make the `fetch` option on the `KycService` constructor optional; it now defaults to the runtime's native `fetch` (browser, React Native, Node 18+), so consumers no longer need to inject one. ([#9908](https://github.com/MetaMask/core/pull/9908))
Expand Down
179 changes: 124 additions & 55 deletions packages/kyc-controller/src/KycController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ import type {
MessengerActions,
MessengerEvents,
} from '@metamask/messenger';
import { bytesToString } from '@metamask/utils';
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 {
getDefaultKycControllerState,
KycController,
Expand Down Expand Up @@ -1549,62 +1550,127 @@ describe('KycController', () => {
});

it('runs the full sub-flow and completes', async () => {
await withController(async ({ controller, handlers, launcher }) => {
launcher.launch.mockImplementation(
async ({ onStatusChange, onTokenExpiration }) => {
onStatusChange?.('idle', 'InProgress');
onStatusChange?.('InProgress', 'Completed');
await onTokenExpiration();
return { ok: true };
},
);
await withController(
{ options: { state: { geoCountry: 'USA' } } },
async ({ controller, handlers, launcher }) => {
launcher.launch.mockImplementation(
async ({ onStatusChange, onTokenExpiration }) => {
onStatusChange?.('idle', 'InProgress');
onStatusChange?.('InProgress', 'Completed');
await onTokenExpiration();
return { ok: true };
},
);

const result = await controller.startSumSub({
locale: 'fr',
debug: true,
});
const result = await controller.startSumSub({
locale: 'fr',
debug: true,
});

expect(result).toStrictEqual({ ok: true });
expect(controller.state.sumsub.status).toBe('complete');
expect(controller.state.sumsub.applicantAccessToken).toBe('aat');
// Session creation returns encryption schemas; wrapping happens on
// the client and both secrets are posted via authorizations.
expect(handlers.createUkycSession).toHaveBeenCalledWith(
expect.objectContaining({
jwtToken: 'mock-jwt-token',
vendorMetadata: expect.objectContaining({
moonPayAccessToken: null,
moonPayUserId: null,
expect(result).toStrictEqual({ ok: true });
expect(controller.state.sumsub.status).toBe('complete');
expect(controller.state.sumsub.applicantAccessToken).toBe('aat');
// Session creation returns encryption schemas; wrapping happens on
// the client and both secrets are posted via authorizations.
expect(handlers.createUkycSession).toHaveBeenCalledWith(
expect.objectContaining({
jwtToken: 'mock-jwt-token',
sessionClientPublicKey:
expect.stringMatching(/^[A-Za-z0-9_-]+$/u),
residenceCountry: 'USA',
vendorMetadata: expect.objectContaining({
moonPayAccessToken: null,
moonPayUserId: null,
}),
}),
);
const { sessionClientPublicKey } = handlers.createUkycSession.mock
.calls[0][0] as {
sessionClientPublicKey: string;
};
const sessionClientPublicKeyBytes = base64UrlToBytes(
sessionClientPublicKey,
);
expect(sessionClientPublicKeyBytes).toHaveLength(32);
expect(
areUint8ArraysEqual(
x25519.getPublicKey(mockWrapEncryptionKey.mock.calls[0][0]),
sessionClientPublicKeyBytes,
),
).toBe(true);
expect(
toBase64Url(
x25519.getPublicKey(mockWrapEncryptionKey.mock.calls[1][0]),
),
).toBe(sessionClientPublicKey);
expect(
handlers.createUkycSession.mock.calls[0][0],
).not.toHaveProperty('wrappedEncryptionKey');
expect(
handlers.createUkycSession.mock.calls[0][0],
).not.toHaveProperty('ukycCapabilityToken');
expect(mockWrapEncryptionKey).toHaveBeenCalledTimes(2);
// First wrap is the 32-byte data_encryption_key; second is the
// encoded capability token (longer than a raw key).
expect(mockWrapEncryptionKey.mock.calls[0][1]).toBe('spk-x');
expect(mockWrapEncryptionKey.mock.calls[0][2]).toHaveLength(32);
expect(mockWrapEncryptionKey.mock.calls[1][1]).toBe('spk-x');
expect(mockWrapEncryptionKey.mock.calls[1][2].length).toBeGreaterThan(
32,
);
// The capability token is wrapped as the UTF-8 bytes of the same
// compact header encoding previously sent as a plaintext field.
expect(bytesToString(mockWrapEncryptionKey.mock.calls[1][2])).toMatch(
/^[A-Za-z0-9\-_]+$/u,
);
expect(handlers.setAuthorizations).toHaveBeenCalledWith({
sessionId: 'sid',
wrappedEncryptionDataKey: { data: 'enc', nonce: 'nonce' },
wrappedUkycCapabilityToken: { data: 'enc', nonce: 'nonce' },
});
// onTokenExpiration re-fetches the applicant access token.
expect(handlers.createJourney).toHaveBeenCalledTimes(2);
},
);
});

it('forwards the resolved geo country as residenceCountry', async () => {
await withController(
{ options: { state: { geoCountry: 'FRA' } } },
async ({ controller, handlers }) => {
await controller.startSumSub();

expect(handlers.createUkycSession).toHaveBeenCalledWith(
expect.objectContaining({ residenceCountry: 'FRA' }),
);
expect(handlers.getGeoCountry).not.toHaveBeenCalled();
},
);
});

it('does not create a UKYC session when reset() runs while resolving residence country', async () => {
await withController(async ({ controller, handlers, launcher }) => {
let release: (country: string) => void = () => {
// Replaced synchronously by the promise executor below.
};
handlers.getGeoCountry.mockReturnValue(
new Promise((resolve) => {
release = resolve;
}),
);
expect(handlers.createUkycSession.mock.calls[0][0]).not.toHaveProperty(
'wrappedEncryptionKey',
);
expect(handlers.createUkycSession.mock.calls[0][0]).not.toHaveProperty(
'ukycCapabilityToken',
);
expect(mockWrapEncryptionKey).toHaveBeenCalledTimes(2);
// First wrap is the 32-byte data_encryption_key; second is the
// encoded capability token (longer than a raw key).
expect(mockWrapEncryptionKey.mock.calls[0][1]).toBe('spk-x');
expect(mockWrapEncryptionKey.mock.calls[0][2]).toHaveLength(32);
expect(mockWrapEncryptionKey.mock.calls[1][1]).toBe('spk-x');
expect(mockWrapEncryptionKey.mock.calls[1][2].length).toBeGreaterThan(
32,
);
// The capability token is wrapped as the UTF-8 bytes of the same
// compact header encoding previously sent as a plaintext field.
expect(bytesToString(mockWrapEncryptionKey.mock.calls[1][2])).toMatch(
/^[A-Za-z0-9\-_]+$/u,
);
expect(handlers.setAuthorizations).toHaveBeenCalledWith({
sessionId: 'sid',
wrappedEncryptionDataKey: { data: 'enc', nonce: 'nonce' },
wrappedUkycCapabilityToken: { data: 'enc', nonce: 'nonce' },
});
// onTokenExpiration re-fetches the applicant access token.
expect(handlers.createJourney).toHaveBeenCalledTimes(2);

const pending = controller.startSumSub();
while (handlers.getGeoCountry.mock.calls.length === 0) {
await Promise.resolve();
}
controller.reset();
release('USA');
const result = await pending;

expect(result).toStrictEqual({});
expect(handlers.createUkycSession).not.toHaveBeenCalled();
expect(launcher.launch).not.toHaveBeenCalled();
expect(controller.state.sumsub.status).toBe('idle');
});
});

Expand Down Expand Up @@ -2927,7 +2993,10 @@ describe('KycController', () => {
handlers.fetchSessionDisclaimers.mock.invocationCallOrder[0],
);
expect(handlers.createUkycSession).toHaveBeenCalledWith(
expect.objectContaining({ vendor: 'iron' }),
expect.objectContaining({
vendor: 'iron',
residenceCountry: 'USA',
}),
);
expect(launcher.launch).toHaveBeenCalled();
expect(controller.buildCheckFrameUrl()).toBeNull();
Expand Down Expand Up @@ -4364,7 +4433,7 @@ describe('KycController', () => {
await withController(
{
options: {
state: { activeVendor: 'iron', phase: 'submit' },
state: { activeVendor: 'iron', phase: 'submit', geoCountry: 'USA' },
},
},
async ({ controller, handlers }) => {
Expand All @@ -4388,7 +4457,7 @@ describe('KycController', () => {
await withController(
{
options: {
state: { activeVendor: 'iron', phase: 'submit' },
state: { activeVendor: 'iron', phase: 'submit', geoCountry: 'USA' },
},
},
async ({ controller, handlers }) => {
Expand Down
25 changes: 23 additions & 2 deletions packages/kyc-controller/src/KycController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +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 type { KycControllerMethodActions } from './KycController-method-action-types.js';
import type { KycServiceMethodActions } from './KycService-method-action-types.js';
import type {
Expand Down Expand Up @@ -1863,16 +1864,36 @@ 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; each encryption schema from session
// creation supplies the matching server public key.
// 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();
const sessionClientPublicKey = toBase64Url(
x25519.getPublicKey(sessionClientPrivateKey),
);
// Residence is the ISO 3166-1 alpha-3 country already resolved for
// disclaimers / KYC-required; fetch it if this sub-flow started without
// that earlier step.
const residenceCountry =
this.state.geoCountry ??
(await this.messenger.call('KycService:getGeoCountry'));
if (this.#generation !== generation) {
return null;
}
if (residenceCountry !== this.state.geoCountry) {
this.#updateIfCurrent(generation, (state) => {
state.geoCountry = residenceCountry;
});
}

const {
sessionId,
encryptionDataKey,
ukycCapabilityToken: capabilityTokenSchema,
} = await this.messenger.call('KycService:createUkycSession', {
jwtToken,
sessionClientPublicKey,
residenceCountry,
...this.#buildUkycSessionVendorFields(),
});
if (this.#generation !== generation) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,10 @@ export type KycServiceFetchJwksAction = {
/**
* Creates a UKYC session for the SumSub document-verification sub-flow.
*
* The response carries per-secret encryption schemas (`encryptionDataKey` and
* The client registers its per-session X25519 public key so the server can
* later open boxes sealed with the matching private key, and supplies the
* customer's ISO 3166-1 alpha-3 country of residence. The response
* carries per-secret encryption schemas (`encryptionDataKey` and
* `ukycCapabilityToken`) so the client can wrap the `data_encryption_key` and
* the read-only `ukyc_capability_token` and submit them via
* {@link KycService.setAuthorizations}.
Expand Down
Loading