From 77d39192818bec5ee8d415ba2702e9cab55917d8 Mon Sep 17 00:00:00 2001 From: washluis-alencar Date: Tue, 10 Jun 2025 18:19:28 -0300 Subject: [PATCH 1/2] feat(eng-8294): add encryption method to create jwe --- package.json | 1 + src/model/EncryptTokenData.ts | 49 +++++++ src/modules/tokens.ts | 22 +++ src/tokenEncryption.ts | 136 +++++++++++++++++ tests/modules/tokenEncryption.test.ts | 201 ++++++++++++++++++++++++++ 5 files changed, 409 insertions(+) create mode 100644 src/model/EncryptTokenData.ts create mode 100644 src/tokenEncryption.ts create mode 100644 tests/modules/tokenEncryption.test.ts diff --git a/package.json b/package.json index 500aa04..3293263 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dependencies": { "@basis-theory/basis-theory-js": "^4.28.2", "card-validator": "^10.0.2", + "jose": "^4.15.4", "ramda": "^0.30.1", "react-native-mask-input": "^1.2.3", "react-native-url-polyfill": "^2.0.0", diff --git a/src/model/EncryptTokenData.ts b/src/model/EncryptTokenData.ts new file mode 100644 index 0000000..c889491 --- /dev/null +++ b/src/model/EncryptTokenData.ts @@ -0,0 +1,49 @@ +// TODO: Migrate to web-elements when we completely drop basis-theory-js +import { + CreateToken, + TokenBase, +} from '@basis-theory/basis-theory-js/types/models'; +import { BTRef, InputBTRefWithDatepart } from '../BaseElementTypes'; + +/** + * Represents token data with element references for secure input handling. + * Used when creating tokens that contain form element references instead of raw values. + */ +type TokenDataWithRef = { + /** Key-value pairs where values are element references or date part references */ + data: Record; + type: TokenBase['type']; +}; + +type TokenData = Pick; + +/** + * Configuration for encrypting token data using public key encryption. + * Contains the token requests, public key, and key identifier. + */ +type EncryptToken = { + /** + * Token requests to encrypt - can be a single token or multiple keyed tokens + */ + tokenRequests: + | { [key: string]: TokenDataWithRef } + | TokenDataWithRef; + publicKeyPEM: string; + + /** Unique identifier for the encryption key obtained from https://developers.basistheory.com/docs/api/client-keys */ + keyId: string; +}; + +/** + * Result of token encryption operation. + * Contains the encrypted token string and its original type. + */ +type EncryptedToken = { + /** Base64-encoded encrypted token data */ + encrypted: string; + + /** Original token type before encryption */ + type: TokenBase['type']; +}; + +export { EncryptToken, EncryptedToken, TokenData, TokenDataWithRef }; diff --git a/src/modules/tokens.ts b/src/modules/tokens.ts index 7cea898..cbd1662 100644 --- a/src/modules/tokens.ts +++ b/src/modules/tokens.ts @@ -19,6 +19,8 @@ import { replaceSensitiveData, } from '../utils/dataManipulationUtils'; import { isNilOrEmpty } from '../utils/shared'; +import { EncryptToken } from '../model/EncryptTokenData'; +import { encryptToken } from '../tokenEncryption'; export type CreateTokenWithBtRef = Omit & { data: Record; @@ -115,6 +117,25 @@ export const Tokens = (bt: BasisTheoryType) => { } }; + const encrypt = async ( + encryptRequest: EncryptToken, + ) => { + + console.log('encryptRequest', encryptRequest); + if (!isNilOrEmpty(_elementErrors)) { + console.log('element errors', _elementErrors); + throw new Error( + 'Unable to encrypt token. Payload contains invalid values. Review elements events for more details.' + ); + } + + try { + return await encryptToken(encryptRequest); + } catch (error) { + console.error(error); + } + }; + return { /** @deprecated use `bt.tokens.retrieve` instead */ getById: getTokenById, @@ -123,5 +144,6 @@ export const Tokens = (bt: BasisTheoryType) => { update, delete: deleteToken, tokenize, + encrypt, }; }; diff --git a/src/tokenEncryption.ts b/src/tokenEncryption.ts new file mode 100644 index 0000000..fe607ea --- /dev/null +++ b/src/tokenEncryption.ts @@ -0,0 +1,136 @@ +import { CompactEncrypt, importJWK, JWK, KeyLike } from 'jose'; +import { EncryptedToken, EncryptToken, TokenData, TokenDataWithRef } from './model/EncryptTokenData'; +import { replaceElementRefs } from './utils/dataManipulationUtils'; + +const ENCRYPTION = { + KEY_TYPE: 'OKP', + CURVE: 'X25519', + ALGORITHM: 'ECDH-ES', + ENCRYPTION_ALGORITHM: 'A256GCM' +} as const; + +export class EncryptValidationError extends Error { + public constructor(message: string) { + super(message); + this.name = 'EncryptValidationError'; + } +} + +/** + * Creates a JSON Web Encryption (JWE) object for the given payload + * @param payload - The string payload to encrypt + * @param jwk - The JSON Web Key containing key metadata + * @param key - The cryptographic key for encryption + * @returns Promise resolving to the encrypted JWE string + * @throws {Error} When encryption fails or parameters are invalid + */ +const createJWE = async ( + payload: string, + jwk: JWK, + key: KeyLike | Uint8Array +): Promise => { + try { + return new CompactEncrypt( + new TextEncoder().encode(payload) + ) + .setProtectedHeader({ alg: ENCRYPTION.ALGORITHM, enc: ENCRYPTION.ENCRYPTION_ALGORITHM, kid: jwk.kid }) + .encrypt(key); + } catch (error) { + throw new Error( + `Failed to create JWE: ${ + error instanceof Error ? error.message : 'Unknown error' + }` + ); + } +}; + +/** + * Normalizes token requests to a consistent array format + * @param tokenRequests - Token requests in either single token or object format + * @returns Array of normalized token objects + */ +const normalizeTokenRequests = ( + tokenRequests: EncryptToken['tokenRequests'] +): Array => { + if ('type' in tokenRequests) { + return [tokenRequests as TokenDataWithRef]; + } + + return Object.values(tokenRequests); +}; + +const removePEMFormat = (publicKeyPEM: string) => + publicKeyPEM + .replace('-----BEGIN PUBLIC KEY-----', '') + .replace('-----END PUBLIC KEY-----', '') + .replace(/[\n\r]/gu, '') + .replace(/\+/gu, '-') + .replace(/\//gu, '_') + .replace(/[=]+$/gu, ''); + +/** + * Encrypts token data using JSON Web Encryption (JWE) + * @param payload - The encryption payload containing token requests, public key, and key ID + * @returns Promise resolving to an array of encrypted tokens + * @throws {Error} When encryption fails or payload is invalid + */ +// TODO: To avoid duplication, add this to web-elements when we completely drop basis-theory-js +export const encryptToken = async ( + payload: EncryptToken +): Promise => { + if (!payload) { + throw new EncryptValidationError('Encryption payload is required'); + } + + if (!payload.publicKeyPEM) { + throw new EncryptValidationError('Public key PEM is required'); + } + + if (!payload.keyId) { + throw new EncryptValidationError('Key ID is required'); + } + + if (!payload.tokenRequests) { + throw new EncryptValidationError('Token requests are required'); + } + + try { + + const jwk: JWK = { + kty: ENCRYPTION.KEY_TYPE, + crv: ENCRYPTION.CURVE, + x: removePEMFormat(payload.publicKeyPEM), + kid: payload.keyId + }; + + const key = await importJWK(jwk, ENCRYPTION.ALGORITHM); + + const tokensWithRef: TokenDataWithRef[] = normalizeTokenRequests(payload.tokenRequests); + if (!tokensWithRef.length) { + throw new EncryptValidationError('No valid tokens found to encrypt'); + } + + const tokens: TokenData[] = tokensWithRef.map(token => replaceElementRefs(token)); + + return await Promise.all( + tokens.map(async (token) => { + if (!token.type) { + throw new EncryptValidationError('Token type is required'); + } + + const tokenPayload = JSON.stringify(token); + const encrypted = await createJWE(tokenPayload, jwk, key); + + return { + encrypted, + type: token.type, + }; + }) + ); + } catch (error) { + if (error instanceof EncryptValidationError) { + throw error; + } + throw new Error(`Failed to encrypt tokens: ${String(error)}`); + } +}; diff --git a/tests/modules/tokenEncryption.test.ts b/tests/modules/tokenEncryption.test.ts new file mode 100644 index 0000000..2a56ffc --- /dev/null +++ b/tests/modules/tokenEncryption.test.ts @@ -0,0 +1,201 @@ +import { _elementErrors } from '../../src/ElementValues'; +import { + CreateTokenWithBtRef, + Tokens, +} from '../../src/modules/tokens'; +import type { BasisTheory as BasisTheoryType } from '@basis-theory/basis-theory-js/types/sdk'; +import { EncryptValidationError } from '../../src/tokenEncryption'; + +jest.mock('../../src/ElementValues', () => ({ + _elementValues: {}, + _elementErrors: {}, +})); + +describe('tokens - Encrypt', () => { + const mockPublicKey = '-----BEGIN PUBLIC KEY-----\nm4trz9vdM2a0YAIBBT15OU71RpfLrFBtbGOD3uS0g10=\n-----END PUBLIC KEY-----'; + const mockKeyId = 'test-key-id-123'; + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + beforeEach(() => { + Object.assign(_elementErrors, {}); + consoleErrorSpy.mockClear(); + }); + + afterAll(() => { + Object.keys(_elementErrors).forEach((key) => delete _elementErrors[key]); + }); + + test('calls encrypt with single token request', async () => { + const tokenWithRef = { + type: 'card', + data: { + number: { id: '123', format: jest.fn() }, + expiration_month: { id: 'expirationDate', datepart: 'month' }, + expiration_year: { id: 'expirationDate', datepart: 'year' }, + }, + } as unknown as CreateTokenWithBtRef; + + const encryptRequest = { + tokenRequests: tokenWithRef, + publicKeyPEM: mockPublicKey, + keyId: mockKeyId, + }; + + const result = await Tokens({} as BasisTheoryType).encrypt(encryptRequest); + expect(result).toBeDefined(); + expect(result).toHaveLength(1); + expect(result![0].encrypted).toBeDefined(); + expect(result![0].encrypted.split('.').length).toBe(5); + expect(result![0].type).toBe('card'); + }); + + test('calls bt encrypt with multiple token requests', async () => { + const cardTokenWithRef = { + type: 'card', + data: { + number: { id: '123', format: jest.fn() }, + }, + } as unknown as CreateTokenWithBtRef; + + const bankTokenWithRef = { + type: 'bank', + data: { + account_number: { id: '456', format: jest.fn() }, + }, + } as unknown as CreateTokenWithBtRef; + + const encryptRequest = { + tokenRequests: { + card: cardTokenWithRef, + bank: bankTokenWithRef, + }, + publicKeyPEM: mockPublicKey, + keyId: mockKeyId, + }; + + const result = await Tokens({} as BasisTheoryType).encrypt(encryptRequest); + expect(result).toBeDefined(); + expect(result).toHaveLength(2); + expect(result![0].encrypted).toBeDefined(); + expect(result![0].encrypted.split('.').length).toBe(5); + expect(result![0].type).toBe('card'); + expect(result![1].encrypted).toBeDefined(); + expect(result![1].encrypted.split('.').length).toBe(5); + expect(result![1].type).toBe('bank'); + }); + + test('handles nested objects in encrypt requests', async () => { + const tokenWithRef = { + type: 'card', + data: { + number: { id: '123', format: jest.fn() }, + billing: { + address: { id: '456', format: jest.fn() }, + }, + tags: [ + { id: 'firstArrayElement', format: jest.fn() }, + { id: 'secondArrayElement', format: jest.fn() }, + ], + }, + } as unknown as CreateTokenWithBtRef; + + const encryptRequest = { + tokenRequests: tokenWithRef, + publicKeyPEM: mockPublicKey, + keyId: mockKeyId, + }; + + const result = await Tokens({} as BasisTheoryType).encrypt(encryptRequest); + expect(result).toBeDefined(); + expect(result).toHaveLength(1); + expect(result![0].encrypted).toBeDefined(); + expect(result![0].encrypted.split('.').length).toBe(5); + expect(result![0].type).toBe('card'); + }); + + test('throws error when publicKeyPEM is null', async () => { + + const tokenWithRef = { + type: 'card', + data: { + number: { id: '123', format: jest.fn() }, + }, + } as unknown as CreateTokenWithBtRef; + + const encryptRequest = { + tokenRequests: tokenWithRef, + publicKeyPEM: '', + keyId: mockKeyId, + }; + + await Tokens({} as BasisTheoryType).encrypt(encryptRequest); + const error = consoleErrorSpy.mock.calls[0][0]; + expect(error).toBeInstanceOf(EncryptValidationError); + expect(error.message).toContain('Public key PEM is required'); + }); + + test('throws error when keyId is null', async () => { + + const tokenWithRef = { + type: 'card', + data: { + number: { id: '123', format: jest.fn() }, + }, + } as unknown as CreateTokenWithBtRef; + + const encryptRequest = { + tokenRequests: tokenWithRef, + publicKeyPEM: mockPublicKey, + keyId: '', + }; + + + await Tokens({} as BasisTheoryType).encrypt(encryptRequest); + const error = consoleErrorSpy.mock.calls[0][0]; + expect(error).toBeInstanceOf(EncryptValidationError); + expect(error.message).toContain('Key ID is required'); + + }); + + test('throws error when tokenRequests is null', async () => { + const encryptRequest = { + tokenRequests: {}, + publicKeyPEM: mockPublicKey, + keyId: mockKeyId, + }; + + await Tokens({} as BasisTheoryType).encrypt(encryptRequest); + const error = consoleErrorSpy.mock.calls[0][0]; + expect(error).toBeInstanceOf(EncryptValidationError); + expect(error.message).toContain('No valid tokens found to encrypt'); + + }); + + test('throws error when validation fails for encrypt', () => { + Object.assign(_elementErrors, { + '123': 'invalid card number', + }); + + const tokenWithRef = { + type: 'card', + data: { + number: { id: '123', format: jest.fn() }, + }, + } as unknown as CreateTokenWithBtRef; + + const encryptRequest = { + tokenRequests: tokenWithRef, + publicKeyPEM: mockPublicKey, + keyId: mockKeyId, + }; + + const action = async () => { + await Tokens({} as BasisTheoryType).encrypt(encryptRequest); + }; + + expect(() => action()).rejects.toThrow( + 'Unable to encrypt token. Payload contains invalid values. Review elements events for more details.' + ); + }); + +}); From 497d44877cf86c10fec5bbe776e903dc385c4384 Mon Sep 17 00:00:00 2001 From: washluis-alencar Date: Wed, 11 Jun 2025 11:52:54 -0300 Subject: [PATCH 2/2] feat(eng-8294): commit yarn.lock --- yarn.lock | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/yarn.lock b/yarn.lock index 54c8197..2df23f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6396,6 +6396,11 @@ joi@^17.2.1: "@sideway/formula" "^3.0.1" "@sideway/pinpoint" "^2.0.0" +jose@^4.15.4: + version "4.15.9" + resolved "https://registry.yarnpkg.com/jose/-/jose-4.15.9.tgz#9b68eda29e9a0614c042fa29387196c7dd800100" + integrity sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"