Skip to content

Commit

Permalink
Merge pull request #5046 from novuhq/nv-3172-add-field-level-encrypti…
Browse files Browse the repository at this point in the history
…on-to-api-keys

Add field-level encryption to API Keys
  • Loading branch information
djabarovgeorge authored Jan 26, 2024
2 parents e90a9c2 + 7fe814d commit 046c0ad
Show file tree
Hide file tree
Showing 25 changed files with 444 additions and 80 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { expect } from 'chai';
import { faker } from '@faker-js/faker';
import { createHash } from 'crypto';

import { UserSession } from '@novu/testing';
import { ChannelTypeEnum } from '@novu/stateless';
import { EnvironmentRepository } from '@novu/dal';
import { decryptApiKey } from '@novu/application-generic';

import { encryptApiKeysMigration } from './encrypt-api-keys-migration';

async function pruneIntegration({ environmentRepository }: { environmentRepository: EnvironmentRepository }) {
const old = await environmentRepository.find({});

for (const oldKey of old) {
await environmentRepository.delete({ _id: oldKey._id });
}
}

describe('Encrypt Old api keys', function () {
let session: UserSession;
const environmentRepository = new EnvironmentRepository();

beforeEach(async () => {
session = new UserSession();
await session.initialize();
});

it('should decrypt all old api keys', async function () {
await pruneIntegration({ environmentRepository });

for (let i = 0; i < 2; i++) {
await environmentRepository.create({
identifier: 'identifier' + i,
name: faker.name.jobTitle(),
_organizationId: session.organization._id,
apiKeys: [
{
key: 'not-encrypted-secret-key',
_userId: session.user._id,
},
],
});
}

const newEnvironments = await environmentRepository.find({});

expect(newEnvironments.length).to.equal(2);

for (const environment of newEnvironments) {
expect(environment.identifier).to.contains('identifier');
expect(environment.name).to.exist;
expect(environment._organizationId).to.equal(session.organization._id);
expect(environment.apiKeys[0].key).to.equal('not-encrypted-secret-key');
expect(environment.apiKeys[0].hash).to.not.exist;
expect(environment.apiKeys[0]._userId).to.equal(session.user._id);
}

await encryptApiKeysMigration();

const encryptEnvironments = await environmentRepository.find({});

for (const environment of encryptEnvironments) {
const decryptedApiKey = decryptApiKey(environment.apiKeys[0].key);
const hashedApiKey = createHash('sha256').update(decryptedApiKey).digest('hex');

expect(environment.identifier).to.contains('identifier');
expect(environment.name).to.exist;
expect(environment._organizationId).to.equal(session.organization._id);
expect(environment.apiKeys[0].key).to.contains('nvsk.');
expect(environment.apiKeys[0].hash).to.equal(hashedApiKey);
expect(environment.apiKeys[0]._userId).to.equal(session.user._id);
}
});

it('should validate migration idempotence', async function () {
await pruneIntegration({ environmentRepository });

const data = {
providerId: 'sendgrid',
channel: ChannelTypeEnum.EMAIL,
active: false,
};

for (let i = 0; i < 2; i++) {
await environmentRepository.create({
identifier: 'identifier' + i,
name: faker.name.jobTitle(),
_organizationId: session.organization._id,
apiKeys: [
{
key: 'not-encrypted-secret-key',
_userId: session.user._id,
},
],
});
}

await encryptApiKeysMigration();
const firstMigrationExecution = await environmentRepository.find({});

await encryptApiKeysMigration();
const secondMigrationExecution = await environmentRepository.find({});

expect(firstMigrationExecution[0].identifier).to.contains(secondMigrationExecution[0].identifier);
expect(firstMigrationExecution[0].name).to.exist;
expect(firstMigrationExecution[0]._organizationId).to.equal(secondMigrationExecution[0]._organizationId);
expect(firstMigrationExecution[0].apiKeys[0].key).to.contains(secondMigrationExecution[0].apiKeys[0].key);
expect(firstMigrationExecution[0].apiKeys[0].hash).to.contains(secondMigrationExecution[0].apiKeys[0].hash);
expect(firstMigrationExecution[0].apiKeys[0]._userId).to.equal(secondMigrationExecution[0].apiKeys[0]._userId);

expect(firstMigrationExecution[1].identifier).to.contains(secondMigrationExecution[1].identifier);
expect(firstMigrationExecution[1].name).to.exist;
expect(firstMigrationExecution[1]._organizationId).to.equal(secondMigrationExecution[1]._organizationId);
expect(firstMigrationExecution[1].apiKeys[0].key).to.contains(secondMigrationExecution[1].apiKeys[0].key);
expect(firstMigrationExecution[1].apiKeys[0].hash).to.contains(secondMigrationExecution[1].apiKeys[0].hash);
expect(firstMigrationExecution[1].apiKeys[0]._userId).to.equal(secondMigrationExecution[1].apiKeys[0]._userId);
});
});
70 changes: 70 additions & 0 deletions apps/api/migrations/encrypt-api-keys/encrypt-api-keys-migration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { EnvironmentRepository, IApiKey } from '@novu/dal';
import { encryptSecret } from '@novu/application-generic';
import { EncryptedSecret } from '@novu/shared';
import { createHash } from 'crypto';

export async function encryptApiKeysMigration() {
// eslint-disable-next-line no-console
console.log('start migration - encrypt api keys');

const environmentRepository = new EnvironmentRepository();
const environments = await environmentRepository.find({});

for (const environment of environments) {
// eslint-disable-next-line no-console
console.log(`environment ${environment._id}`);

if (!environment.apiKeys) {
// eslint-disable-next-line no-console
console.log(`environment ${environment._id} - is not contains api keys, skipping..`);
continue;
}

if (
environment.apiKeys.every((key) => {
isEncrypted(key.key);
})
) {
// eslint-disable-next-line no-console
console.log(`environment ${environment._id} - api keys are already encrypted, skipping..`);
continue;
}

const updatePayload: IEncryptedApiKey[] = encryptApiKeysWithGuard(environment.apiKeys);

await environmentRepository.update(
{ _id: environment._id },
{
$set: { apiKeys: updatePayload },
}
);
// eslint-disable-next-line no-console
console.log(`environment ${environment._id} - api keys updated`);
}
// eslint-disable-next-line no-console
console.log('end migration');
}

export function encryptApiKeysWithGuard(apiKeys: IApiKey[]): IEncryptedApiKey[] {
return apiKeys.map((apiKey: IApiKey) => {
const hashedApiKey = createHash('sha256').update(apiKey.key).digest('hex');

const encryptedApiKey: IEncryptedApiKey = {
hash: apiKey?.hash ? apiKey?.hash : hashedApiKey,
key: isEncrypted(apiKey.key) ? apiKey.key : encryptSecret(apiKey.key),
_userId: apiKey._userId,
};

return encryptedApiKey;
});
}

function isEncrypted(apiKey: string): apiKey is EncryptedSecret {
return apiKey.startsWith('nvsk.');
}

export interface IEncryptedApiKey {
key: EncryptedSecret;
_userId: string;
hash: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,13 @@ describe('Encrypt Old Credentials', function () {
});
}

const newIntegration = await integrationRepository.find({});
const newIntegration = await integrationRepository.find({} as any);

expect(newIntegration.length).to.equal(2);

await encryptOldCredentialsMigration();

const encryptIntegration = await integrationRepository.find({});
const encryptIntegration = await integrationRepository.find({} as any);

for (const integrationKey in encryptIntegration) {
const integration = encryptIntegration[integrationKey];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { IntegrationEntity } from '@novu/dal';
import { encryptProviderSecret } from '../../src/app/shared/services/encryption';
import { IntegrationRepository } from '@novu/dal';
import { ICredentialsDto, secureCredentials } from '@novu/shared';
import { encryptSecret } from '@novu/application-generic';

export async function encryptOldCredentialsMigration() {
// eslint-disable-next-line no-console
console.log('start migration - encrypt credentials');

const integrationRepository = new IntegrationRepository();
const integrations = await integrationRepository.find({});
const integrations = await integrationRepository.find({} as any);

for (const integration of integrations) {
// eslint-disable-next-line no-console
Expand All @@ -25,7 +25,7 @@ export async function encryptOldCredentialsMigration() {
updatePayload.credentials = encryptCredentialsWithGuard(integration);

await integrationRepository.update(
{ _id: integration._id },
{ _id: integration._id, _environmentId: integration._environmentId },
{
$set: updatePayload,
}
Expand All @@ -45,7 +45,7 @@ export function encryptCredentialsWithGuard(integration: IntegrationEntity): ICr
const credential = credentials[key];

if (needEncryption(key, credential, integration)) {
encryptedCredentials[key] = encryptProviderSecret(credential);
encryptedCredentials[key] = encryptSecret(credential);
} else {
encryptedCredentials[key] = credential;
}
Expand Down
14 changes: 8 additions & 6 deletions apps/api/src/app/environments/dtos/environment-response.dto.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IApiKey } from '@novu/dal';
import { ApiKey } from '../../shared/dtos/api-key';

export class EnvironmentResponseDto {
@ApiPropertyOptional()
Expand All @@ -15,11 +13,15 @@ export class EnvironmentResponseDto {
@ApiProperty()
identifier: string;

@ApiProperty({
type: [ApiKey],
})
apiKeys: IApiKey[];
@ApiPropertyOptional()
apiKeys?: IApiKeyDto[];

@ApiProperty()
_parentId: string;
}

export interface IApiKeyDto {
key: string;
_userId: string;
hash?: string;
}
23 changes: 23 additions & 0 deletions apps/api/src/app/environments/e2e/get-api-keys.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
import { NOVU_ENCRYPTION_SUB_MASK } from '@novu/shared';

describe('Get Environment API Keys - /environments/api-keys (GET)', async () => {
let session: UserSession;
before(async () => {
session = new UserSession();
await session.initialize({});
});

it('should get environment api keys correctly', async () => {
const demoEnvironment = {
name: 'Hello App',
};
await session.testAgent.post('/v1/environments').send(demoEnvironment).expect(201);

const { body } = await session.testAgent.get('/v1/environments/api-keys').send();

expect(body.data[0].key).to.not.contains(NOVU_ENCRYPTION_SUB_MASK);
expect(body.data[0]._userId).to.equal(session.user._id);
});
});
3 changes: 3 additions & 0 deletions apps/api/src/app/environments/e2e/regenerate-api-keys.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
import { NOVU_ENCRYPTION_SUB_MASK } from '@novu/shared';

describe('Environment - Regenerate Api Key', async () => {
let session: UserSession;
Expand All @@ -14,11 +15,13 @@ describe('Environment - Regenerate Api Key', async () => {
body: { data: oldApiKeys },
} = await session.testAgent.get('/v1/environments/api-keys').send({});
const oldApiKey = oldApiKeys[0].key;
expect(oldApiKey).to.not.contains(NOVU_ENCRYPTION_SUB_MASK);

const {
body: { data: newApiKeys },
} = await session.testAgent.post('/v1/environments/api-keys/regenerate').send({});
const newApiKey = newApiKeys[0].key;
expect(newApiKey).to.not.contains(NOVU_ENCRYPTION_SUB_MASK);

expect(oldApiKey).to.not.equal(newApiKey);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { EnvironmentRepository, LayoutRepository } from '@novu/dal';
import { UserSession } from '@novu/testing';
import { expect } from 'chai';

import { EnvironmentRepository } from '@novu/dal';
import { UserSession } from '@novu/testing';
import { NOVU_ENCRYPTION_SUB_MASK } from '@novu/shared';

describe('Create Environment - /environments (POST)', async () => {
let session: UserSession;
const environmentRepository = new EnvironmentRepository();
const layoutRepository = new LayoutRepository();
before(async () => {
session = new UserSession();
await session.initialize({
Expand All @@ -24,8 +25,14 @@ describe('Create Environment - /environments (POST)', async () => {
expect(body.data.identifier).to.be.ok;
const dbApp = await environmentRepository.findOne({ _id: body.data._id });

if (!dbApp) {
expect(dbApp).to.be.ok;
throw new Error('App not found');
}

expect(dbApp.apiKeys.length).to.equal(1);
expect(dbApp.apiKeys[0].key).to.be.ok;
expect(dbApp.apiKeys[0].key).to.contains(NOVU_ENCRYPTION_SUB_MASK);
expect(dbApp.apiKeys[0]._userId).to.equal(session.user._id);
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { nanoid } from 'nanoid';
import { Injectable } from '@nestjs/common';
import { createHash } from 'crypto';

import { EnvironmentRepository } from '@novu/dal';
import { nanoid } from 'nanoid';
import { encryptApiKey } from '@novu/application-generic';

import { CreateEnvironmentCommand } from './create-environment.command';

import { GenerateUniqueApiKey } from '../generate-unique-api-key/generate-unique-api-key.usecase';
// eslint-disable-next-line max-len
import { CreateNotificationGroupCommand } from '../../../notification-groups/usecases/create-notification-group/create-notification-group.command';
Expand All @@ -21,6 +23,8 @@ export class CreateEnvironment {

async execute(command: CreateEnvironmentCommand) {
const key = await this.generateUniqueApiKey.execute();
const encryptedApiKey = encryptApiKey(key);
const hashedApiKey = createHash('sha256').update(key).digest('hex');

const environment = await this.environmentRepository.create({
_organizationId: command.organizationId,
Expand All @@ -29,8 +33,9 @@ export class CreateEnvironment {
_parentId: command.parentEnvironmentId,
apiKeys: [
{
key,
key: encryptedApiKey,
_userId: command.userId,
hash: hashedApiKey,
},
],
});
Expand Down
Loading

0 comments on commit 046c0ad

Please sign in to comment.