Skip to content
Draft
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
2 changes: 2 additions & 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 All @@ -39,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Stop routing `KycService` write endpoints through the query cache. Every `POST` (`createSession`, `checkKycRequired`, `createVendorCustomer`, `submitVendorDisclaimers`, `submitSessionDisclaimers`, `createUkycSession`, `setAuthorizations`, `createJourney`) now issues its request directly instead of via `fetchQuery`. Previously these were modelled as queries, so two overlapping calls sharing a `queryKey` were deduplicated into a single request — a second `createVendorCustomer` while the first was in flight never reached the API — and their responses were retained in the cache and published on the messenger as `cacheUpdated` payloads, which for these endpoints include session tokens and applicant access tokens. Writes are also no longer retried by the service policy, so a failed non-idempotent request cannot create duplicate records server-side.
- Clear leftover MoonPay `sessionToken`, `accessToken`, and Check/Auth frame credentials when `initialize` or `createVendorCustomer` switches to another vendor, so `buildCheckFrameUrl` cannot return a MoonPay URL for a consents-path session. ([#9908](https://github.com/MetaMask/core/pull/9908))
- Rewind the consents path when SumSub fails before completion (thrown step or SDK close without `Completed`), instead of refreshing user status and forcing `phase` to `done`. A terminal UKYC rejection after the SDK completed still finishes as `done` so the decision can be reflected in user status. ([#9908](https://github.com/MetaMask/core/pull/9908))
- Make `createVendorCustomer` a no-op during in-progress phases (matching `initialize`), so a vendor switch cannot leave Check/Auth frames attached to the wrong vendor. ([#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
Loading