diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md index a451d2e527..5bcb488f05 100644 --- a/packages/kyc-controller/ARCHITECTURE.md +++ b/packages/kyc-controller/ARCHITECTURE.md @@ -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` @@ -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 }) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index f89d8c44bc..b653883425 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -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)) @@ -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)) diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 8507367337..fbea92307e 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -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, @@ -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'); }); }); @@ -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(); @@ -4364,7 +4433,7 @@ describe('KycController', () => { await withController( { options: { - state: { activeVendor: 'iron', phase: 'submit' }, + state: { activeVendor: 'iron', phase: 'submit', geoCountry: 'USA' }, }, }, async ({ controller, handlers }) => { @@ -4388,7 +4457,7 @@ describe('KycController', () => { await withController( { options: { - state: { activeVendor: 'iron', phase: 'submit' }, + state: { activeVendor: 'iron', phase: 'submit', geoCountry: 'USA' }, }, }, async ({ controller, handlers }) => { diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 2ae5a3e58e..8ae5444cc3 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -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 { @@ -1863,9 +1864,27 @@ 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, @@ -1873,6 +1892,8 @@ export class KycController extends BaseController< ukycCapabilityToken: capabilityTokenSchema, } = await this.messenger.call('KycService:createUkycSession', { jwtToken, + sessionClientPublicKey, + residenceCountry, ...this.#buildUkycSessionVendorFields(), }); if (this.#generation !== generation) { diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts index 304b56ce0b..b7dfbe9f83 100644 --- a/packages/kyc-controller/src/KycService-method-action-types.ts +++ b/packages/kyc-controller/src/KycService-method-action-types.ts @@ -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}. diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index d81909c420..b9dac8d141 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -11,6 +11,8 @@ import { KycService } from './KycService.js'; const MOCK_API_URL = 'https://kyc-api.dev-api.cx.metamask.io'; const MOCK_FRACTAL_URL = 'https://fractal.dev-api.cx.metamask.io'; +const SESSION_CLIENT_PUBLIC_KEY = 'session-client-public-key'; +const RESIDENCE_COUNTRY = 'USA'; describe('KycService', () => { afterEach(() => { @@ -291,6 +293,8 @@ describe('KycService', () => { (body: Record) => body.jwtToken === 'jwt' && body.vendorId === 'moonpay' && + body.sessionClientPublicKey === SESSION_CLIENT_PUBLIC_KEY && + body.residenceCountry === RESIDENCE_COUNTRY && body.wrappedEncryptionKey === undefined && body.ukycCapabilityToken === undefined, ) @@ -300,6 +304,8 @@ describe('KycService', () => { expect( await service.createUkycSession({ jwtToken: 'jwt', + sessionClientPublicKey: SESSION_CLIENT_PUBLIC_KEY, + residenceCountry: RESIDENCE_COUNTRY, vendorMetadata: { foo: 'bar' }, }), ).toStrictEqual(response); @@ -312,6 +318,8 @@ describe('KycService', () => { await expect( service.createUkycSession({ jwtToken: 'jwt', + sessionClientPublicKey: SESSION_CLIENT_PUBLIC_KEY, + residenceCountry: RESIDENCE_COUNTRY, vendorMetadata: {}, }), ).rejects.toThrow(/Malformed response received from UKYC sessions API/u); @@ -563,6 +571,25 @@ describe('KycService', () => { /Malformed response received from vendor customers API/u, ); }); + + it('sends one request per call when two calls overlap', async () => { + const scope = nock(MOCK_API_URL) + .post('/vendors/iron/customers', { email: 'a@b.co' }) + .times(2) + .reply(200, { + id: 'iron-1', + email: 'a@b.co', + status: 'SigningsRequired', + }); + const { service } = getService(); + + await Promise.all([ + service.createVendorCustomer({ vendor: 'iron', email: 'a@b.co' }), + service.createVendorCustomer({ vendor: 'iron', email: 'a@b.co' }), + ]); + + expect(scope.isDone()).toBe(true); + }); }); describe('submitVendorDisclaimers', () => { @@ -878,6 +905,8 @@ describe('KycService', () => { .post('/sessions', (body) => { return ( body.vendorId === 'moonpay' && + body.sessionClientPublicKey === SESSION_CLIENT_PUBLIC_KEY && + body.residenceCountry === RESIDENCE_COUNTRY && body.vendorMetadata?.moonPayAccessToken === 'tok' && body.wrappedEncryptionKey === undefined && body.ukycCapabilityToken === undefined @@ -889,6 +918,8 @@ describe('KycService', () => { expect( await service.createUkycSession({ jwtToken: 'jwt', + sessionClientPublicKey: SESSION_CLIENT_PUBLIC_KEY, + residenceCountry: RESIDENCE_COUNTRY, vendorMetadata: { moonPayAccessToken: 'tok' }, }), ).toStrictEqual(response); @@ -904,6 +935,8 @@ describe('KycService', () => { .post('/sessions', (body) => { return ( body.vendorId === 'iron' && + body.sessionClientPublicKey === SESSION_CLIENT_PUBLIC_KEY && + body.residenceCountry === RESIDENCE_COUNTRY && JSON.stringify(body.vendorMetadata) === '{}' ); }) @@ -913,6 +946,8 @@ describe('KycService', () => { expect( await service.createUkycSession({ jwtToken: 'jwt', + sessionClientPublicKey: SESSION_CLIENT_PUBLIC_KEY, + residenceCountry: RESIDENCE_COUNTRY, vendor: 'iron', }), ).toStrictEqual(response); diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 318efafd93..1cf0973600 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -319,6 +319,16 @@ export type SubmitSessionDisclaimersParams = { export type CreateUkycSessionParams = { jwtToken: string; + /** + * The client's per-session X25519 public key (unpadded base64url). Generated + * with the matching private key used later to wrap authorizations, so the + * session server can open those boxes. + */ + sessionClientPublicKey: string; + /** + * Country of residence in ISO 3166-1 alpha-3 format (e.g. `USA`, `GBR`). + */ + residenceCountry: string; /** * Identity vendor for the UKYC session. Defaults to `moonpay` for the * existing Check/Auth flow. Pass a non-MoonPay vendor (e.g. `iron`) for @@ -362,13 +372,21 @@ export type GetSessionStatusParams = { * `fetch` when provided), and the auth bearer token and geolocation come from * other controllers via the messenger. * - * It extends {@link BaseDataService}, so every request is routed through - * `fetchQuery`: it is wrapped in the shared service policy (retries, circuit - * breaker) and its result is exposed via the service's `QueryClient`. Read-only - * endpoints (`fetchDisclaimers`, `fetchJwks`) are cached with a `staleTime`; - * vendor-disclaimer, session-scoped disclaimer, session-creating, and - * status-polling endpoints opt out of caching (`staleTime`/`gcTime` of `0`) - * so they never serve a stale result. + * It extends {@link BaseDataService}, so read-only endpoints are routed through + * `fetchQuery`: they are wrapped in the shared service policy (retries, circuit + * breaker) and their results are exposed via the service's `QueryClient`. + * `fetchDisclaimers` and `fetchJwks` are cached with a `staleTime`; + * session-scoped disclaimer and status-polling reads opt out of caching + * (`staleTime`/`gcTime` of `0`) so they never serve a stale result. + * + * Write endpoints (every `POST`) deliberately bypass `fetchQuery`. The query + * cache is built for idempotent reads: it deduplicates concurrent requests + * sharing a `queryKey`, retains responses for replay, and publishes them on the + * messenger via `cacheUpdated`. None of that is safe for calls that create + * sessions, customers, or consents — two overlapping `createVendorCustomer` + * calls would collapse into a single `POST`, and session tokens would be + * broadcast as cache payloads. Writes therefore call `#requestJson` directly, + * which also means they are not retried by the service policy. */ export class KycService extends BaseDataService< typeof serviceName, @@ -505,21 +523,9 @@ export class KycService extends BaseDataService< params: CreateSessionParams, ): Promise> { const url = new URL('/vendors/moonpay/sessions', this.#baseUrl); - const data = await this.fetchQuery({ - queryKey: [ - `${this.name}:createSession`, - params.email, - params.termsAcceptedAt, - params.disclaimerIds, - ], - queryFn: async () => - this.#requestJson(url, { - method: 'POST', - body: JSON.stringify(params), - }), - // A session-creating mutation must never serve a stale/cached result. - staleTime: 0, - gcTime: 0, + const data = await this.#requestJson(url, { + method: 'POST', + body: JSON.stringify(params), }); return this.#validateResponse( data, @@ -564,22 +570,9 @@ export class KycService extends BaseDataService< } } - const data = await this.fetchQuery({ - queryKey: [ - `${this.name}:checkKycRequired`, - vendor, - params.accessToken ?? null, - params.country ?? null, - capabilities, - ], - queryFn: async () => - this.#requestJson(url, { - method: 'POST', - body: JSON.stringify(body), - }), - // The requirement can change server-side, so always re-check. - staleTime: 0, - gcTime: 0, + const data = await this.#requestJson(url, { + method: 'POST', + body: JSON.stringify(body), }); const { required } = this.#validateResponse( data, @@ -604,21 +597,11 @@ export class KycService extends BaseDataService< params: CreateVendorCustomerParams, ): Promise { const url = new URL(`/vendors/${params.vendor}/customers`, this.#baseUrl); - const data = await this.fetchQuery({ - queryKey: [ - `${this.name}:createVendorCustomer`, - params.vendor, - params.email, - ], - queryFn: async () => - this.#requestJson(url, { - method: 'POST', - body: JSON.stringify({ email: params.email }), - }), - // Customer creation/resume must never serve a stale/cached result. - staleTime: 0, - gcTime: 0, + const data = await this.#requestJson(url, { + method: 'POST', + body: JSON.stringify({ email: params.email }), }); + console.log('============> createVendorCustomer', data); return this.#validateResponse( data, VendorCustomerResponseStruct, @@ -645,19 +628,9 @@ export class KycService extends BaseDataService< `/vendors/${encodeURIComponent(params.vendor)}/disclaimers`, this.#baseUrl, ); - const data = await this.fetchQuery({ - queryKey: [ - `${this.name}:submitVendorDisclaimers`, - params.vendor, - params.disclaimerIds, - ], - queryFn: async () => - this.#requestJson(url, { - method: 'POST', - body: JSON.stringify({ disclaimerIds: params.disclaimerIds }), - }), - staleTime: 0, - gcTime: 0, + const data = await this.#requestJson(url, { + method: 'POST', + body: JSON.stringify({ disclaimerIds: params.disclaimerIds }), }); return this.#validateResponse( data, @@ -712,26 +685,14 @@ export class KycService extends BaseDataService< `/sessions/${encodeURIComponent(params.sessionId)}/disclaimers`, this.#baseUrl, ); - const data = await this.fetchQuery({ - queryKey: [ - `${this.name}:submitSessionDisclaimers`, - params.sessionId, - params.idOS, - params.kycProvider, - params.credentialReusabilityConsentGiven, - ], - queryFn: async () => - this.#requestJson(url, { - method: 'POST', - body: JSON.stringify({ - idOS: params.idOS, - kycProvider: params.kycProvider, - credentialReusabilityConsentGiven: - params.credentialReusabilityConsentGiven, - }), - }), - staleTime: 0, - gcTime: 0, + const data = await this.#requestJson(url, { + method: 'POST', + body: JSON.stringify({ + idOS: params.idOS, + kycProvider: params.kycProvider, + credentialReusabilityConsentGiven: + params.credentialReusabilityConsentGiven, + }), }); return this.#validateResponse( data, @@ -790,7 +751,10 @@ export class KycService extends BaseDataService< /** * 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}. @@ -802,21 +766,16 @@ export class KycService extends BaseDataService< params: CreateUkycSessionParams, ): Promise { const url = new URL('/sessions', this.#baseUrl); - const data = await this.fetchQuery({ - queryKey: [`${this.name}:createUkycSession`, params.jwtToken], - queryFn: async () => - this.#requestJson(url, { - method: 'POST', - body: JSON.stringify({ - vendorId: params.vendor ?? 'moonpay', - vendorUserId: 'mockedId', - jwtToken: params.jwtToken, - vendorMetadata: params.vendorMetadata ?? {}, - }), - }), - // A session-creating mutation must never serve a stale/cached result. - staleTime: 0, - gcTime: 0, + const data = await this.#requestJson(url, { + method: 'POST', + body: JSON.stringify({ + vendorId: params.vendor ?? 'moonpay', + vendorUserId: 'mockedId', + jwtToken: params.jwtToken, + sessionClientPublicKey: params.sessionClientPublicKey, + residenceCountry: params.residenceCountry, + vendorMetadata: params.vendorMetadata ?? {}, + }), }); return this.#validateResponse( data, @@ -841,18 +800,12 @@ export class KycService extends BaseDataService< `/sessions/${encodeURIComponent(params.sessionId)}/authorizations`, this.#baseUrl, ); - const data = await this.fetchQuery({ - queryKey: [`${this.name}:setAuthorizations`, params.sessionId], - queryFn: async () => - this.#requestJson(url, { - method: 'POST', - body: JSON.stringify({ - wrappedEncryptionDataKey: params.wrappedEncryptionDataKey, - wrappedUkycCapabilityToken: params.wrappedUkycCapabilityToken, - }), - }), - staleTime: 0, - gcTime: 0, + const data = await this.#requestJson(url, { + method: 'POST', + body: JSON.stringify({ + wrappedEncryptionDataKey: params.wrappedEncryptionDataKey, + wrappedUkycCapabilityToken: params.wrappedUkycCapabilityToken, + }), }); return this.#validateResponse( data, @@ -875,13 +828,7 @@ export class KycService extends BaseDataService< `/sessions/${encodeURIComponent(sessionId)}/journey`, this.#baseUrl, ); - const data = await this.fetchQuery({ - queryKey: [`${this.name}:createJourney`, sessionId], - queryFn: async () => this.#requestJson(url, { method: 'POST' }), - // Journeys are (re)created on demand; do not reuse a cached token. - staleTime: 0, - gcTime: 0, - }); + const data = await this.#requestJson(url, { method: 'POST' }); return this.#validateResponse( data, ApplicantAccessTokenResponseStruct, @@ -957,8 +904,9 @@ export class KycService extends BaseDataService< /** * Performs a single JSON request. * - * This is meant to be used as the `queryFn` for {@link fetchQuery}, which - * wraps it in the shared service policy (retries, circuit breaker). Requests + * Read endpoints pass this as the `queryFn` to {@link fetchQuery}, which + * wraps it in the shared service policy (retries, circuit breaker). Write + * endpoints call it directly, so they are executed exactly once. Requests * are authenticated with the wallet bearer token by default; pass * `{ authenticated: false }` for calls to services that do not expect it * (e.g. the Fractal JWKS endpoint). diff --git a/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts index d3d9c512e2..2dc616c2f1 100644 --- a/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts +++ b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts @@ -9,8 +9,8 @@ const DATA_ENCRYPTION_KEY = new Uint8Array(32).fill(7); /** * Reverses {@link wrapEncryptionKey} from the server's perspective: reads the * client public key from the first 32 bytes of `data` and opens the NaCl box - * with the server private key. This is the only information the session server - * has after wrapping-key registration was removed. + * with the server private key. The client also registers this public key on + * `createUkycSession`. * * @param serverPrivateKey - The server's X25519 private key. * @param data - The base64url `clientPublicKey || ciphertext+tag`. diff --git a/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts index d5748a9fce..67e01399b8 100644 --- a/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts +++ b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts @@ -7,12 +7,10 @@ import { base64UrlToBytes, toBase64Url } from '../encoding.js'; * (X25519 + XSalsa20-Poly1305) established with a per-secret wrapping key * returned inside an encryption schema from `createUkycSession`. * - * Unlike {@link wrapUserKey} (which generates a fresh ephemeral keypair per - * call), this reuses a session client keypair. There is no prior wrapping-key - * exchange to register that public key, so `data` is - * `clientPublicKey (32) || ciphertext+tag` and only `{ data, nonce }` need be - * transmitted. Used for both the `data_encryption_key` and the - * `ukyc_capability_token`. + * Reuses a session client keypair whose public half is registered on + * `createUkycSession`. `data` is still `clientPublicKey (32) || ciphertext+tag` + * so the box can be opened from `{ data, nonce }` alone. Used for both the + * `data_encryption_key` and the `ukyc_capability_token`. */ /** @@ -32,9 +30,8 @@ export type WrappedEncryptionKeyParts = { * The box is sealed with NaCl's `crypto_box`, keyed by the X25519 shared secret * between our session client private key and the session server public key * from an encryption schema (`encryptionDataKey` or `ukycCapabilityToken`) - * returned by `createUkycSession`. The 32-byte client public key is prefixed - * onto `data` so the server can open the box without a prior key-registration - * step. + * returned by `createUkycSession`. The 32-byte client public key is still + * prefixed onto `data` so the box is self-describing on the wire. * * @param sessionClientPrivateKey - Our session's X25519 private key. * @param sessionServerPublicKey - The server's X25519 public key (base64url). diff --git a/packages/kyc-controller/src/ukyc/wrapUserKey.test.ts b/packages/kyc-controller/src/ukyc/wrapUserKey.test.ts deleted file mode 100644 index 3842792c25..0000000000 --- a/packages/kyc-controller/src/ukyc/wrapUserKey.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { areUint8ArraysEqual, base64ToBytes } 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 } from '@noble/hashes/utils'; - -import { wrapUserKey } from './wrapUserKey.js'; - -const X25519_KEY_SIZE_BYTES = 32; -const IV_SIZE_BYTES = 12; - -const DATA_ENCRYPTION_KEY = new Uint8Array(32).fill(7); - -/** - * Decodes the unpadded base64url blob produced by {@link wrapUserKey}. - * - * @param value - The base64url-encoded wrapped key. - * @returns The raw blob bytes. - */ -function fromBase64Url(value: string): Uint8Array { - return base64ToBytes( - value - .replace(/-/gu, '+') - .replace(/_/gu, '/') - .padEnd(value.length + ((4 - (value.length % 4)) % 4), '='), - ); -} - -/** - * Reverses {@link wrapUserKey} with the recipient's private key. - * - * @param blob - The base64url wrapped key. - * @param recipientPrivateKey - The recipient's X25519 private key. - * @returns The recovered plaintext key bytes. - */ -function unwrapUserKey( - blob: string, - recipientPrivateKey: Uint8Array, -): Uint8Array { - const bytes = fromBase64Url(blob); - const ephemeralPublicKey = bytes.slice(0, X25519_KEY_SIZE_BYTES); - const iv = bytes.slice( - X25519_KEY_SIZE_BYTES, - X25519_KEY_SIZE_BYTES + IV_SIZE_BYTES, - ); - const ciphertext = bytes.slice(X25519_KEY_SIZE_BYTES + IV_SIZE_BYTES); - - const shared = x25519.getSharedSecret( - recipientPrivateKey, - ephemeralPublicKey, - ); - const aeadKey = hkdf(sha256, shared, undefined, undefined, 32); - return gcm(aeadKey, iv).decrypt(ciphertext); -} - -describe('UKYC wrapUserKey', () => { - it('produces an unpadded base64url blob', () => { - const privateKey = x25519.utils.randomSecretKey(); - const publicKey = x25519.getPublicKey(privateKey); - - const wrapped = wrapUserKey(bytesToHex(publicKey), DATA_ENCRYPTION_KEY); - - expect(wrapped).toMatch(/^[A-Za-z0-9_-]+$/u); - expect(wrapped).not.toContain('='); - }); - - it('wraps a key the recipient can recover (hex public key)', () => { - const privateKey = x25519.utils.randomSecretKey(); - const publicKey = x25519.getPublicKey(privateKey); - - const wrapped = wrapUserKey(bytesToHex(publicKey), DATA_ENCRYPTION_KEY); - const recovered = unwrapUserKey(wrapped, privateKey); - - expect(areUint8ArraysEqual(recovered, DATA_ENCRYPTION_KEY)).toBe(true); - }); - - it('accepts a base64url-encoded public key', () => { - const privateKey = x25519.utils.randomSecretKey(); - const publicKey = x25519.getPublicKey(privateKey); - const publicKeyBase64Url = Buffer.from(publicKey) - .toString('base64') - .replace(/\+/gu, '-') - .replace(/\//gu, '_') - .replace(/[=]+$/u, ''); - - const wrapped = wrapUserKey(publicKeyBase64Url, DATA_ENCRYPTION_KEY); - const recovered = unwrapUserKey(wrapped, privateKey); - - expect(areUint8ArraysEqual(recovered, DATA_ENCRYPTION_KEY)).toBe(true); - }); - - it('produces a fresh ephemeral key (non-deterministic output) per call', () => { - const privateKey = x25519.utils.randomSecretKey(); - const publicKey = x25519.getPublicKey(privateKey); - - const first = wrapUserKey(bytesToHex(publicKey), DATA_ENCRYPTION_KEY); - const second = wrapUserKey(bytesToHex(publicKey), DATA_ENCRYPTION_KEY); - - expect(first).not.toStrictEqual(second); - expect( - areUint8ArraysEqual( - unwrapUserKey(first, privateKey), - unwrapUserKey(second, privateKey), - ), - ).toBe(true); - }); - - it('rejects a public key of the wrong length', () => { - expect(() => wrapUserKey('abcd', DATA_ENCRYPTION_KEY)).toThrow( - 'unexpected length', - ); - }); -}); diff --git a/packages/kyc-controller/src/ukyc/wrapUserKey.ts b/packages/kyc-controller/src/ukyc/wrapUserKey.ts deleted file mode 100644 index f694d6310e..0000000000 --- a/packages/kyc-controller/src/ukyc/wrapUserKey.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { base64ToBytes } 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 { hexToBytes, randomBytes } from '@noble/hashes/utils'; - -import { toBase64Url } from '../encoding.js'; - -/** - * Wraps (encrypts) a symmetric key so that only the holder of a given X25519 - * private key can recover it — used to seal the `data_encryption_key` for the - * idOS Relay before it is handed to the UKYC API as `wrappedUserKey`. - */ - -/** X25519 public/private keys and AES-256-GCM keys are all 32 bytes. */ -const X25519_KEY_SIZE_BYTES = 32; - -/** 96-bit IV, the AES-GCM standard nonce size. */ -const IV_SIZE_BYTES = 12; - -/** - * Decodes an X25519 public key presented as either hex or base64/base64url. - * - * @param recipientPublicKey - The recipient's 32-byte X25519 public key. - * @returns The raw 32-byte public key. - */ -function decodePublicKey(recipientPublicKey: string): Uint8Array { - const isHex = - recipientPublicKey.length === X25519_KEY_SIZE_BYTES * 2 && - /^[0-9a-fA-F]+$/u.test(recipientPublicKey); - - const bytes = isHex - ? hexToBytes(recipientPublicKey) - : base64ToBytes( - recipientPublicKey - .replace(/-/gu, '+') - .replace(/_/gu, '/') - .padEnd( - recipientPublicKey.length + - ((4 - (recipientPublicKey.length % 4)) % 4), - '=', - ), - ); - - if (bytes.length !== X25519_KEY_SIZE_BYTES) { - throw new Error( - `UKYC: wrappingPublicKey has unexpected length ${bytes.length}, expected ${X25519_KEY_SIZE_BYTES}.`, - ); - } - return bytes; -} - -/** - * Wraps `keyToWrap` for the holder of `recipientPublicKey` using an - * ephemeral-static ECDH + AES-256-GCM sealed-box scheme: - * - * ephemeral = fresh X25519 keypair (one per call) - * shared = X25519(ephemeralPrivate, recipientPublic) - * aeadKey = HKDF-SHA256(shared, 32 bytes) - * iv = 12 random bytes - * ct = AES-256-GCM(aeadKey, iv).encrypt(keyToWrap) // ct includes tag - * - * The recipient reverses it with their private key: - * - * shared = X25519(recipientPrivate, ephemeralPublic) - * aeadKey = HKDF-SHA256(shared, 32 bytes) - * key = AES-256-GCM(aeadKey, iv).decrypt(ct) - * - * This mirrors the X25519 + AES-256-GCM/HKDF decryption used for MoonPay - * Check/Auth-frame credentials, so both directions share one primitive. - * - * @param recipientPublicKey - The recipient's X25519 public key (hex or base64). - * @param keyToWrap - The raw symmetric key bytes to encrypt (e.g. the - * `data_encryption_key`). - * @returns Base64url of `ephemeralPublicKey(32) || iv(12) || ciphertext+tag`. - */ -export function wrapUserKey( - recipientPublicKey: string, - keyToWrap: Uint8Array, -): string { - const recipient = decodePublicKey(recipientPublicKey); - - const ephemeralPrivateKey = x25519.utils.randomSecretKey(); - const ephemeralPublicKey = x25519.getPublicKey(ephemeralPrivateKey); - - const shared = x25519.getSharedSecret(ephemeralPrivateKey, recipient); - const aeadKey = hkdf(sha256, shared, undefined, undefined, 32); - - const iv = randomBytes(IV_SIZE_BYTES); - const ciphertext = gcm(aeadKey, iv).encrypt(keyToWrap); - - const blob = new Uint8Array( - ephemeralPublicKey.length + iv.length + ciphertext.length, - ); - blob.set(ephemeralPublicKey, 0); - blob.set(iv, ephemeralPublicKey.length); - blob.set(ciphertext, ephemeralPublicKey.length + iv.length); - - return toBase64Url(blob); -}