diff --git a/apps/api/migrations/encrypt-api-keys/encrypt-api-keys-migration.spec.ts b/apps/api/migrations/encrypt-api-keys/encrypt-api-keys-migration.spec.ts new file mode 100644 index 00000000000..9671f3fa5c5 --- /dev/null +++ b/apps/api/migrations/encrypt-api-keys/encrypt-api-keys-migration.spec.ts @@ -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); + }); +}); diff --git a/apps/api/migrations/encrypt-api-keys/encrypt-api-keys-migration.ts b/apps/api/migrations/encrypt-api-keys/encrypt-api-keys-migration.ts new file mode 100644 index 00000000000..0efcf935df3 --- /dev/null +++ b/apps/api/migrations/encrypt-api-keys/encrypt-api-keys-migration.ts @@ -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; +} diff --git a/apps/api/migrations/encrypt-credentials/encrypt-credentials-migration.spec.ts b/apps/api/migrations/encrypt-credentials/encrypt-credentials-migration.spec.ts index a38e6fe9c49..1db746189cb 100644 --- a/apps/api/migrations/encrypt-credentials/encrypt-credentials-migration.spec.ts +++ b/apps/api/migrations/encrypt-credentials/encrypt-credentials-migration.spec.ts @@ -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]; diff --git a/apps/api/migrations/encrypt-credentials/encrypt-credentials-migration.ts b/apps/api/migrations/encrypt-credentials/encrypt-credentials-migration.ts index e85b4106124..eb2e18abd90 100644 --- a/apps/api/migrations/encrypt-credentials/encrypt-credentials-migration.ts +++ b/apps/api/migrations/encrypt-credentials/encrypt-credentials-migration.ts @@ -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 @@ -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, } @@ -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; } diff --git a/apps/api/src/app/environments/dtos/environment-response.dto.ts b/apps/api/src/app/environments/dtos/environment-response.dto.ts index d59c97454f7..4b25f5a3834 100644 --- a/apps/api/src/app/environments/dtos/environment-response.dto.ts +++ b/apps/api/src/app/environments/dtos/environment-response.dto.ts @@ -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() @@ -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; +} diff --git a/apps/api/src/app/environments/e2e/get-api-keys.e2e.ts b/apps/api/src/app/environments/e2e/get-api-keys.e2e.ts new file mode 100644 index 00000000000..e6f68713983 --- /dev/null +++ b/apps/api/src/app/environments/e2e/get-api-keys.e2e.ts @@ -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); + }); +}); diff --git a/apps/api/src/app/environments/e2e/regenerate-api-keys.e2e.ts b/apps/api/src/app/environments/e2e/regenerate-api-keys.e2e.ts index 718d3d63bc9..135b372a644 100644 --- a/apps/api/src/app/environments/e2e/regenerate-api-keys.e2e.ts +++ b/apps/api/src/app/environments/e2e/regenerate-api-keys.e2e.ts @@ -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; @@ -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); diff --git a/apps/api/src/app/environments/usecases/create-environment/create-environment.e2e.ts b/apps/api/src/app/environments/usecases/create-environment/create-environment.e2e.ts index 59b349ae046..9ec017db7a3 100644 --- a/apps/api/src/app/environments/usecases/create-environment/create-environment.e2e.ts +++ b/apps/api/src/app/environments/usecases/create-environment/create-environment.e2e.ts @@ -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({ @@ -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); }); diff --git a/apps/api/src/app/environments/usecases/create-environment/create-environment.usecase.ts b/apps/api/src/app/environments/usecases/create-environment/create-environment.usecase.ts index 16ee9f735f7..882ac67f2ac 100644 --- a/apps/api/src/app/environments/usecases/create-environment/create-environment.usecase.ts +++ b/apps/api/src/app/environments/usecases/create-environment/create-environment.usecase.ts @@ -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'; @@ -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, @@ -29,8 +33,9 @@ export class CreateEnvironment { _parentId: command.parentEnvironmentId, apiKeys: [ { - key, + key: encryptedApiKey, _userId: command.userId, + hash: hashedApiKey, }, ], }); diff --git a/apps/api/src/app/environments/usecases/generate-unique-api-key/generate-unique-api-key.usecase.ts b/apps/api/src/app/environments/usecases/generate-unique-api-key/generate-unique-api-key.usecase.ts index 7c4886dafba..f5b2d81b5e2 100644 --- a/apps/api/src/app/environments/usecases/generate-unique-api-key/generate-unique-api-key.usecase.ts +++ b/apps/api/src/app/environments/usecases/generate-unique-api-key/generate-unique-api-key.usecase.ts @@ -1,6 +1,8 @@ +import * as hat from 'hat'; +import { createHash } from 'crypto'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; + import { EnvironmentRepository } from '@novu/dal'; -import * as hat from 'hat'; const API_KEY_GENERATION_MAX_RETRIES = 3; @@ -14,8 +16,7 @@ export class GenerateUniqueApiKey { let isApiKeyUsed = true; while (isApiKeyUsed) { apiKey = this.generateApiKey(); - const environment = await this.environmentRepository.findByApiKey(apiKey); - isApiKeyUsed = environment ? true : false; + isApiKeyUsed = await this.validateIsApiKeyUsed(apiKey); count += 1; if (count === API_KEY_GENERATION_MAX_RETRIES) { @@ -27,6 +28,17 @@ export class GenerateUniqueApiKey { return apiKey as string; } + private async validateIsApiKeyUsed(apiKey: string) { + const hashedApiKey = createHash('sha256').update(apiKey).digest('hex'); + + const environment = await this.environmentRepository.findByApiKey({ + key: apiKey, + hash: hashedApiKey, + }); + + return !!environment; + } + /** * Extracting the generation functionality so it can be stubbed for functional testing * diff --git a/apps/api/src/app/environments/usecases/get-api-keys/get-api-keys.usecase.ts b/apps/api/src/app/environments/usecases/get-api-keys/get-api-keys.usecase.ts index 708e022745e..bf212dba5c2 100644 --- a/apps/api/src/app/environments/usecases/get-api-keys/get-api-keys.usecase.ts +++ b/apps/api/src/app/environments/usecases/get-api-keys/get-api-keys.usecase.ts @@ -1,12 +1,23 @@ import { Injectable } from '@nestjs/common'; -import { IApiKey, EnvironmentRepository } from '@novu/dal'; + +import { EnvironmentRepository, IApiKey } from '@novu/dal'; + import { GetApiKeysCommand } from './get-api-keys.command'; +import { ApiKey } from '../../../shared/dtos/api-key'; +import { decryptApiKey } from '@novu/application-generic'; @Injectable() export class GetApiKeys { constructor(private environmentRepository: EnvironmentRepository) {} - async execute(command: GetApiKeysCommand): Promise { - return await this.environmentRepository.getApiKeys(command.environmentId); + async execute(command: GetApiKeysCommand): Promise { + const keys = await this.environmentRepository.getApiKeys(command.environmentId); + + return keys.map((apiKey: IApiKey) => { + return { + key: decryptApiKey(apiKey.key), + _userId: apiKey._userId, + }; + }); } } diff --git a/apps/api/src/app/environments/usecases/get-environment/get-environment.usecase.ts b/apps/api/src/app/environments/usecases/get-environment/get-environment.usecase.ts index dc7e1244dd0..86383d86f82 100644 --- a/apps/api/src/app/environments/usecases/get-environment/get-environment.usecase.ts +++ b/apps/api/src/app/environments/usecases/get-environment/get-environment.usecase.ts @@ -1,13 +1,16 @@ import { Injectable, NotFoundException } from '@nestjs/common'; -import { EnvironmentRepository } from '@novu/dal'; + +import { EnvironmentEntity, EnvironmentRepository } from '@novu/dal'; + import { GetEnvironmentCommand } from './get-environment.command'; +import { EnvironmentResponseDto } from '../../dtos/environment-response.dto'; @Injectable() export class GetEnvironment { constructor(private environmentRepository: EnvironmentRepository) {} - async execute(command: GetEnvironmentCommand) { - const environment = await this.environmentRepository.findOne( + async execute(command: GetEnvironmentCommand): Promise { + const environment: Omit | null = await this.environmentRepository.findOne( { _id: command.environmentId, _organizationId: command.organizationId, diff --git a/apps/api/src/app/environments/usecases/get-my-environments/get-my-environments.e2e.ts b/apps/api/src/app/environments/usecases/get-my-environments/get-my-environments.e2e.ts index 644ec7a5397..d32f240e0ac 100644 --- a/apps/api/src/app/environments/usecases/get-my-environments/get-my-environments.e2e.ts +++ b/apps/api/src/app/environments/usecases/get-my-environments/get-my-environments.e2e.ts @@ -1,5 +1,6 @@ import { UserSession } from '@novu/testing'; import { expect } from 'chai'; +import { NOVU_ENCRYPTION_SUB_MASK } from '@novu/shared'; describe('Get My Environments - /environments (GET)', async () => { let session: UserSession; @@ -19,6 +20,8 @@ describe('Get My Environments - /environments (GET)', async () => { if (elem._id !== session.environment._id) { expect(elem.apiKeys.length).to.eq(0); } else { + expect(elem.apiKeys[0].key).to.not.contains(NOVU_ENCRYPTION_SUB_MASK); + expect(elem.apiKeys[0]._userId).to.equal(session.user._id); expect(elem.apiKeys.length).to.be.greaterThanOrEqual(1); } } diff --git a/apps/api/src/app/environments/usecases/get-my-environments/get-my-environments.usecase.ts b/apps/api/src/app/environments/usecases/get-my-environments/get-my-environments.usecase.ts index cffe3da5e2c..0acaeb1d012 100644 --- a/apps/api/src/app/environments/usecases/get-my-environments/get-my-environments.usecase.ts +++ b/apps/api/src/app/environments/usecases/get-my-environments/get-my-environments.usecase.ts @@ -1,6 +1,10 @@ -import { Injectable, Logger, Scope } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException, Scope } from '@nestjs/common'; + import { EnvironmentEntity, EnvironmentRepository } from '@novu/dal'; +import { decryptApiKey } from '@novu/application-generic'; + import { GetMyEnvironmentsCommand } from './get-my-environments.command'; +import { EnvironmentResponseDto } from '../../dtos/environment-response.dto'; @Injectable({ scope: Scope.REQUEST, @@ -8,14 +12,17 @@ import { GetMyEnvironmentsCommand } from './get-my-environments.command'; export class GetMyEnvironments { constructor(private environmentRepository: EnvironmentRepository) {} - async execute(command: GetMyEnvironmentsCommand): Promise { + async execute(command: GetMyEnvironmentsCommand): Promise { Logger.verbose('Getting Environments'); const environments = await this.environmentRepository.findOrganizationEnvironments(command.organizationId); + if (!environments?.length) + throw new NotFoundException(`Environments for organization ${command.organizationId} not found`); + return environments.map((environment) => { if (environment._id === command.environmentId) { - return environment; + return this.decryptApiKeys(environment); } environment.apiKeys = []; @@ -23,4 +30,17 @@ export class GetMyEnvironments { return environment; }); } + + private decryptApiKeys(environment: EnvironmentEntity): EnvironmentResponseDto { + const decryptedApiKeysEnvironment = { ...environment }; + + decryptedApiKeysEnvironment.apiKeys = environment.apiKeys.map((apiKey) => { + return { + ...apiKey, + key: decryptApiKey(apiKey.key), + }; + }); + + return decryptedApiKeysEnvironment; + } } diff --git a/apps/api/src/app/environments/usecases/regenerate-api-keys/regenerate-api-keys.usecase.ts b/apps/api/src/app/environments/usecases/regenerate-api-keys/regenerate-api-keys.usecase.ts index d7a59bb1ed8..91013dd775f 100644 --- a/apps/api/src/app/environments/usecases/regenerate-api-keys/regenerate-api-keys.usecase.ts +++ b/apps/api/src/app/environments/usecases/regenerate-api-keys/regenerate-api-keys.usecase.ts @@ -1,8 +1,13 @@ +import { createHash } from 'crypto'; import { Injectable } from '@nestjs/common'; -import { IApiKey, EnvironmentRepository } from '@novu/dal'; + +import { EnvironmentRepository } from '@novu/dal'; +import { decryptApiKey, encryptApiKey } from '@novu/application-generic'; + import { ApiException } from '../../../shared/exceptions/api.exception'; import { GenerateUniqueApiKey } from '../generate-unique-api-key/generate-unique-api-key.usecase'; import { GetApiKeysCommand } from '../get-api-keys/get-api-keys.command'; +import { IApiKeyDto } from '../../dtos/environment-response.dto'; @Injectable() export class RegenerateApiKeys { @@ -11,7 +16,7 @@ export class RegenerateApiKeys { private generateUniqueApiKey: GenerateUniqueApiKey ) {} - async execute(command: GetApiKeysCommand): Promise { + async execute(command: GetApiKeysCommand): Promise { const environment = await this.environmentRepository.findOne({ _id: command.environmentId }); if (!environment) { @@ -19,7 +24,21 @@ export class RegenerateApiKeys { } const key = await this.generateUniqueApiKey.execute(); + const encryptedApiKey = encryptApiKey(key); + const hashedApiKey = createHash('sha256').update(key).digest('hex'); + + const environments = await this.environmentRepository.updateApiKey( + command.environmentId, + encryptedApiKey, + command.userId, + hashedApiKey + ); - return await this.environmentRepository.updateApiKey(command.environmentId, key, command.userId); + return environments.map((item) => { + return { + _userId: item._userId, + key: decryptApiKey(item.key), + }; + }); } } diff --git a/apps/api/src/app/user/usecases/update-profile-email/update-profile-email.usecase.ts b/apps/api/src/app/user/usecases/update-profile-email/update-profile-email.usecase.ts index 53c516ae6ba..2219ad0ef9f 100644 --- a/apps/api/src/app/user/usecases/update-profile-email/update-profile-email.usecase.ts +++ b/apps/api/src/app/user/usecases/update-profile-email/update-profile-email.usecase.ts @@ -1,7 +1,13 @@ import { BadRequestException, forwardRef, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { UserRepository } from '@novu/dal'; -import { AnalyticsService, buildAuthServiceKey, buildUserKey, InvalidateCacheService } from '@novu/application-generic'; +import { + AnalyticsService, + buildAuthServiceKey, + buildUserKey, + decryptApiKey, + InvalidateCacheService, +} from '@novu/application-generic'; import { EnvironmentRepository } from '@novu/dal'; import { UpdateProfileEmailCommand } from './update-profile-email.command'; @@ -40,9 +46,12 @@ export class UpdateProfileEmail { }); const apiKeys = await this.environmentRepository.getApiKeys(command.environmentId); + + const decryptedApiKey = decryptApiKey(apiKeys[0].key); + await this.invalidateCache.invalidateByKey({ key: buildAuthServiceKey({ - apiKey: apiKeys[0].key, + apiKey: decryptedApiKey, }), }); diff --git a/libs/dal/src/repositories/environment/environment.entity.ts b/libs/dal/src/repositories/environment/environment.entity.ts index 76e35281381..e575d49c90b 100644 --- a/libs/dal/src/repositories/environment/environment.entity.ts +++ b/libs/dal/src/repositories/environment/environment.entity.ts @@ -1,11 +1,18 @@ import { Types } from 'mongoose'; +import { EncryptedSecret, IApiRateLimitMaximum } from '@novu/shared'; + import type { OrganizationId } from '../organization'; import type { ChangePropsValueType } from '../../types/helpers'; -import { IApiRateLimitMaximum } from '@novu/shared'; export interface IApiKey { - key: string; + /* + * backward compatibility - + * remove `string` type after encrypt-api-keys-migration run + * remove the optional from hash + */ + key: EncryptedSecret | string; + hash?: string; _userId: string; } diff --git a/libs/dal/src/repositories/environment/environment.repository.ts b/libs/dal/src/repositories/environment/environment.repository.ts index c8c94f9e696..2ac423c7ea7 100644 --- a/libs/dal/src/repositories/environment/environment.repository.ts +++ b/libs/dal/src/repositories/environment/environment.repository.ts @@ -1,4 +1,4 @@ -import { IApiRateLimitMaximum } from '@novu/shared'; +import { EncryptedSecret, IApiRateLimitMaximum } from '@novu/shared'; import { BaseRepository } from '../base-repository'; import { IApiKey, EnvironmentEntity, EnvironmentDBModel } from './environment.entity'; import { Environment } from './environment.schema'; @@ -35,7 +35,7 @@ export class EnvironmentRepository extends BaseRepository { @@ -69,7 +68,7 @@ export class EnvironmentRepository extends BaseRepository( type: Schema.Types.String, unique: true, }, + hash: Schema.Types.String, _userId: { type: Schema.Types.ObjectId, ref: 'User', diff --git a/libs/shared/src/types/shared/index.ts b/libs/shared/src/types/shared/index.ts index 3e6c15724c3..183f657b79b 100644 --- a/libs/shared/src/types/shared/index.ts +++ b/libs/shared/src/types/shared/index.ts @@ -1 +1,5 @@ export type CustomDataType = { [key: string]: string | string[] | boolean | number | undefined }; + +export const NOVU_ENCRYPTION_SUB_MASK = 'nvsk.'; + +export type EncryptedSecret = `${typeof NOVU_ENCRYPTION_SUB_MASK}${string}`; diff --git a/packages/application-generic/src/encryption/encrypt-provider.spec.ts b/packages/application-generic/src/encryption/encrypt-provider.spec.ts index e6080edd9ec..688fa12dd19 100644 --- a/packages/application-generic/src/encryption/encrypt-provider.spec.ts +++ b/packages/application-generic/src/encryption/encrypt-provider.spec.ts @@ -1,8 +1,8 @@ import { decryptCredentials, - decryptProviderSecret, + decryptSecret, encryptCredentials, - encryptProviderSecret, + encryptSecret, } from './encrypt-provider'; import { ICredentialsDto } from '@novu/shared'; @@ -11,7 +11,7 @@ describe('Encrypt provider secrets', function () { it('should encrypt provider secret', async function () { const password = '1234'; - const encrypted = encryptProviderSecret(password); + const encrypted = encryptSecret(password); expect(encrypted).toContain(novuSubMask); expect(encrypted).not.toEqual(password); @@ -20,8 +20,8 @@ describe('Encrypt provider secrets', function () { it('should decrypt provider secret', async function () { const password = '123'; - const encrypted = encryptProviderSecret(password); - const decrypted = decryptProviderSecret(encrypted); + const encrypted = encryptSecret(password); + const decrypted = decryptSecret(encrypted); expect(decrypted).toEqual(password); }); diff --git a/packages/application-generic/src/encryption/encrypt-provider.ts b/packages/application-generic/src/encryption/encrypt-provider.ts index cbe1e9e8d11..03b160d86cf 100644 --- a/packages/application-generic/src/encryption/encrypt-provider.ts +++ b/packages/application-generic/src/encryption/encrypt-provider.ts @@ -1,20 +1,23 @@ -import { ICredentialsDto, secureCredentials } from '@novu/shared'; +import { + EncryptedSecret, + ICredentialsDto, + NOVU_ENCRYPTION_SUB_MASK, + secureCredentials, +} from '@novu/shared'; import { decrypt, encrypt } from './cipher'; -const NOVU_SUB_MASK = 'nvsk.'; - -export function encryptProviderSecret(text: string): string { +export function encryptSecret(text: string): EncryptedSecret { const encrypted = encrypt(text); - return NOVU_SUB_MASK + encrypted; + return `${NOVU_ENCRYPTION_SUB_MASK}${encrypted}`; } -export function decryptProviderSecret(text: string): string { +export function decryptSecret(text: string | EncryptedSecret): string { let encryptedSecret = text; - if (isEncryptedCredential(text)) { - encryptedSecret = text.slice(NOVU_SUB_MASK.length); + if (isNovuEncrypted(text)) { + encryptedSecret = text.slice(NOVU_ENCRYPTION_SUB_MASK.length); } return decrypt(encryptedSecret); @@ -26,8 +29,8 @@ export function encryptCredentials( const encryptedCredentials: ICredentialsDto = {}; for (const key in credentials) { - encryptedCredentials[key] = needEncryption(key) - ? encryptProviderSecret(credentials[key]) + encryptedCredentials[key] = isCredentialEncryptionRequired(key) + ? encryptSecret(credentials[key]) : credentials[key]; } @@ -41,19 +44,34 @@ export function decryptCredentials( for (const key in credentials) { decryptedCredentials[key] = - typeof credentials[key] === 'string' && - isEncryptedCredential(credentials[key]) - ? decryptProviderSecret(credentials[key]) + typeof credentials[key] === 'string' && isNovuEncrypted(credentials[key]) + ? decryptSecret(credentials[key]) : credentials[key]; } return decryptedCredentials; } -function isEncryptedCredential(text: string): boolean { - return text.startsWith(NOVU_SUB_MASK); +export function encryptApiKey(apiKey: string): EncryptedSecret { + if (isNovuEncrypted(apiKey)) { + return apiKey; + } + + return encryptSecret(apiKey); +} + +export function decryptApiKey(apiKey: string): string { + if (isNovuEncrypted(apiKey)) { + return decryptSecret(apiKey); + } + + return apiKey; +} + +function isNovuEncrypted(text: string): text is EncryptedSecret { + return text.startsWith(NOVU_ENCRYPTION_SUB_MASK); } -function needEncryption(key: string): boolean { +function isCredentialEncryptionRequired(key: string): boolean { return secureCredentials.some((secureCred) => secureCred === key); } diff --git a/packages/application-generic/src/services/auth/auth.service.ts b/packages/application-generic/src/services/auth/auth.service.ts index 06c6a5b4145..7c00311deea 100644 --- a/packages/application-generic/src/services/auth/auth.service.ts +++ b/packages/application-generic/src/services/auth/auth.service.ts @@ -1,3 +1,4 @@ +import { createHash } from 'crypto'; import { forwardRef, Inject, @@ -17,7 +18,6 @@ import { UserEntity, UserRepository, EnvironmentEntity, - IApiKey, } from '@novu/dal'; import { AuthProviderEnum, @@ -194,12 +194,9 @@ export class AuthService { @Instrument() public async validateApiKey(apiKey: string): Promise { - const { - environment, - user, - error, - key, // In the future, roles/scopes will be assigned to the API Key. - } = await this.getApiKeyUser({ apiKey }); + const { environment, user, error } = await this.getApiKeyUser({ + apiKey, + }); if (error) throw new UnauthorizedException(error); @@ -387,15 +384,31 @@ export class AuthService { private async getApiKeyUser({ apiKey }: { apiKey: string }): Promise<{ environment?: EnvironmentEntity; user?: UserEntity; - key?: IApiKey; error?: string; }> { - const environment = await this.environmentRepository.findByApiKey(apiKey); + const hashedApiKey = createHash('sha256').update(apiKey).digest('hex'); + + const environment = await this.environmentRepository.findByApiKey({ + key: apiKey, + hash: hashedApiKey, + }); + if (!environment) { + // Failed to find the environment for the provided API key. return { error: 'API Key not found' }; } - const key = environment.apiKeys.find((i) => i.key === apiKey); + let key = environment.apiKeys.find((i) => i.hash === hashedApiKey); + + if (!key) { + /* + * backward compatibility - delete after encrypt-api-keys-migration execution + * find by decrypted key if key not found, because of backward compatibility + * use-case: findByApiKey found by decrypted key, so we need to validate by decrypted key + */ + key = environment.apiKeys.find((i) => i.key === apiKey); + } + if (!key) { return { error: 'API Key not found' }; } @@ -405,6 +418,6 @@ export class AuthService { return { error: 'User not found' }; } - return { environment, user, key }; + return { environment, user }; } } diff --git a/packages/application-generic/src/services/cache/key-builders/entities.ts b/packages/application-generic/src/services/cache/key-builders/entities.ts index 0521d2cd2b3..af4c2a86007 100644 --- a/packages/application-generic/src/services/cache/key-builders/entities.ts +++ b/packages/application-generic/src/services/cache/key-builders/entities.ts @@ -9,6 +9,7 @@ import { prefixWrapper, ServiceConfigIdentifierEnum, } from './shared'; +import { createHash as createHashCrypto } from 'crypto'; const buildSubscriberKey = ({ subscriberId, @@ -80,13 +81,23 @@ const buildGroupedBlueprintsKey = (): string => identifier: BLUEPRINT_IDENTIFIER, }); -const buildAuthServiceKey = ({ apiKey }: { apiKey: string }): string => - buildKeyById({ +const createHash = (apiKey: string): string => { + const hash = createHashCrypto('sha256'); + hash.update(apiKey); + + return hash.digest('hex'); +}; + +const buildAuthServiceKey = ({ apiKey }: { apiKey: string }): string => { + const apiKeyHash = createHash(apiKey); + + return buildKeyById({ type: CacheKeyTypeEnum.ENTITY, keyEntity: CacheKeyPrefixEnum.AUTH_SERVICE, - identifier: apiKey, + identifier: apiKeyHash, identifierPrefix: IdentifierPrefixEnum.API_KEY, }); +}; const buildMaximumApiRateLimitKey = ({ apiRateLimitCategory, diff --git a/packages/node/src/lib/changes/changes.spec.ts b/packages/node/src/lib/changes/changes.spec.ts index a9fb40870ec..632d3bff81e 100644 --- a/packages/node/src/lib/changes/changes.spec.ts +++ b/packages/node/src/lib/changes/changes.spec.ts @@ -27,7 +27,11 @@ describe('test use of novus node package - Changes class', () => { expect(mockedAxios.get).toHaveBeenCalled(); expect(mockedAxios.get).toHaveBeenCalledWith('/changes', { - params: { limit: 20, page: 1, promoted: false }, + params: { + limit: 20, + page: 1, + promoted: false, + }, }); });