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..0441cf8b77 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)) 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..c248dc6952 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); @@ -878,6 +886,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 +899,8 @@ describe('KycService', () => { expect( await service.createUkycSession({ jwtToken: 'jwt', + sessionClientPublicKey: SESSION_CLIENT_PUBLIC_KEY, + residenceCountry: RESIDENCE_COUNTRY, vendorMetadata: { moonPayAccessToken: 'tok' }, }), ).toStrictEqual(response); @@ -904,6 +916,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 +927,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..c03682fc1f 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 @@ -790,7 +800,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}. @@ -811,6 +824,8 @@ export class KycService extends BaseDataService< vendorId: params.vendor ?? 'moonpay', vendorUserId: 'mockedId', jwtToken: params.jwtToken, + sessionClientPublicKey: params.sessionClientPublicKey, + residenceCountry: params.residenceCountry, vendorMetadata: params.vendorMetadata ?? {}, }), }), 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); -}