diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index 2111616d1c..f0c879fbcc 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -17,6 +17,7 @@ export default (): ReturnType => ({ encryption: { type: faker.string.sample(), awsKms: { + algorithm: faker.string.alphanumeric(), keyId: faker.string.uuid(), }, local: { diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 4a1c45f581..b3c77dbc0b 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -30,6 +30,7 @@ export default () => ({ type: process.env.ACCOUNTS_ENCRYPTION_TYPE || 'local', awsKms: { keyId: process.env.AWS_KMS_ENCRYPTION_KEY_ID, + algorithm: process.env.AWS_KMS_ENCRYPTION_ALGORITHM || 'aes-256-cbc', }, local: { algorithm: process.env.LOCAL_ENCRYPTION_ALGORITHM || 'aes-256-cbc', diff --git a/src/datasources/accounts/encryption/aws-encryption-api.service.spec.ts b/src/datasources/accounts/encryption/aws-encryption-api.service.spec.ts index 8e1faab5b1..85585a29e3 100644 --- a/src/datasources/accounts/encryption/aws-encryption-api.service.spec.ts +++ b/src/datasources/accounts/encryption/aws-encryption-api.service.spec.ts @@ -1,8 +1,16 @@ +import { fakeJson } from '@/__tests__/faker'; import type { IConfigurationService } from '@/config/configuration.service.interface'; import { AwsEncryptionApiService } from '@/datasources/accounts/encryption/aws-encryption-api.service'; -import { DecryptCommand, EncryptCommand, KMSClient } from '@aws-sdk/client-kms'; +import { encryptedBlobBuilder } from '@/datasources/accounts/encryption/entities/__tests__/encrypted-blob.builder'; +import { + DecryptCommand, + EncryptCommand, + GenerateDataKeyCommand, + KMSClient, +} from '@aws-sdk/client-kms'; import { faker } from '@faker-js/faker/.'; import { mockClient } from 'aws-sdk-client-mock'; +import * as crypto from 'crypto'; const mockConfigurationService = { get: jest.fn(), @@ -17,36 +25,142 @@ describe('AwsEncryptionApiService', () => { beforeEach(() => { jest.resetAllMocks(); mockConfigurationService.get.mockImplementation((key) => { - if (key === 'accounts.encryption.awsKms.keyId') { - return awsKmsKeyId; - } + if (key === 'accounts.encryption.awsKms.keyId') return awsKmsKeyId; + throw new Error(`Unexpected key: ${key}`); + }); + mockConfigurationService.getOrThrow.mockImplementation((key) => { + if (key === 'accounts.encryption.awsKms.algorithm') return 'aes-256-cbc'; throw new Error(`Unexpected key: ${key}`); }); target = new AwsEncryptionApiService(mockConfigurationService); }); - it('should encrypt and decrypt data correctly', async () => { - const data = 'test data'; - kmsMock.on(EncryptCommand).resolves({ - CiphertextBlob: Buffer.from(data), + describe('encrypt/decrypt', () => { + it('should encrypt and decrypt data correctly', async () => { + const data = faker.string.alphanumeric(); + kmsMock.on(EncryptCommand).resolves({ + CiphertextBlob: Buffer.from(data), + }); + kmsMock.on(DecryptCommand).resolves({ + Plaintext: Buffer.from(data), + }); + const encrypted = await target.encrypt(data); + const decrypted = await target.decrypt(encrypted); + + expect(decrypted).toBe(data); + expect( + kmsMock.commandCalls(EncryptCommand, { + KeyId: awsKmsKeyId, + Plaintext: Buffer.from(data), + }), + ).toHaveLength(1); + expect( + kmsMock.commandCalls(DecryptCommand, { + CiphertextBlob: Buffer.from(encrypted, 'base64'), + }), + ).toHaveLength(1); }); - kmsMock.on(DecryptCommand).resolves({ - Plaintext: Buffer.from(data), + + it('should fail to encrypt when the KMS client fails', async () => { + const data = faker.string.alphanumeric(); + kmsMock.on(EncryptCommand).rejects(new Error('Test error')); + await expect(target.encrypt(data)).rejects.toThrow('Test error'); }); - const encrypted = await target.encrypt(data); - const decrypted = await target.decrypt(encrypted); - expect(decrypted).toBe(data); - expect( - kmsMock.commandCalls(EncryptCommand, { - KeyId: awsKmsKeyId, - Plaintext: Buffer.from(data), - }), - ).toHaveLength(1); - expect( - kmsMock.commandCalls(DecryptCommand, { - CiphertextBlob: Buffer.from(encrypted, 'base64'), - }), - ).toHaveLength(1); + it('should fail to decrypt when the KMS client fails', async () => { + const data = faker.string.alphanumeric(); + kmsMock.on(DecryptCommand).rejects(new Error('Test error')); + await expect(target.decrypt(data)).rejects.toThrow('Test error'); + }); + + it('should fail to encrypt when the KMS client does not return a CiphertextBlob', async () => { + const data = faker.string.alphanumeric(); + kmsMock.on(EncryptCommand).resolves({}); + await expect(target.encrypt(data)).rejects.toThrow( + 'Failed to encrypt data', + ); + }); + + it('should fail to decrypt when the KMS client does not return a Plaintext', async () => { + const data = faker.string.alphanumeric(); + kmsMock.on(DecryptCommand).resolves({}); + await expect(target.decrypt(data)).rejects.toThrow( + 'Failed to decrypt data', + ); + }); + }); + + describe('encryptBlob/decryptBlob', () => { + it('should encrypt and decrypt arrays of objects correctly', async () => { + const data = [JSON.parse(fakeJson()), JSON.parse(fakeJson())]; + const key = new Uint8Array(crypto.randomBytes(32).buffer); + const encryptedBlob = encryptedBlobBuilder().build(); + kmsMock.on(GenerateDataKeyCommand).resolves({ + Plaintext: Buffer.from(key), + CiphertextBlob: Buffer.from(encryptedBlob.encryptedDataKey), + }); + kmsMock.on(EncryptCommand).resolves({ + CiphertextBlob: encryptedBlob.encryptedDataKey, + }); + kmsMock.on(DecryptCommand).resolves({ + Plaintext: Buffer.from(key), + }); + const encrypted = await target.encryptBlob(data); + const decrypted = await target.decryptBlob(encrypted); + + expect(decrypted).toStrictEqual(data); + }); + + it('should fail to encrypt when the KMS client fails to generate the key', async () => { + const data = [JSON.parse(fakeJson()), JSON.parse(fakeJson())]; + kmsMock.on(GenerateDataKeyCommand).rejects(new Error('Test error')); + await expect(target.encryptBlob(data)).rejects.toThrow('Test error'); + }); + + it('should fail to encrypt when the KMS client does not return a CiphertextBlob key', async () => { + const data = [JSON.parse(fakeJson()), JSON.parse(fakeJson())]; + kmsMock + .on(GenerateDataKeyCommand) + .resolves({ Plaintext: Buffer.from([]) }); + await expect(target.encryptBlob(data)).rejects.toThrow( + 'Failed to generate data key', + ); + }); + + it('should fail to encrypt when the KMS client does not return a Plaintext key', async () => { + const data = [JSON.parse(fakeJson()), JSON.parse(fakeJson())]; + kmsMock + .on(GenerateDataKeyCommand) + .resolves({ CiphertextBlob: Buffer.from([]) }); + await expect(target.encryptBlob(data)).rejects.toThrow( + 'Failed to generate data key', + ); + }); + + it('should fail to encrypt non-object data', async () => { + await expect( + target.encryptBlob(faker.string.alphanumeric()), + ).rejects.toThrow('Data must be an object or array'); + }); + + it('should fail to encrypt null data', async () => { + await expect(target.encryptBlob(null)).rejects.toThrow( + 'Data must be an object or array', + ); + }); + + it('should fail to encrypt undefined data', async () => { + await expect(target.encryptBlob(undefined)).rejects.toThrow( + 'Data must be an object or array', + ); + }); + + it('should fail to decrypt when the KMS client fails while decrypting the key', async () => { + const encryptedBlob = encryptedBlobBuilder().build(); + kmsMock.on(DecryptCommand).rejects(new Error('Test error')); + await expect(target.decryptBlob(encryptedBlob)).rejects.toThrow( + 'Test error', + ); + }); }); }); diff --git a/src/datasources/accounts/encryption/aws-encryption-api.service.ts b/src/datasources/accounts/encryption/aws-encryption-api.service.ts index 76edcf5f17..99974e9bdc 100644 --- a/src/datasources/accounts/encryption/aws-encryption-api.service.ts +++ b/src/datasources/accounts/encryption/aws-encryption-api.service.ts @@ -1,12 +1,20 @@ import { IConfigurationService } from '@/config/configuration.service.interface'; +import { EncryptedBlob } from '@/datasources/accounts/encryption/entities/encrypted-blob.entity'; import type { IEncryptionApi } from '@/domain/interfaces/encryption-api.interface'; -import { DecryptCommand, EncryptCommand, KMSClient } from '@aws-sdk/client-kms'; +import { + DecryptCommand, + EncryptCommand, + GenerateDataKeyCommand, + KMSClient, +} from '@aws-sdk/client-kms'; import { Inject, Injectable } from '@nestjs/common'; +import * as crypto from 'crypto'; @Injectable() export class AwsEncryptionApiService implements IEncryptionApi { private readonly kmsClient: KMSClient; private readonly awsKmsKeyId: string | undefined; + private readonly algorithm: string; constructor( @Inject(IConfigurationService) @@ -15,6 +23,9 @@ export class AwsEncryptionApiService implements IEncryptionApi { this.awsKmsKeyId = this.configurationService.get( 'accounts.encryption.awsKms.keyId', ); + this.algorithm = this.configurationService.getOrThrow( + 'accounts.encryption.awsKms.algorithm', + ); this.kmsClient = new KMSClient({}); } @@ -38,6 +49,47 @@ export class AwsEncryptionApiService implements IEncryptionApi { if (!decryptedData.Plaintext) { throw new Error('Failed to decrypt data'); } - return Buffer.from(decryptedData.Plaintext).toString('utf-8'); + return Buffer.from(decryptedData.Plaintext).toString('utf8'); + } + + async encryptBlob(data: unknown): Promise { + if ((typeof data !== 'object' && !Array.isArray(data)) || data === null) { + throw new Error('Data must be an object or array'); + } + const { Plaintext, CiphertextBlob } = await this.kmsClient.send( + new GenerateDataKeyCommand({ + KeyId: this.awsKmsKeyId, + KeySpec: 'AES_256', + }), + ); + if (!Plaintext || !CiphertextBlob) { + throw new Error('Failed to generate data key'); + } + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(this.algorithm, Plaintext, iv); + return { + encryptedData: Buffer.concat([ + cipher.update(JSON.stringify(data), 'utf8'), + cipher.final(), + ]), + encryptedDataKey: Buffer.from(CiphertextBlob), + iv, + }; + } + + async decryptBlob(encryptedBlob: EncryptedBlob): Promise { + const { encryptedData, encryptedDataKey, iv } = encryptedBlob; + const { Plaintext } = await this.kmsClient.send( + new DecryptCommand({ CiphertextBlob: encryptedDataKey }), + ); + if (!Plaintext) { + throw new Error('Failed to decrypt data key'); + } + const decipher = crypto.createDecipheriv(this.algorithm, Plaintext, iv); + const decryptedData = Buffer.concat([ + decipher.update(encryptedData), + decipher.final(), + ]); + return JSON.parse(decryptedData.toString('utf8')); } } diff --git a/src/datasources/accounts/encryption/encryption-api.manager.spec.ts b/src/datasources/accounts/encryption/encryption-api.manager.spec.ts index 4a665e6eab..060242434e 100644 --- a/src/datasources/accounts/encryption/encryption-api.manager.spec.ts +++ b/src/datasources/accounts/encryption/encryption-api.manager.spec.ts @@ -52,6 +52,8 @@ describe('EncryptionApiManager', () => { it('should get a AwsEncryptionApiService', async () => { mockConfigurationService.getOrThrow.mockImplementation((key) => { if (key === 'accounts.encryption.type') return 'aws'; + if (key === 'accounts.encryption.awsKms.keyId') return 'a'.repeat(64); + if (key === 'accounts.encryption.awsKms.algorithm') return 'aes-256-cbc'; throw new Error(`Unexpected key: ${key}`); }); target = new EncryptionApiManager(mockConfigurationService); @@ -64,6 +66,8 @@ describe('EncryptionApiManager', () => { it('should return the same instance of AwsEncryptionApiService on a second call', async () => { mockConfigurationService.getOrThrow.mockImplementation((key) => { if (key === 'accounts.encryption.type') return 'aws'; + if (key === 'accounts.encryption.awsKms.keyId') return 'a'.repeat(64); + if (key === 'accounts.encryption.awsKms.algorithm') return 'aes-256-cbc'; throw new Error(`Unexpected key: ${key}`); }); target = new EncryptionApiManager(mockConfigurationService); diff --git a/src/datasources/accounts/encryption/entities/__tests__/encrypted-blob.builder.ts b/src/datasources/accounts/encryption/entities/__tests__/encrypted-blob.builder.ts new file mode 100644 index 0000000000..12f082cd6b --- /dev/null +++ b/src/datasources/accounts/encryption/entities/__tests__/encrypted-blob.builder.ts @@ -0,0 +1,18 @@ +import { Builder, type IBuilder } from '@/__tests__/builder'; +import { fakeJson } from '@/__tests__/faker'; +import type { EncryptedBlob } from '@/datasources/accounts/encryption/entities/encrypted-blob.entity'; +import { faker } from '@faker-js/faker/.'; + +export function encryptedBlobBuilder(): IBuilder { + return new Builder() + .with( + 'encryptedData', + Buffer.from( + faker.helpers.multiple(() => JSON.parse(fakeJson()), { + count: 10, + }), + ), + ) + .with('encryptedDataKey', Buffer.from(faker.string.alphanumeric())) + .with('iv', Buffer.from(faker.string.alphanumeric())); +} diff --git a/src/datasources/accounts/encryption/entities/encrypted-blob.entity.ts b/src/datasources/accounts/encryption/entities/encrypted-blob.entity.ts new file mode 100644 index 0000000000..9174dd9e40 --- /dev/null +++ b/src/datasources/accounts/encryption/entities/encrypted-blob.entity.ts @@ -0,0 +1,5 @@ +export type EncryptedBlob = { + encryptedData: Buffer; + encryptedDataKey: Buffer; + iv: Buffer; +}; diff --git a/src/datasources/accounts/encryption/local-encryption-api.service.spec.ts b/src/datasources/accounts/encryption/local-encryption-api.service.spec.ts index fc7e553d75..4c1f072f42 100644 --- a/src/datasources/accounts/encryption/local-encryption-api.service.spec.ts +++ b/src/datasources/accounts/encryption/local-encryption-api.service.spec.ts @@ -1,5 +1,8 @@ +import { fakeJson } from '@/__tests__/faker'; import type { IConfigurationService } from '@/config/configuration.service.interface'; +import { encryptedBlobBuilder } from '@/datasources/accounts/encryption/entities/__tests__/encrypted-blob.builder'; import { LocalEncryptionApiService } from '@/datasources/accounts/encryption/local-encryption-api.service'; +import { faker } from '@faker-js/faker/.'; const mockConfigurationService = { get: jest.fn(), @@ -21,41 +24,115 @@ describe('LocalEncryptionApiService', () => { target = new LocalEncryptionApiService(mockConfigurationService); }); - it('should fail to encrypt in production', async () => { - mockConfigurationService.getOrThrow.mockImplementation((key) => { - if (key === 'application.isProduction') return true; - if (key === 'accounts.encryption.local.algorithm') return 'aes-256-cbc'; - if (key === 'accounts.encryption.local.key') return 'a'.repeat(64); - if (key === 'accounts.encryption.local.iv') return 'b'.repeat(32); - throw new Error(`Unexpected key: ${key}`); + describe('encrypt/decrypt', () => { + it('should fail to encrypt in production', async () => { + mockConfigurationService.getOrThrow.mockImplementation((key) => { + if (key === 'application.isProduction') return true; + if (key === 'accounts.encryption.local.algorithm') return 'aes-256-cbc'; + if (key === 'accounts.encryption.local.key') return 'a'.repeat(64); + if (key === 'accounts.encryption.local.iv') return 'b'.repeat(32); + throw new Error(`Unexpected key: ${key}`); + }); + target = new LocalEncryptionApiService(mockConfigurationService); + + await expect(target.encrypt(faker.string.alphanumeric())).rejects.toThrow( + 'Local encryption is not suitable for production usage', + ); }); - target = new LocalEncryptionApiService(mockConfigurationService); - await expect(target.encrypt('data')).rejects.toThrow( - 'Local encryption is not suitable for production usage', - ); - }); + it('should fail to decrypt in production', async () => { + mockConfigurationService.getOrThrow.mockImplementation((key) => { + if (key === 'application.isProduction') return true; + if (key === 'accounts.encryption.local.algorithm') return 'aes-256-cbc'; + if (key === 'accounts.encryption.local.key') return 'a'.repeat(64); + if (key === 'accounts.encryption.local.iv') return 'b'.repeat(32); + throw new Error(`Unexpected key: ${key}`); + }); + target = new LocalEncryptionApiService(mockConfigurationService); - it('should fail to decrypt in production', async () => { - mockConfigurationService.getOrThrow.mockImplementation((key) => { - if (key === 'application.isProduction') return true; - if (key === 'accounts.encryption.local.algorithm') return 'aes-256-cbc'; - if (key === 'accounts.encryption.local.key') return 'a'.repeat(64); - if (key === 'accounts.encryption.local.iv') return 'b'.repeat(32); - throw new Error(`Unexpected key: ${key}`); + await expect(target.decrypt(faker.string.alphanumeric())).rejects.toThrow( + 'Local encryption is not suitable for production usage', + ); }); - target = new LocalEncryptionApiService(mockConfigurationService); - await expect(target.decrypt('data')).rejects.toThrow( - 'Local encryption is not suitable for production usage', - ); + it('should encrypt and decrypt data correctly', async () => { + const data = faker.string.alphanumeric({ length: 100 }); + const encrypted = await target.encrypt(data); + const decrypted = await target.decrypt(encrypted); + + expect(decrypted).toBe(data); + }); + + it('should encrypt and decrypt objects correctly', async () => { + const data = JSON.parse(fakeJson()); + const encrypted = await target.encryptBlob(data); + const decrypted = await target.decryptBlob(encrypted); + + expect(decrypted).toStrictEqual(data); + }); }); - it('should encrypt and decrypt data correctly', async () => { - const data = 'test data'; - const encrypted = await target.encrypt(data); - const decrypted = await target.decrypt(encrypted); + describe('encryptBlob/decryptBlob', () => { + it('should fail to encryptBlob in production', async () => { + mockConfigurationService.getOrThrow.mockImplementation((key) => { + if (key === 'application.isProduction') return true; + if (key === 'accounts.encryption.local.algorithm') return 'aes-256-cbc'; + if (key === 'accounts.encryption.local.key') return 'a'.repeat(64); + if (key === 'accounts.encryption.local.iv') return 'b'.repeat(32); + throw new Error(`Unexpected key: ${key}`); + }); + target = new LocalEncryptionApiService(mockConfigurationService); + + await expect( + target.encryptBlob(faker.string.alphanumeric()), + ).rejects.toThrow( + 'Local encryption is not suitable for production usage', + ); + }); + + it('should fail to decryptBlob in production', async () => { + mockConfigurationService.getOrThrow.mockImplementation((key) => { + if (key === 'application.isProduction') return true; + if (key === 'accounts.encryption.local.algorithm') return 'aes-256-cbc'; + if (key === 'accounts.encryption.local.key') return 'a'.repeat(64); + if (key === 'accounts.encryption.local.iv') return 'b'.repeat(32); + throw new Error(`Unexpected key: ${key}`); + }); + target = new LocalEncryptionApiService(mockConfigurationService); + + await expect( + target.decryptBlob(encryptedBlobBuilder().build()), + ).rejects.toThrow( + 'Local encryption is not suitable for production usage', + ); + }); + + it('should encrypt and decrypt arrays of objects correctly', async () => { + const data = faker.helpers.multiple(() => JSON.parse(fakeJson()), { + count: 10, + }); + const encrypted = await target.encryptBlob(data); + const decrypted = await target.decryptBlob(encrypted); + + expect(decrypted).toStrictEqual(data); + }); + + it('should fail to encrypt non-object data', async () => { + await expect( + target.encryptBlob(faker.string.alphanumeric()), + ).rejects.toThrow('Data must be an object or array'); + }); + + it('should fail to encrypt null data', async () => { + await expect(target.encryptBlob(null)).rejects.toThrow( + 'Data must be an object or array', + ); + }); - expect(decrypted).toBe(data); + it('should fail to encrypt undefined data', async () => { + await expect(target.encryptBlob(undefined)).rejects.toThrow( + 'Data must be an object or array', + ); + }); }); }); diff --git a/src/datasources/accounts/encryption/local-encryption-api.service.ts b/src/datasources/accounts/encryption/local-encryption-api.service.ts index 01ddd944ca..675dc156d0 100644 --- a/src/datasources/accounts/encryption/local-encryption-api.service.ts +++ b/src/datasources/accounts/encryption/local-encryption-api.service.ts @@ -1,4 +1,5 @@ import { IConfigurationService } from '@/config/configuration.service.interface'; +import { EncryptedBlob } from '@/datasources/accounts/encryption/entities/encrypted-blob.entity'; import type { IEncryptionApi } from '@/domain/interfaces/encryption-api.interface'; import { Injectable } from '@nestjs/common'; import * as crypto from 'crypto'; @@ -50,4 +51,47 @@ export class LocalEncryptionApiService implements IEncryptionApi { decrypted += decipher.final('utf8'); return Promise.resolve(decrypted); } + + async encryptBlob(data: unknown): Promise { + if (this.isProduction) { + throw new Error('Local encryption is not suitable for production usage'); + } + if ((typeof data !== 'object' && !Array.isArray(data)) || data === null) { + throw new Error('Data must be an object or array'); + } + const encryptedData = Buffer.from( + await this.encrypt(JSON.stringify(data)), + 'hex', + ); + const encryptedDataKey = Buffer.from( + await this.encrypt(this.key.toString('hex')), + 'hex', + ); + return { + encryptedData, + encryptedDataKey, + iv: this.iv, + }; + } + + async decryptBlob(encryptedBlob: EncryptedBlob): Promise { + if (this.isProduction) { + throw new Error('Local encryption is not suitable for production usage'); + } + const decryptedKey = await this.decrypt( + encryptedBlob.encryptedDataKey.toString('hex'), + ); + const decipher = crypto.createDecipheriv( + this.algorithm, + Buffer.from(decryptedKey, 'hex'), + encryptedBlob.iv, + ); + let decrypted = decipher.update( + encryptedBlob.encryptedData.toString('hex'), + 'hex', + 'utf8', + ); + decrypted += decipher.final('utf8'); + return Promise.resolve(JSON.parse(decrypted)); + } } diff --git a/src/domain/interfaces/encryption-api.interface.ts b/src/domain/interfaces/encryption-api.interface.ts index a45c8a224b..a53ed168dd 100644 --- a/src/domain/interfaces/encryption-api.interface.ts +++ b/src/domain/interfaces/encryption-api.interface.ts @@ -1,7 +1,13 @@ +import type { EncryptedBlob } from '@/datasources/accounts/encryption/entities/encrypted-blob.entity'; + export const IEncryptionApi = Symbol('IEncryptionApi'); export interface IEncryptionApi { encrypt(data: string): Promise; decrypt(data: string): Promise; + + encryptBlob(data: unknown): Promise; + + decryptBlob(data: EncryptedBlob): Promise; }