Skip to content

Commit

Permalink
Account names encryption via AWS KMS (#2120)
Browse files Browse the repository at this point in the history
- Adds accounts.encryption configuration to define both the type (aws, local) and the specifics of both.
- Adds EncryptionApiManager, which determines the IEncryptionApi implementation to use.
- Adds the LocalEncryptionApiService implementation of IEncryptionApi, that encrypts/decrypts data in local (not suitable for production).
- Adds the AwsEncryptionApiService implementation of IEncryptionApi, that encrypts/decrypts data in the cloud using the AWS KMS service.
- Adds the related tests.
- Adds encryption for Account['name'] in AccountsDatasource.
  • Loading branch information
hectorgomezv authored Nov 14, 2024
1 parent 28e52ab commit 20e8be8
Show file tree
Hide file tree
Showing 17 changed files with 962 additions and 61 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"migration:revert": "yarn typeorm migration:revert"
},
"dependencies": {
"@aws-sdk/client-kms": "^3.687.0",
"@aws-sdk/client-s3": "^3.685.0",
"@fingerprintjs/fingerprintjs-pro-server-api": "^5.2.0",
"@nestjs/cli": "^10.4.7",
Expand Down
26 changes: 13 additions & 13 deletions src/config/configuration.validator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ describe('Configuration validator', () => {
...JSON.parse(fakeJson()),
AUTH_TOKEN: faker.string.uuid(),
AWS_ACCESS_KEY_ID: faker.string.uuid(),
AWS_KMS_ENCRYPTION_KEY_ID: faker.string.uuid(),
AWS_SECRET_ACCESS_KEY: faker.string.uuid(),
AWS_REGION: faker.string.alphanumeric(),
ALERTS_PROVIDER_SIGNING_KEY: faker.string.uuid(),
Expand Down Expand Up @@ -153,6 +154,7 @@ describe('Configuration validator', () => {
...JSON.parse(fakeJson()),
AUTH_TOKEN: faker.string.uuid(),
AWS_ACCESS_KEY_ID: faker.string.uuid(),
AWS_KMS_ENCRYPTION_KEY_ID: faker.string.uuid(),
AWS_SECRET_ACCESS_KEY: faker.string.uuid(),
AWS_REGION: faker.lorem.word(),
LOG_LEVEL: faker.helpers.arrayElement(['error', 'warn', 'info']),
Expand Down Expand Up @@ -197,22 +199,20 @@ describe('Configuration validator', () => {
);
});

describe.each(['staging', 'production'])('environment', (env) => {
describe.each(['staging', 'production'])('%s environment', (env) => {
it.each([
{ key: 'AWS_ACCESS_KEY_ID' },
{ key: 'AWS_KMS_ENCRYPTION_KEY_ID' },
{ key: 'AWS_SECRET_ACCESS_KEY' },
{ key: 'AWS_REGION' },
])(
`should require AWS_* configuration in ${env} environment`,
({ key }) => {
process.env.NODE_ENV = 'production';
const config = { ...omit(validConfiguration, key), CGW_ENV: env };
expect(() =>
configurationValidator(config, RootConfigurationSchema),
).toThrow(
`Configuration is invalid: ${key} is required in production and staging environments`,
);
},
);
])(`should require $key configuration in ${env} environment`, ({ key }) => {
process.env.NODE_ENV = 'production';
const config = { ...omit(validConfiguration, key), CGW_ENV: env };
expect(() =>
configurationValidator(config, RootConfigurationSchema),
).toThrow(
`Configuration is invalid: ${key} is required in production and staging environments`,
);
});
});
});
11 changes: 11 additions & 0 deletions src/config/entities/__tests__/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ export default (): ReturnType<typeof configuration> => ({
creationRateLimitPeriodSeconds: faker.number.int(),
creationRateLimitCalls: faker.number.int(),
},
encryption: {
type: faker.string.sample(),
awsKms: {
keyId: faker.string.uuid(),
},
local: {
algorithm: faker.string.alphanumeric(),
key: faker.string.alphanumeric(),
iv: faker.string.alphanumeric(),
},
},
},
amqp: {
url: faker.internet.url({ appendSlash: false }),
Expand Down
13 changes: 13 additions & 0 deletions src/config/entities/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ export default () => ({
`${25}`,
),
},
encryption: {
// The encryption type to use. Defaults to 'local'.
// Supported values: 'aws', 'local'
type: process.env.ACCOUNTS_ENCRYPTION_TYPE || 'local',
awsKms: {
keyId: process.env.AWS_KMS_ENCRYPTION_KEY_ID,
},
local: {
algorithm: process.env.LOCAL_ENCRYPTION_ALGORITHM || 'aes-256-cbc',
key: process.env.LOCAL_ENCRYPTION_KEY || 'a'.repeat(64),
iv: process.env.LOCAL_ENCRYPTION_IV || 'b'.repeat(32),
},
},
},
amqp: {
url: process.env.AMQP_URL || 'amqp://localhost:5672',
Expand Down
41 changes: 25 additions & 16 deletions src/config/entities/schemas/configuration.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { z } from 'zod';

export const RootConfigurationSchema = z
.object({
ACCOUNTS_ENCRYPTION_TYPE: z.enum(['local', 'aws']).optional(),
AUTH_TOKEN: z.string(),
AWS_ACCESS_KEY_ID: z.string().optional(),
AWS_KMS_ENCRYPTION_KEY_ID: z.string().optional(),
AWS_SECRET_ACCESS_KEY: z.string().optional(),
AWS_REGION: z.string().optional(),
CGW_ENV: z.string().optional(),
Expand Down Expand Up @@ -41,24 +43,31 @@ export const RootConfigurationSchema = z
})
.superRefine((config, ctx) =>
// Check for AWS_* fields in production and staging environments
['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_REGION'].forEach(
(field) => {
if (
config.CGW_ENV &&
config instanceof Object &&
['production', 'staging'].includes(config.CGW_ENV) &&
!(config as Record<string, unknown>)[field]
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `is required in production and staging environments`,
path: [field],
});
}
},
),
[
'AWS_ACCESS_KEY_ID',
'AWS_KMS_ENCRYPTION_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'AWS_REGION',
].forEach((field) => {
if (
config.CGW_ENV &&
config instanceof Object &&
['production', 'staging'].includes(config.CGW_ENV) &&
!(config as Record<string, unknown>)[field]
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `is required in production and staging environments`,
path: [field],
});
}
}),
);

export type FileStorageType = z.infer<
typeof RootConfigurationSchema
>['TARGETED_MESSAGING_FILE_STORAGE_TYPE'];

export type AccountsEncryptionType = z.infer<
typeof RootConfigurationSchema
>['ACCOUNTS_ENCRYPTION_TYPE'];
13 changes: 9 additions & 4 deletions src/datasources/accounts/accounts.datasource.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { Module } from '@nestjs/common';
import { PostgresDatabaseModule } from '@/datasources/db/v1/postgres-database.module';
import { AccountsDatasource } from '@/datasources/accounts/accounts.datasource';
import { EncryptionApiManager } from '@/datasources/accounts/encryption/encryption-api.manager';
import { PostgresDatabaseModule } from '@/datasources/db/v1/postgres-database.module';
import { IAccountsDatasource } from '@/domain/interfaces/accounts.datasource.interface';
import { IEncryptionApiManager } from '@/domain/interfaces/encryption-api.manager.interface';
import { Module } from '@nestjs/common';

@Module({
imports: [PostgresDatabaseModule],
providers: [{ provide: IAccountsDatasource, useClass: AccountsDatasource }],
exports: [IAccountsDatasource],
providers: [
{ provide: IAccountsDatasource, useClass: AccountsDatasource },
{ provide: IEncryptionApiManager, useClass: EncryptionApiManager },
],
exports: [IAccountsDatasource, IEncryptionApiManager],
})
export class AccountsDatasourceModule {}
33 changes: 25 additions & 8 deletions src/datasources/accounts/accounts.datasource.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { accountDataTypeBuilder } from '@/domain/accounts/entities/__tests__/acc
import { createAccountDtoBuilder } from '@/domain/accounts/entities/__tests__/create-account.dto.builder';
import { upsertAccountDataSettingsDtoBuilder } from '@/domain/accounts/entities/__tests__/upsert-account-data-settings.dto.entity.builder';
import type { AccountDataType } from '@/domain/accounts/entities/account-data-type.entity';
import type { IEncryptionApi } from '@/domain/interfaces/encryption-api.interface';
import type { IEncryptionApiManager } from '@/domain/interfaces/encryption-api.manager.interface';
import type { ILoggingService } from '@/logging/logging.interface';
import { faker } from '@faker-js/faker';
import type postgres from 'postgres';
Expand All @@ -26,6 +28,15 @@ const mockConfigurationService = jest.mocked({
getOrThrow: jest.fn(),
} as jest.MockedObjectDeep<IConfigurationService>);

const encryptionApiManagerMock = {
getApi: jest.fn(),
} as jest.MockedObjectDeep<IEncryptionApiManager>;

const encryptionApiMock = {
encrypt: jest.fn(),
decrypt: jest.fn(),
} as jest.MockedObjectDeep<IEncryptionApi>;

describe('AccountsDatasource tests', () => {
let fakeCacheService: FakeCacheService;
let sql: postgres.Sql;
Expand All @@ -48,7 +59,11 @@ describe('AccountsDatasource tests', () => {
new CachedQueryResolver(mockLoggingService, fakeCacheService),
mockLoggingService,
mockConfigurationService,
encryptionApiManagerMock,
);
encryptionApiManagerMock.getApi.mockResolvedValue(encryptionApiMock);
encryptionApiMock.encrypt.mockResolvedValue('encrypted');
encryptionApiMock.decrypt.mockResolvedValue('decrypted');
});

afterEach(async () => {
Expand All @@ -74,7 +89,7 @@ describe('AccountsDatasource tests', () => {
id: expect.any(Number),
group_id: null,
address: createAccountDto.address,
name: createAccountDto.name,
name: 'decrypted',
created_at: expect.any(Date),
updated_at: expect.any(Date),
});
Expand All @@ -88,7 +103,7 @@ describe('AccountsDatasource tests', () => {
id: expect.any(Number),
group_id: null,
address: createAccountDto.address,
name: createAccountDto.name,
name: 'encrypted',
}),
]),
);
Expand All @@ -106,7 +121,7 @@ describe('AccountsDatasource tests', () => {
id: expect.any(Number),
group_id: null,
address: createAccountDto.address,
name: createAccountDto.name,
name: 'decrypted',
created_at: expect.any(Date),
updated_at: expect.any(Date),
});
Expand All @@ -120,7 +135,7 @@ describe('AccountsDatasource tests', () => {
id: expect.any(Number),
group_id: null,
address: createAccountDto.address,
name: createAccountDto.name,
name: 'encrypted',
}),
]),
);
Expand Down Expand Up @@ -164,6 +179,7 @@ describe('AccountsDatasource tests', () => {
new CachedQueryResolver(mockLoggingService, fakeCacheService),
mockLoggingService,
mockConfigurationService,
encryptionApiManagerMock,
);

for (let i = 0; i < accountCreationRateLimitCalls; i++) {
Expand Down Expand Up @@ -205,6 +221,7 @@ describe('AccountsDatasource tests', () => {
new CachedQueryResolver(mockLoggingService, fakeCacheService),
mockLoggingService,
mockConfigurationService,
encryptionApiManagerMock,
);

for (let i = 0; i < accountsToCreate; i++) {
Expand Down Expand Up @@ -251,7 +268,7 @@ describe('AccountsDatasource tests', () => {
id: expect.any(Number),
group_id: null,
address: createAccountDto.address,
name: createAccountDto.name,
name: 'decrypted',
}),
);
});
Expand All @@ -270,7 +287,7 @@ describe('AccountsDatasource tests', () => {
id: expect.any(Number),
group_id: null,
address: createAccountDto.address,
name: createAccountDto.name,
name: 'decrypted',
}),
);
const cacheDir = new CacheDir(`account_${createAccountDto.address}`, '');
Expand All @@ -281,7 +298,7 @@ describe('AccountsDatasource tests', () => {
id: expect.any(Number),
group_id: null,
address: createAccountDto.address,
name: createAccountDto.name,
name: 'encrypted',
}),
]),
);
Expand Down Expand Up @@ -355,7 +372,7 @@ describe('AccountsDatasource tests', () => {
id: expect.any(Number),
group_id: null,
address: createAccountDto.address,
name: createAccountDto.name,
name: 'decrypted',
}),
);

Expand Down
47 changes: 34 additions & 13 deletions src/datasources/accounts/accounts.datasource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { CreateAccountDto } from '@/domain/accounts/entities/create-account.dto.
import { UpsertAccountDataSettingsDto } from '@/domain/accounts/entities/upsert-account-data-settings.dto.entity';
import { AccountsCreationRateLimitError } from '@/domain/accounts/errors/accounts-creation-rate-limit.error';
import { IAccountsDatasource } from '@/domain/interfaces/accounts.datasource.interface';
import { IEncryptionApiManager } from '@/domain/interfaces/encryption-api.manager.interface';
import { ILoggingService, LoggingService } from '@/logging/logging.interface';
import { asError } from '@/logging/utils';
import { IpSchema } from '@/validation/entities/schemas/ip.schema';
Expand Down Expand Up @@ -45,6 +46,8 @@ export class AccountsDatasource implements IAccountsDatasource, OnModuleInit {
@Inject(LoggingService) private readonly loggingService: ILoggingService,
@Inject(IConfigurationService)
private readonly configurationService: IConfigurationService,
@Inject(IEncryptionApiManager)
private readonly encryptionApiManager: IEncryptionApiManager,
) {
this.defaultExpirationTimeInSeconds =
this.configurationService.getOrThrow<number>(
Expand Down Expand Up @@ -83,28 +86,30 @@ export class AccountsDatasource implements IAccountsDatasource, OnModuleInit {
clientIp: string;
}): Promise<Account> {
await this.checkCreationRateLimit(args.clientIp);
const { address, name } = args.createAccountDto;
const hash = crypto.createHash('sha256');
hash.update(name);
const nameHash = hash.digest('hex');
// TODO: encrypt the name
const [account] = await this.sql<[Account]>`
const encryptedAccountData = await this.encryptAccountData(
args.createAccountDto,
);
const [dbAccount] = await this.sql<[Account]>`
INSERT INTO accounts (address, name, name_hash)
VALUES (${address}, ${name}, ${nameHash})
VALUES (${encryptedAccountData.address}, ${encryptedAccountData.name}, ${encryptedAccountData.nameHash})
RETURNING *
`.catch((e) => {
this.loggingService.warn(`Error creating account: ${asError(e).message}`);
throw new UnprocessableEntityException('Error creating account.');
});
const cacheDir = CacheRouter.getAccountCacheDir(address);
// TODO: decrypt the name
const result = omit({ ...account, name }, 'name_hash');
const cacheDir = CacheRouter.getAccountCacheDir(
args.createAccountDto.address,
);
const account = {
...dbAccount,
name: Buffer.from(dbAccount.name).toString('utf8'),
};
await this.cacheService.hSet(
cacheDir,
JSON.stringify([result]),
JSON.stringify([account]),
this.defaultExpirationTimeInSeconds,
);
return result;
return omit(await this.decryptAccountData(account), 'name_hash');
}

async getAccount(address: `0x${string}`): Promise<Account> {
Expand All @@ -119,7 +124,7 @@ export class AccountsDatasource implements IAccountsDatasource, OnModuleInit {
throw new NotFoundException('Error getting account.');
}

return account;
return this.decryptAccountData(account);
}

async deleteAccount(address: `0x${string}`): Promise<void> {
Expand Down Expand Up @@ -262,4 +267,20 @@ export class AccountsDatasource implements IAccountsDatasource, OnModuleInit {
}
}
}

async encryptAccountData(
createAccountDto: CreateAccountDto,
): Promise<{ address: `0x${string}`; name: string; nameHash: string }> {
const hash = crypto.createHash('sha256');
hash.update(createAccountDto.name);
const nameHash = hash.digest('hex');
const api = await this.encryptionApiManager.getApi();
const encryptedName = await api.encrypt(createAccountDto.name);
return { address: createAccountDto.address, name: encryptedName, nameHash };
}

async decryptAccountData(account: Account): Promise<Account> {
const api = await this.encryptionApiManager.getApi();
return { ...account, name: await api.decrypt(account.name) };
}
}
Loading

0 comments on commit 20e8be8

Please sign in to comment.