Skip to content

Commit

Permalink
Add Blob Encryption (#2130)
Browse files Browse the repository at this point in the history
  • Loading branch information
hectorgomezv authored Nov 15, 2024
1 parent 0f309a2 commit 8f74a8b
Show file tree
Hide file tree
Showing 10 changed files with 376 additions and 54 deletions.
1 change: 1 addition & 0 deletions src/config/entities/__tests__/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export default (): ReturnType<typeof configuration> => ({
encryption: {
type: faker.string.sample(),
awsKms: {
algorithm: faker.string.alphanumeric(),
keyId: faker.string.uuid(),
},
local: {
Expand Down
1 change: 1 addition & 0 deletions src/config/entities/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
162 changes: 138 additions & 24 deletions src/datasources/accounts/encryption/aws-encryption-api.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand All @@ -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',
);
});
});
});
56 changes: 54 additions & 2 deletions src/datasources/accounts/encryption/aws-encryption-api.service.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -15,6 +23,9 @@ export class AwsEncryptionApiService implements IEncryptionApi {
this.awsKmsKeyId = this.configurationService.get<string>(
'accounts.encryption.awsKms.keyId',
);
this.algorithm = this.configurationService.getOrThrow<string>(
'accounts.encryption.awsKms.algorithm',
);
this.kmsClient = new KMSClient({});
}

Expand All @@ -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<EncryptedBlob> {
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<unknown> {
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'));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<EncryptedBlob> {
return new Builder<EncryptedBlob>()
.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()));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type EncryptedBlob = {
encryptedData: Buffer;
encryptedDataKey: Buffer;
iv: Buffer;
};
Loading

0 comments on commit 8f74a8b

Please sign in to comment.