From 9eeebf9614081860f1ec99ac7cd6f0b4f5c7c340 Mon Sep 17 00:00:00 2001 From: William Calderipe Date: Wed, 13 Mar 2024 17:04:19 +0100 Subject: [PATCH] Engine provision (#166) --- README.md | 26 +-- apps/policy-engine/Makefile | 9 + apps/policy-engine/README.md | 6 + .../src/app/__test__/e2e/tenant.spec.ts | 12 +- apps/policy-engine/src/app/app.module.ts | 22 +- .../__test__/unit/bootstrap.service.spec.ts | 20 ++ .../__test__/unit/tenant.service.spec.ts | 13 +- .../src/app/core/service/bootstrap.service.ts | 25 +- .../src/app/core/service/engine.service.ts | 18 +- .../src/app/core/service/provision.service.ts | 65 ++++++ .../__test__/unit/engine.repository.spec.ts | 22 +- .../__test__/unit/tenant.repository.spec.ts | 32 +-- .../repository/engine.repository.ts | 12 +- .../repository/tenant.repository.ts | 28 +-- apps/policy-engine/src/cli.ts | 8 + apps/policy-engine/src/cli/cli.module.ts | 9 + .../src/cli/command/provision.command.ts | 49 ++++ .../integration/encryption.service.spec.ts | 79 ------- .../__test__/unit/encryption.service.spec.ts | 90 -------- .../src/encryption/core/encryption.service.ts | 182 --------------- .../src/encryption/encryption.module.ts | 22 -- .../repository/encryption.repository.ts | 39 ---- .../policy-engine/src/policy-engine.config.ts | 6 +- .../src/policy-engine.constant.ts | 6 + .../encryption-module-option.factory.ts | 58 +++++ .../__test__/unit/admin-api-key.guard.spec.ts | 11 +- .../src/shared/guard/admin-api-key.guard.ts | 2 +- .../encrypt-key-value.service.spec.ts | 52 +++++ .../integration/key-value.service.spec.ts | 21 +- .../core/service/encrypt-key-value.service.ts | 43 ++++ .../core/service/key-value.service.ts | 23 -- .../module/key-value/key-value.module.ts | 27 ++- .../repository/prisma-key-value.repository.ts | 46 ++++ .../migration.sql | 8 + .../module/persistence/schema/schema.prisma | 9 + .../src/shared/schema/engine.schema.ts | 6 +- .../src/shared/testing/encryption.testing.ts | 14 ++ package-lock.json | 218 +++++++++++++++++- package.json | 1 + packages/encryption-module/.eslintrc.json | 18 ++ packages/encryption-module/README.md | 38 +++ packages/encryption-module/jest.config.ts | 11 + packages/encryption-module/project.json | 23 ++ packages/encryption-module/src/index.ts | 6 + .../__test__/unit/encryption.module.spec.ts | 22 ++ .../__test__/unit/encryption.service.spec.ts | 45 ++++ .../lib/__test__/unit/encryption.util.spec.ts | 20 ++ .../src/lib/encryption.constant.ts | 10 + .../src/lib/encryption.exception.ts | 1 + .../src/lib/encryption.module-definition.ts | 6 + .../src/lib/encryption.module.ts | 11 + .../src/lib/encryption.service.ts | 49 ++++ .../src/lib/encryption.type.ts | 7 + .../src/lib/encryption.util.ts | 74 ++++++ packages/encryption-module/tsconfig.json | 23 ++ packages/encryption-module/tsconfig.lib.json | 16 ++ packages/encryption-module/tsconfig.spec.json | 9 + tsconfig.base.json | 1 + 58 files changed, 1139 insertions(+), 590 deletions(-) create mode 100644 apps/policy-engine/src/app/core/service/provision.service.ts create mode 100644 apps/policy-engine/src/cli.ts create mode 100644 apps/policy-engine/src/cli/cli.module.ts create mode 100644 apps/policy-engine/src/cli/command/provision.command.ts delete mode 100644 apps/policy-engine/src/encryption/core/__test__/integration/encryption.service.spec.ts delete mode 100644 apps/policy-engine/src/encryption/core/__test__/unit/encryption.service.spec.ts delete mode 100644 apps/policy-engine/src/encryption/core/encryption.service.ts delete mode 100644 apps/policy-engine/src/encryption/encryption.module.ts delete mode 100644 apps/policy-engine/src/encryption/persistence/repository/encryption.repository.ts create mode 100644 apps/policy-engine/src/shared/factory/encryption-module-option.factory.ts create mode 100644 apps/policy-engine/src/shared/module/key-value/core/service/__test__/integration/encrypt-key-value.service.spec.ts create mode 100644 apps/policy-engine/src/shared/module/key-value/core/service/encrypt-key-value.service.ts create mode 100644 apps/policy-engine/src/shared/module/key-value/persistence/repository/prisma-key-value.repository.ts rename apps/policy-engine/src/shared/module/persistence/schema/migrations/{20240301204146_init => 20240312112602_init}/migration.sql (52%) create mode 100644 apps/policy-engine/src/shared/testing/encryption.testing.ts create mode 100644 packages/encryption-module/.eslintrc.json create mode 100644 packages/encryption-module/README.md create mode 100644 packages/encryption-module/jest.config.ts create mode 100644 packages/encryption-module/project.json create mode 100644 packages/encryption-module/src/index.ts create mode 100644 packages/encryption-module/src/lib/__test__/unit/encryption.module.spec.ts create mode 100644 packages/encryption-module/src/lib/__test__/unit/encryption.service.spec.ts create mode 100644 packages/encryption-module/src/lib/__test__/unit/encryption.util.spec.ts create mode 100644 packages/encryption-module/src/lib/encryption.constant.ts create mode 100644 packages/encryption-module/src/lib/encryption.exception.ts create mode 100644 packages/encryption-module/src/lib/encryption.module-definition.ts create mode 100644 packages/encryption-module/src/lib/encryption.module.ts create mode 100644 packages/encryption-module/src/lib/encryption.service.ts create mode 100644 packages/encryption-module/src/lib/encryption.type.ts create mode 100644 packages/encryption-module/src/lib/encryption.util.ts create mode 100644 packages/encryption-module/tsconfig.json create mode 100644 packages/encryption-module/tsconfig.lib.json create mode 100644 packages/encryption-module/tsconfig.spec.json diff --git a/README.md b/README.md index 3c6e4446f..f4289297f 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,18 @@

Narval logo

-

Narval is the most advanced and secure authorization stack for web3.

-

-@app/armory -@app/policy-engine -@narval/transaction-request-intent -@narval/transaction-request-intent -

+

Armory is the most advanced and secure authorization stack for web3.

-## Description +## Project -TBD +| Project | Status | +|---------------------------------------------------------------------------------------|--------| +| [@app/armory](./apps/armory/README.md) | @app/armory CI status | +| [@app/policy-engine](./apps/policy-engine/README.md) | @app/policy-engine CI status | +| [@narval/encryption-module](./packages/encryption-module/README.md) | N/A | +| [@narval/policy-engine-shared](./packages/policy-engine-shared/README.md) | @narval/policy-engine-shared CI status | +| [@narval/signature](./packages/signature/README.md) | N/A | +| [@narval/transaction-request-intent](./packages/transaction-request-intent/README.md) | @narval/transaction-request-intent CI status | ## Getting started @@ -25,13 +26,6 @@ make setup At the end, you must have a working environment ready to run any application. -## Project - -- [@app/armory](./apps/armory/README.md) -- [@app/policy-engine](./apps/policy-engine/README.md) -- [@narval/policy-engine-shared](./packages/policy-engine-shared/README.md) -- [@narval/transaction-request-intent](./packages/transaction-request-intent/README.md) - ## Docker We use Docker & `docker-compose` to run the application's dependencies. diff --git a/apps/policy-engine/Makefile b/apps/policy-engine/Makefile index 1f5329c5d..d242aff36 100644 --- a/apps/policy-engine/Makefile +++ b/apps/policy-engine/Makefile @@ -14,6 +14,7 @@ policy-engine/setup: make policy-engine/rego/build make policy-engine/db/setup make policy-engine/test/db/setup + make policy-engine/cli CMD=provision policy-engine/copy-default-env: cp ${POLICY_ENGINE_PROJECT_DIR}/.env.default ${POLICY_ENGINE_PROJECT_DIR}/.env @@ -119,6 +120,14 @@ policy-engine/test: make policy-engine/test/integration make policy-engine/test/e2e +# === CLI === + +policy-engine/cli: + npx dotenv -e ${POLICY_ENGINE_PROJECT_DIR}/.env -- \ + ts-node -r tsconfig-paths/register \ + --project ${POLICY_ENGINE_PROJECT_DIR}/tsconfig.app.json \ + ${POLICY_ENGINE_PROJECT_DIR}/src/cli.ts ${CMD} + # === Open Policy Agent & Rego === policy-engine/rego/build: diff --git a/apps/policy-engine/README.md b/apps/policy-engine/README.md index 6561686ec..06464fafb 100644 --- a/apps/policy-engine/README.md +++ b/apps/policy-engine/README.md @@ -38,3 +38,9 @@ make policy-engine/lint make policy-engine/format/check make policy-engine/lint/check ``` + +## CLI + +```bash +make policy-engine/cli CMD=help +``` diff --git a/apps/policy-engine/src/app/__test__/e2e/tenant.spec.ts b/apps/policy-engine/src/app/__test__/e2e/tenant.spec.ts index dea950706..9c3e64331 100644 --- a/apps/policy-engine/src/app/__test__/e2e/tenant.spec.ts +++ b/apps/policy-engine/src/app/__test__/e2e/tenant.spec.ts @@ -1,15 +1,16 @@ +import { EncryptionModuleOptionProvider } from '@narval/encryption-module' import { HttpStatus, INestApplication } from '@nestjs/common' import { ConfigModule, ConfigService } from '@nestjs/config' import { Test, TestingModule } from '@nestjs/testing' import request from 'supertest' import { v4 as uuid } from 'uuid' import { AppModule } from '../../../app/app.module' -import { EncryptionService } from '../../../encryption/core/encryption.service' import { Config, load } from '../../../policy-engine.config' import { REQUEST_HEADER_API_KEY } from '../../../policy-engine.constant' import { KeyValueRepository } from '../../../shared/module/key-value/core/repository/key-value.repository' import { InMemoryKeyValueRepository } from '../../../shared/module/key-value/persistence/repository/in-memory-key-value.repository' import { TestPrismaService } from '../../../shared/module/persistence/service/test-prisma.service' +import { getTestRawAesKeyring } from '../../../shared/testing/encryption.testing' import { EngineService } from '../../core/service/engine.service' import { CreateTenantDto } from '../../http/rest/dto/create-tenant.dto' import { TenantRepository } from '../../persistence/repository/tenant.repository' @@ -19,7 +20,6 @@ describe('Tenant', () => { let module: TestingModule let testPrismaService: TestPrismaService let tenantRepository: TenantRepository - let encryptionService: EncryptionService let engineService: EngineService let configService: ConfigService @@ -37,6 +37,10 @@ describe('Tenant', () => { }) .overrideProvider(KeyValueRepository) .useValue(new InMemoryKeyValueRepository()) + .overrideProvider(EncryptionModuleOptionProvider) + .useValue({ + keyring: getTestRawAesKeyring() + }) .compile() app = module.createNestApplication() @@ -44,13 +48,11 @@ describe('Tenant', () => { engineService = module.get(EngineService) tenantRepository = module.get(TenantRepository) testPrismaService = module.get(TestPrismaService) - encryptionService = module.get(EncryptionService) configService = module.get>(ConfigService) await testPrismaService.truncateAll() - await encryptionService.setup() - await engineService.create({ + await engineService.save({ id: configService.get('engine.id', { infer: true }), masterKey: 'unsafe-test-master-key', adminApiKey diff --git a/apps/policy-engine/src/app/app.module.ts b/apps/policy-engine/src/app/app.module.ts index 56628bbbb..6c4a6101f 100644 --- a/apps/policy-engine/src/app/app.module.ts +++ b/apps/policy-engine/src/app/app.module.ts @@ -1,9 +1,10 @@ +import { EncryptionModule } from '@narval/encryption-module' import { HttpModule } from '@nestjs/axios' import { Module, OnApplicationBootstrap, ValidationPipe } from '@nestjs/common' -import { ConfigModule } from '@nestjs/config' +import { ConfigModule, ConfigService } from '@nestjs/config' import { APP_PIPE } from '@nestjs/core' -import { EncryptionModule } from '../encryption/encryption.module' import { load } from '../policy-engine.config' +import { EncryptionModuleOptionFactory } from '../shared/factory/encryption-module-option.factory' import { KeyValueModule } from '../shared/module/key-value/key-value.module' import { AppController } from './app.controller' import { AppService } from './app.service' @@ -11,6 +12,7 @@ import { DataStoreRepositoryFactory } from './core/factory/data-store-repository import { BootstrapService } from './core/service/bootstrap.service' import { DataStoreService } from './core/service/data-store.service' import { EngineService } from './core/service/engine.service' +import { ProvisionService } from './core/service/provision.service' import { SigningService } from './core/service/signing.service' import { TenantService } from './core/service/tenant.service' import { TenantController } from './http/rest/controller/tenant.controller' @@ -28,8 +30,12 @@ import { TenantRepository } from './persistence/repository/tenant.repository' isGlobal: true }), HttpModule, - EncryptionModule, - KeyValueModule + KeyValueModule, + EncryptionModule.registerAsync({ + imports: [AppModule], + inject: [ConfigService, EngineService], + useClass: EncryptionModuleOptionFactory + }) ], controllers: [AppController, TenantController], providers: [ @@ -39,18 +45,22 @@ import { TenantRepository } from './persistence/repository/tenant.repository' DataStoreService, EngineRepository, EngineService, - SigningService, EntityRepository, FileSystemDataStoreRepository, HttpDataStoreRepository, OpaService, + ProvisionService, + SigningService, TenantRepository, TenantService, { provide: APP_PIPE, useClass: ValidationPipe } - ] + ], + // - The EngineService is required by the EncryptionModule async registration. + // - The ProvisionService is required by the CliModule. + exports: [EngineService, ProvisionService] }) export class AppModule implements OnApplicationBootstrap { constructor(private bootstrapService: BootstrapService) {} diff --git a/apps/policy-engine/src/app/core/service/__test__/unit/bootstrap.service.spec.ts b/apps/policy-engine/src/app/core/service/__test__/unit/bootstrap.service.spec.ts index 8fbf87f52..15a5e6533 100644 --- a/apps/policy-engine/src/app/core/service/__test__/unit/bootstrap.service.spec.ts +++ b/apps/policy-engine/src/app/core/service/__test__/unit/bootstrap.service.spec.ts @@ -1,6 +1,13 @@ +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { MockProxy, mock } from 'jest-mock-extended' +import { EngineRepository } from '../../../../../app/persistence/repository/engine.repository' +import { load } from '../../../../../policy-engine.config' +import { KeyValueRepository } from '../../../../../shared/module/key-value/core/repository/key-value.repository' +import { KeyValueService } from '../../../../../shared/module/key-value/core/service/key-value.service' +import { InMemoryKeyValueRepository } from '../../../../../shared/module/key-value/persistence/repository/in-memory-key-value.repository' import { BootstrapService } from '../../bootstrap.service' +import { EngineService } from '../../engine.service' import { TenantService } from '../../tenant.service' describe(BootstrapService.name, () => { @@ -41,8 +48,21 @@ describe(BootstrapService.name, () => { tenantServiceMock.findAll.mockResolvedValue([tenantOne, tenantTwo]) const module = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + load: [load], + isGlobal: true + }) + ], providers: [ BootstrapService, + EngineService, + EngineRepository, + KeyValueService, + { + provide: KeyValueRepository, + useClass: InMemoryKeyValueRepository + }, { provide: TenantService, useValue: tenantServiceMock diff --git a/apps/policy-engine/src/app/core/service/__test__/unit/tenant.service.spec.ts b/apps/policy-engine/src/app/core/service/__test__/unit/tenant.service.spec.ts index 8d26e1b62..d22d93f6e 100644 --- a/apps/policy-engine/src/app/core/service/__test__/unit/tenant.service.spec.ts +++ b/apps/policy-engine/src/app/core/service/__test__/unit/tenant.service.spec.ts @@ -1,8 +1,11 @@ +import { EncryptionModule } from '@narval/encryption-module' import { DataStoreConfiguration, FIXTURE } from '@narval/policy-engine-shared' import { Test } from '@nestjs/testing' import { MockProxy, mock } from 'jest-mock-extended' -import { KeyValueService } from '../../../../../shared/module/key-value/core/service/key-value.service' +import { KeyValueRepository } from '../../../../../shared/module/key-value/core/repository/key-value.repository' +import { EncryptKeyValueService } from '../../../../../shared/module/key-value/core/service/encrypt-key-value.service' import { InMemoryKeyValueRepository } from '../../../../../shared/module/key-value/persistence/repository/in-memory-key-value.repository' +import { getTestRawAesKeyring } from '../../../../../shared/testing/encryption.testing' import { Tenant } from '../../../../../shared/type/domain.type' import { TenantRepository } from '../../../../persistence/repository/tenant.repository' import { DataStoreService } from '../../data-store.service' @@ -48,15 +51,21 @@ describe(TenantService.name, () => { dataStoreServiceMock.fetch.mockResolvedValue(stores) const module = await Test.createTestingModule({ + imports: [ + EncryptionModule.register({ + keyring: getTestRawAesKeyring() + }) + ], providers: [ TenantService, TenantRepository, + EncryptKeyValueService, { provide: DataStoreService, useValue: dataStoreServiceMock }, { - provide: KeyValueService, + provide: KeyValueRepository, useClass: InMemoryKeyValueRepository } ] diff --git a/apps/policy-engine/src/app/core/service/bootstrap.service.ts b/apps/policy-engine/src/app/core/service/bootstrap.service.ts index c361a40a4..0a7a8cf64 100644 --- a/apps/policy-engine/src/app/core/service/bootstrap.service.ts +++ b/apps/policy-engine/src/app/core/service/bootstrap.service.ts @@ -8,30 +8,7 @@ export class BootstrapService { constructor(private tenantService: TenantService) {} async boot(): Promise { - this.logger.log('Start application bootstrap procedure') - - await this.tenantService.onboard( - { - clientId: '012553b0-34e9-4b48-b217-ced3c906cd39', - clientSecret: 'unsafe-dev-secret', - dataStore: { - entity: { - dataUrl: 'http://127.0.0.1:4200/api/data-store', - signatureUrl: 'http://127.0.0.1:4200/api/data-store', - keys: [] - }, - policy: { - dataUrl: 'http://127.0.0.1:4200/api/data-store', - signatureUrl: 'http://127.0.0.1:4200/api/data-store', - keys: [] - } - }, - createdAt: new Date(), - updatedAt: new Date() - }, - // Disable sync after the onboard because we'll sync it as part of the boot. - { syncAfter: false } - ) + this.logger.log('Start engine bootstrap') await this.syncTenants() } diff --git a/apps/policy-engine/src/app/core/service/engine.service.ts b/apps/policy-engine/src/app/core/service/engine.service.ts index 398e9cca6..5d002b1ae 100644 --- a/apps/policy-engine/src/app/core/service/engine.service.ts +++ b/apps/policy-engine/src/app/core/service/engine.service.ts @@ -12,8 +12,8 @@ export class EngineService { private engineRepository: EngineRepository ) {} - async getEngine(): Promise { - const engine = await this.engineRepository.findById(this.getId()) + async getEngineOrThrow(): Promise { + const engine = await this.getEngine() if (engine) { return engine @@ -22,8 +22,18 @@ export class EngineService { throw new EngineNotProvisionedException() } - async create(engine: Engine): Promise { - return this.engineRepository.create(engine) + async getEngine(): Promise { + const engine = await this.engineRepository.findById(this.getId()) + + if (engine) { + return engine + } + + return null + } + + async save(engine: Engine): Promise { + return this.engineRepository.save(engine) } private getId(): string { diff --git a/apps/policy-engine/src/app/core/service/provision.service.ts b/apps/policy-engine/src/app/core/service/provision.service.ts new file mode 100644 index 000000000..13edc28b3 --- /dev/null +++ b/apps/policy-engine/src/app/core/service/provision.service.ts @@ -0,0 +1,65 @@ +import { generateKeyEncryptionKey, generateMasterKey } from '@narval/encryption-module' +import { Injectable, Logger } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { randomBytes } from 'crypto' +import { Config } from '../../../policy-engine.config' +import { EngineService } from './engine.service' + +@Injectable() +export class ProvisionService { + private logger = new Logger(ProvisionService.name) + + constructor( + private configService: ConfigService, + private engineService: EngineService + ) {} + + async provision(): Promise { + this.logger.log('Start engine provision') + + const engine = await this.engineService.getEngine() + + const isFirstTime = engine === null + + // IMPORTANT: The order of internal methods call matters. + + if (isFirstTime) { + await this.createEngine() + await this.maybeSetupEncryption() + } + } + + private async createEngine(): Promise { + this.logger.log('Generate admin API key and save engine') + + await this.engineService.save({ + id: this.getEngineId(), + adminApiKey: randomBytes(20).toString('hex') + }) + } + + private async maybeSetupEncryption(): Promise { + // Get the engine's latest state. + const engine = await this.engineService.getEngineOrThrow() + + if (engine.masterKey) { + return this.logger.log('Skip master key set up because it already exists') + } + + const keyring = this.configService.get('keyring', { infer: true }) + + if (keyring.type === 'raw') { + this.logger.log('Generate and save engine master key') + + const { masterPassword } = keyring + const kek = generateKeyEncryptionKey(masterPassword, this.getEngineId()) + const masterKey = await generateMasterKey(kek) + + await this.engineService.save({ ...engine, masterKey }) + } + } + + private getEngineId(): string { + return this.configService.get('engine.id', { infer: true }) + } +} diff --git a/apps/policy-engine/src/app/persistence/repository/__test__/unit/engine.repository.spec.ts b/apps/policy-engine/src/app/persistence/repository/__test__/unit/engine.repository.spec.ts index 354bf17cb..24f8e6abf 100644 --- a/apps/policy-engine/src/app/persistence/repository/__test__/unit/engine.repository.spec.ts +++ b/apps/policy-engine/src/app/persistence/repository/__test__/unit/engine.repository.spec.ts @@ -1,9 +1,9 @@ +import { EncryptionModule } from '@narval/encryption-module' import { Test } from '@nestjs/testing' -import { EncryptionModule } from '../../../../../encryption/encryption.module' -import { ApplicationException } from '../../../../../shared/exception/application.exception' import { KeyValueRepository } from '../../../../../shared/module/key-value/core/repository/key-value.repository' import { KeyValueService } from '../../../../../shared/module/key-value/core/service/key-value.service' import { InMemoryKeyValueRepository } from '../../../../../shared/module/key-value/persistence/repository/in-memory-key-value.repository' +import { getTestRawAesKeyring } from '../../../../../shared/testing/encryption.testing' import { Engine } from '../../../../../shared/type/domain.type' import { EngineRepository } from '../../engine.repository' @@ -15,7 +15,11 @@ describe(EngineRepository.name, () => { inMemoryKeyValueRepository = new InMemoryKeyValueRepository() const module = await Test.createTestingModule({ - imports: [EncryptionModule], + imports: [ + EncryptionModule.register({ + keyring: getTestRawAesKeyring() + }) + ], providers: [ KeyValueService, EngineRepository, @@ -29,15 +33,15 @@ describe(EngineRepository.name, () => { repository = module.get(EngineRepository) }) - describe('create', () => { + describe('save', () => { const engine: Engine = { id: 'test-engine-id', adminApiKey: 'unsafe-test-admin-api-key', masterKey: 'unsafe-test-master-key' } - it('creates a new engine', async () => { - await repository.create(engine) + it('saves a new engine', async () => { + await repository.save(engine) const value = await inMemoryKeyValueRepository.get(repository.getKey(engine.id)) const actualEngine = await repository.findById(engine.id) @@ -45,11 +49,5 @@ describe(EngineRepository.name, () => { expect(value).not.toEqual(null) expect(engine).toEqual(actualEngine) }) - - it('throws an error when engine is duplicate', async () => { - await repository.create(engine) - - await expect(repository.create(engine)).rejects.toThrow(ApplicationException) - }) }) }) diff --git a/apps/policy-engine/src/app/persistence/repository/__test__/unit/tenant.repository.spec.ts b/apps/policy-engine/src/app/persistence/repository/__test__/unit/tenant.repository.spec.ts index b14cccdd8..c778b9b37 100644 --- a/apps/policy-engine/src/app/persistence/repository/__test__/unit/tenant.repository.spec.ts +++ b/apps/policy-engine/src/app/persistence/repository/__test__/unit/tenant.repository.spec.ts @@ -1,3 +1,4 @@ +import { EncryptionModule } from '@narval/encryption-module' import { Action, Criterion, @@ -8,13 +9,11 @@ import { Then } from '@narval/policy-engine-shared' import { Test } from '@nestjs/testing' -import { mock } from 'jest-mock-extended' -import { EncryptionService } from '../../../../../encryption/core/encryption.service' -import { EncryptionModule } from '../../../../../encryption/encryption.module' -import { EncryptionRepository } from '../../../../../encryption/persistence/repository/encryption.repository' import { KeyValueRepository } from '../../../../../shared/module/key-value/core/repository/key-value.repository' +import { EncryptKeyValueService } from '../../../../../shared/module/key-value/core/service/encrypt-key-value.service' import { KeyValueService } from '../../../../../shared/module/key-value/core/service/key-value.service' import { InMemoryKeyValueRepository } from '../../../../../shared/module/key-value/persistence/repository/in-memory-key-value.repository' +import { getTestRawAesKeyring } from '../../../../../shared/testing/encryption.testing' import { Tenant } from '../../../../../shared/type/domain.type' import { TenantRepository } from '../../../repository/tenant.repository' @@ -27,22 +26,16 @@ describe(TenantRepository.name, () => { beforeEach(async () => { inMemoryKeyValueRepository = new InMemoryKeyValueRepository() - const encryptionRepository = mock() - encryptionRepository.getEngine.mockResolvedValue({ - id: 'test-engine', - masterKey: 'unsafe-test-master-key', - adminApiKey: 'unsafe-test-api-key' - }) - const module = await Test.createTestingModule({ - imports: [EncryptionModule], + imports: [ + EncryptionModule.register({ + keyring: getTestRawAesKeyring() + }) + ], providers: [ KeyValueService, TenantRepository, - { - provide: EncryptionRepository, - useValue: encryptionRepository - }, + EncryptKeyValueService, { provide: KeyValueRepository, useValue: inMemoryKeyValueRepository @@ -50,13 +43,6 @@ describe(TenantRepository.name, () => { ] }).compile() - // IMPORTANT: The onApplicationBootstrap performs several side-effects to - // set up the encryption. - // - // TODO: Refactor the encryption service. It MUST be ready for usage given - // its arguments rather than depending on a set up step. - await module.get(EncryptionService).setup() - repository = module.get(TenantRepository) }) diff --git a/apps/policy-engine/src/app/persistence/repository/engine.repository.ts b/apps/policy-engine/src/app/persistence/repository/engine.repository.ts index d53ae4949..c2d63117e 100644 --- a/apps/policy-engine/src/app/persistence/repository/engine.repository.ts +++ b/apps/policy-engine/src/app/persistence/repository/engine.repository.ts @@ -1,5 +1,4 @@ -import { HttpStatus, Injectable } from '@nestjs/common' -import { ApplicationException } from '../../../shared/exception/application.exception' +import { Injectable } from '@nestjs/common' import { KeyValueService } from '../../../shared/module/key-value/core/service/key-value.service' import { engineSchema } from '../../../shared/schema/engine.schema' import { Engine } from '../../../shared/type/domain.type' @@ -18,14 +17,7 @@ export class EngineRepository { return null } - async create(engine: Engine): Promise { - if (await this.keyValueService.get(this.getKey(engine.id))) { - throw new ApplicationException({ - message: 'Engine already exist', - suggestedHttpStatusCode: HttpStatus.INTERNAL_SERVER_ERROR - }) - } - + async save(engine: Engine): Promise { await this.keyValueService.set(this.getKey(engine.id), this.encode(engine)) return engine diff --git a/apps/policy-engine/src/app/persistence/repository/tenant.repository.ts b/apps/policy-engine/src/app/persistence/repository/tenant.repository.ts index da36c809a..5f655da14 100644 --- a/apps/policy-engine/src/app/persistence/repository/tenant.repository.ts +++ b/apps/policy-engine/src/app/persistence/repository/tenant.repository.ts @@ -1,16 +1,16 @@ import { EntityStore, PolicyStore, entityStoreSchema, policyStoreSchema } from '@narval/policy-engine-shared' import { Injectable } from '@nestjs/common' import { compact } from 'lodash/fp' -import { KeyValueService } from '../../../shared/module/key-value/core/service/key-value.service' +import { EncryptKeyValueService } from '../../../shared/module/key-value/core/service/encrypt-key-value.service' import { tenantIndexSchema, tenantSchema } from '../../../shared/schema/tenant.schema' import { Tenant } from '../../../shared/type/domain.type' @Injectable() export class TenantRepository { - constructor(private keyValueService: KeyValueService) {} + constructor(private encryptKeyValueService: EncryptKeyValueService) {} async findByClientId(clientId: string): Promise { - const value = await this.keyValueService.get(this.getKey(clientId)) + const value = await this.encryptKeyValueService.get(this.getKey(clientId)) if (value) { return this.decode(value) @@ -20,14 +20,14 @@ export class TenantRepository { } async save(tenant: Tenant): Promise { - await this.keyValueService.set(this.getKey(tenant.clientId), this.encode(tenant)) + await this.encryptKeyValueService.set(this.getKey(tenant.clientId), this.encode(tenant)) await this.index(tenant) return tenant } async getTenantIndex(): Promise { - const index = await this.keyValueService.get(this.getIndexKey()) + const index = await this.encryptKeyValueService.get(this.getIndexKey()) if (index) { return this.decodeIndex(index) @@ -37,11 +37,11 @@ export class TenantRepository { } async saveEntityStore(clientId: string, store: EntityStore): Promise { - return this.keyValueService.set(this.getEntityStoreKey(clientId), this.encodeEntityStore(store)) + return this.encryptKeyValueService.set(this.getEntityStoreKey(clientId), this.encodeEntityStore(store)) } async findEntityStore(clientId: string): Promise { - const value = await this.keyValueService.get(this.getEntityStoreKey(clientId)) + const value = await this.encryptKeyValueService.get(this.getEntityStoreKey(clientId)) if (value) { return this.decodeEntityStore(value) @@ -51,11 +51,11 @@ export class TenantRepository { } async savePolicyStore(clientId: string, store: PolicyStore): Promise { - return this.keyValueService.set(this.getPolicyStoreKey(clientId), this.encodePolicyStore(store)) + return this.encryptKeyValueService.set(this.getPolicyStoreKey(clientId), this.encodePolicyStore(store)) } async findPolicyStore(clientId: string): Promise { - const value = await this.keyValueService.get(this.getPolicyStoreKey(clientId)) + const value = await this.encryptKeyValueService.get(this.getPolicyStoreKey(clientId)) if (value) { return this.decodePolicyStore(value) @@ -97,13 +97,13 @@ export class TenantRepository { private async index(tenant: Tenant): Promise { const currentIndex = await this.getTenantIndex() - await this.keyValueService.set(this.getIndexKey(), this.encodeIndex([...currentIndex, tenant.clientId])) + await this.encryptKeyValueService.set(this.getIndexKey(), this.encodeIndex([...currentIndex, tenant.clientId])) return true } private encode(tenant: Tenant): string { - return KeyValueService.encode(tenantSchema.parse(tenant)) + return EncryptKeyValueService.encode(tenantSchema.parse(tenant)) } private decode(value: string): Tenant { @@ -111,7 +111,7 @@ export class TenantRepository { } private encodeIndex(value: string[]): string { - return KeyValueService.encode(tenantIndexSchema.parse(value)) + return EncryptKeyValueService.encode(tenantIndexSchema.parse(value)) } private decodeIndex(value: string): string[] { @@ -119,7 +119,7 @@ export class TenantRepository { } private encodeEntityStore(value: EntityStore): string { - return KeyValueService.encode(entityStoreSchema.parse(value)) + return EncryptKeyValueService.encode(entityStoreSchema.parse(value)) } private decodeEntityStore(value: string): EntityStore { @@ -127,7 +127,7 @@ export class TenantRepository { } private encodePolicyStore(value: PolicyStore): string { - return KeyValueService.encode(policyStoreSchema.parse(value)) + return EncryptKeyValueService.encode(policyStoreSchema.parse(value)) } private decodePolicyStore(value: string): PolicyStore { diff --git a/apps/policy-engine/src/cli.ts b/apps/policy-engine/src/cli.ts new file mode 100644 index 000000000..f1262d231 --- /dev/null +++ b/apps/policy-engine/src/cli.ts @@ -0,0 +1,8 @@ +import { CommandFactory } from 'nest-commander' +import { CliModule } from './cli/cli.module' + +async function bootstrap() { + await CommandFactory.run(CliModule, ['error']) +} + +bootstrap() diff --git a/apps/policy-engine/src/cli/cli.module.ts b/apps/policy-engine/src/cli/cli.module.ts new file mode 100644 index 000000000..7c769353d --- /dev/null +++ b/apps/policy-engine/src/cli/cli.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common' +import { AppModule } from '../app/app.module' +import { ProvisionCommand } from './command/provision.command' + +@Module({ + imports: [AppModule], + providers: [ProvisionCommand] +}) +export class CliModule {} diff --git a/apps/policy-engine/src/cli/command/provision.command.ts b/apps/policy-engine/src/cli/command/provision.command.ts new file mode 100644 index 000000000..38869c325 --- /dev/null +++ b/apps/policy-engine/src/cli/command/provision.command.ts @@ -0,0 +1,49 @@ +import { ConfigService } from '@nestjs/config' +import { Command, CommandRunner } from 'nest-commander' +import { EngineService } from '../../app/core/service/engine.service' +import { ProvisionService } from '../../app/core/service/provision.service' +import { Config } from '../../policy-engine.config' + +@Command({ + name: 'provision', + description: 'Provision the policy engine for the first time' +}) +export class ProvisionCommand extends CommandRunner { + constructor( + private provisionService: ProvisionService, + private engineService: EngineService, + private configService: ConfigService + ) { + super() + } + + async run(): Promise { + const engine = await this.engineService.getEngine() + + if (engine && engine.masterKey) { + return console.log('Engine already provisioned') + } + + await this.provisionService.provision() + + try { + const keyring = this.configService.get('keyring', { infer: true }) + const engine = await this.engineService.getEngineOrThrow() + + console.log('Engine ID:', engine.id) + console.log('Engine admin API key:', engine.adminApiKey) + console.log('Encryption type:', keyring.type) + + if (keyring.type === 'raw') { + console.log(`Is encryption master password set? ${keyring.masterPassword ? '✅' : '❌'}`) + console.log(`Is encryption master key set? ${engine.masterKey ? '✅' : '❌'}`) + } + + if (keyring.type === 'awskms') { + console.log(`Is encryption master KMS ARN set? ${keyring.masterAwsKmsArn ? '✅' : '❌'}`) + } + } catch (error) { + console.log('Something went wrong provisioning the engine', error) + } + } +} diff --git a/apps/policy-engine/src/encryption/core/__test__/integration/encryption.service.spec.ts b/apps/policy-engine/src/encryption/core/__test__/integration/encryption.service.spec.ts deleted file mode 100644 index 49c1389ea..000000000 --- a/apps/policy-engine/src/encryption/core/__test__/integration/encryption.service.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { ConfigModule, ConfigService } from '@nestjs/config' -import { Test, TestingModule } from '@nestjs/testing' -import { mock } from 'jest-mock-extended' -import nock from 'nock' -import { load } from '../../../../policy-engine.config' -import { PersistenceModule } from '../../../../shared/module/persistence/persistence.module' -import { TestPrismaService } from '../../../../shared/module/persistence/service/test-prisma.service' -import { EncryptionRepository } from '../../../persistence/repository/encryption.repository' -import { EncryptionService } from '../../encryption.service' - -describe('EncryptionService', () => { - let module: TestingModule - let service: EncryptionService - let testPrismaService: TestPrismaService - - nock.enableNetConnect('kms.us-east-2.amazonaws.com:443') - - beforeEach(async () => { - // These mocked config values matter; they're specifically tied to the mocked masterKey below - // If you change these, the decryption won't work & tests will fail - const configServiceMock = mock({ - get: jest.fn().mockImplementation((key: string) => { - if (key === 'keyring') { - return { - type: 'raw', - masterPassword: 'unsafe-local-dev-master-password' - } - } - if (key === 'engine.id') { - return 'local-dev-engine-instance-1' - } - }) - }) - - module = await Test.createTestingModule({ - imports: [ - ConfigModule.forRoot({ - load: [load], - isGlobal: true - }), - PersistenceModule - ], - providers: [ - EncryptionService, - EncryptionRepository, - { - provide: ConfigService, - useValue: configServiceMock // use the mock ConfigService - } - ] - }).compile() - - service = module.get(EncryptionService) - testPrismaService = module.get(TestPrismaService) - - await testPrismaService.truncateAll() - - if (service.setup) { - await service.setup() - } - }) - - afterEach(async () => { - await testPrismaService.truncateAll() - await module.close() - }) - - it('should create & encrypt a master key on application bootstrap', async () => { - await service.setup() - - const engine = await testPrismaService.getClient().engine.findFirst({ - where: { - id: 'local-dev-engine-instance-1' - } - }) - - expect(engine?.masterKey).toBeDefined() - }) -}) diff --git a/apps/policy-engine/src/encryption/core/__test__/unit/encryption.service.spec.ts b/apps/policy-engine/src/encryption/core/__test__/unit/encryption.service.spec.ts deleted file mode 100644 index d5cb6d1ba..000000000 --- a/apps/policy-engine/src/encryption/core/__test__/unit/encryption.service.spec.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { toBytes, toHex } from '@narval/policy-engine-shared' -import { ConfigModule, ConfigService } from '@nestjs/config' -import { Test } from '@nestjs/testing' -import { mock } from 'jest-mock-extended' -import { load } from '../../../../policy-engine.config' -import { EncryptionRepository } from '../../../persistence/repository/encryption.repository' -import { EncryptionService } from '../../encryption.service' - -describe('EncryptionService', () => { - let service: EncryptionService - - beforeEach(async () => { - // These mocked config values matter; they're specifically tied to the mocked masterKey below - // If you change these, the decryption won't work & tests will fail - const configServiceMock = mock({ - get: jest.fn().mockImplementation((key: string) => { - if (key === 'keyring') { - return { - type: 'raw', - masterPassword: 'unsafe-local-dev-master-password' - } - } - if (key === 'engine.id') { - return 'local-dev-engine-instance-1' - } - }) - }) - - const encryptionRepositoryMock = { - getEngine: jest.fn().mockImplementation(() => - Promise.resolve({ - // unencryptedMasterKey: dfd9cc70f1ad02d19e0efa020d82f557022f59ca6bedbec1df38e8fd37ae3bb9 - masterKey: - '0x0205785d67737fa3bae8eb249cf8d3baed5942f1677d8c98b4cdeef55560a3bcf510bd008d00030003617070000d61726d6f72792d656e67696e6500156177732d63727970746f2d7075626c69632d6b657900444177336764324b6e58646f512f2b76745347367031444442384d65766d61434b324c7861426e65476a315531537777526b376b4d366868752f707a446f48724c77773d3d0007707572706f7365000f646174612d656e6372797074696f6e000100146e617276616c2e61726d6f72792e656e67696e65002561726d6f72792e656e67696e652e6b656b000000800000000c8a92a7c9deb43316f6c29e8d0030132d63c7337c9888a06b638966e83056a0575958b42588b7aed999b9659e6d4bc5bed4664d91fae0b14d48917e00cdbb02000010000749ed0ed3616b7990f9e73f5a42eb46dc182002612e33dcb8e3c7d4759184c46ce3f0893a87ac15257d53097ac5d74affffffff00000001000000000000000000000001000000205d7209b51db8cf8264b9065add71a8514dc26baa6987d8a0a3acb1c4a2503b0f3b7c974a35ed234c1b94668736cd8bfa00673065023100a5d8d192e9802649dab86af6e00ab6d7472533e85dfe1006cb8bd9ef2472d15096fa42e742d18cb92530c762c3bd44d40230350299b42feaa1149c6ad1b25add24c30b3bf1c08263b96df0d43e2ad3e19802872e792040f1faf3d0a73bca6fb067ca', - id: 'test-engine-id' - }) - ) - } - const moduleRef = await Test.createTestingModule({ - imports: [ - ConfigModule.forRoot({ - load: [load], - isGlobal: true - }) - ], - providers: [ - EncryptionService, - { - provide: EncryptionRepository, - useValue: encryptionRepositoryMock - }, - { - provide: ConfigService, - useValue: configServiceMock // use the mock ConfigService - } - ] - }).compile() - - service = moduleRef.get(EncryptionService) - - if (service.setup) { - await service.setup() - } - }) - - it('should encrypt then decrypt successfully, with a string', async () => { - const data = 'Hello World' - const encrypted = await service.encrypt(data) - const decrypted = await service.decrypt(encrypted) - - expect(decrypted.toString('utf-8')).toBe(data) - }) - - it('should encrypt then decrypt successfully, with a buffer from a hexstring', async () => { - const data = '0xdfd9cc70f1ad02d19e0efa020d82f557022f59ca6bedbec1df38e8fd37ae3bb9' - const encrypted = await service.encrypt(toBytes(data)) - const decrypted = await service.decrypt(encrypted) - - expect(toHex(decrypted)).toBe(data) - }) - - it('should decrypt a hex-encoded string', async () => { - const data = 'Hello World' - const encryptedBuffer = await service.encrypt(data) - const encryptedHex = toHex(encryptedBuffer) - const decrypted = await service.decrypt(encryptedHex) - - expect(decrypted.toString('utf-8')).toBe(data) - }) -}) diff --git a/apps/policy-engine/src/encryption/core/encryption.service.ts b/apps/policy-engine/src/encryption/core/encryption.service.ts deleted file mode 100644 index cc9e0f2b6..000000000 --- a/apps/policy-engine/src/encryption/core/encryption.service.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { - CommitmentPolicy, - KmsKeyringNode, - RawAesKeyringNode, - RawAesWrappingSuiteIdentifier, - buildClient -} from '@aws-crypto/client-node' -import { Hex, toBytes, toHex } from '@narval/policy-engine-shared' -import { Inject, Injectable, Logger } from '@nestjs/common' -import { ConfigService } from '@nestjs/config' -import crypto from 'crypto' -import { Config } from '../../policy-engine.config' -import { EncryptionRepository } from '../persistence/repository/encryption.repository' - -const keyNamespace = 'narval.armory.engine' -const commitmentPolicy = CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT -const wrappingSuite = RawAesWrappingSuiteIdentifier.AES256_GCM_IV12_TAG16_NO_PADDING -const defaultEncryptionContext = { - purpose: 'data-encryption', - app: 'armory-engine' -} - -const { encrypt, decrypt } = buildClient(commitmentPolicy) -@Injectable() -export class EncryptionService { - private logger = new Logger(EncryptionService.name) - - private configService: ConfigService - - private engineId: string - - private keyring: RawAesKeyringNode | KmsKeyringNode | undefined - - constructor( - private encryptionRepository: EncryptionRepository, - @Inject(ConfigService) configService: ConfigService - ) { - this.configService = configService - this.engineId = configService.get('engine.id', { infer: true }) - } - - async setup(): Promise { - this.logger.log('Set up keyring') - const keyringConfig = this.configService.get('keyring', { infer: true }) - - // We have a Raw Keyring, so we are using a MasterPassword/KEK+MasterKey for encryption - if (keyringConfig.type === 'raw') { - const engine = await this.encryptionRepository.getEngine(this.engineId) - let encryptedMasterKey = engine?.masterKey - - // Derive the Key Encryption Key (KEK) from the master password using PBKDF2 - const masterPassword = keyringConfig.masterPassword - const kek = this.deriveKeyEncryptionKey(masterPassword) - - if (!encryptedMasterKey) { - // No MK yet, so create it & encrypt w/ the KEK - encryptedMasterKey = await this.generateMasterKey(kek) - } - - const decryptedMasterKey = await this.decryptMasterKey(kek, toBytes(encryptedMasterKey)) - const isolatedMasterKey = Buffer.alloc(decryptedMasterKey.length) - decryptedMasterKey.copy(isolatedMasterKey, 0, 0, decryptedMasterKey.length) - - /* Configure the Raw AES keyring. */ - const keyring = new RawAesKeyringNode({ - keyName: 'armory.engine.wrapping-key', - keyNamespace, - unencryptedMasterKey: isolatedMasterKey, - wrappingSuite - }) - - this.keyring = keyring - } - // We have AWS KMS config so we'll use that instead as the MasterKey, which means we don't need a KEK separately - else if (keyringConfig.type === 'awskms') { - const keyring = new KmsKeyringNode({ generatorKeyId: keyringConfig.masterAwsKmsArn }) - this.keyring = keyring - } else { - throw new Error('Invalid Keyring Configuration found') - } - } - - private getKeyEncryptionKeyring(kek: Buffer) { - // Allocate a new isolated buffer to ensure we don't manipulate the kek - const isolatedKek = Buffer.alloc(kek.length) - kek.copy(isolatedKek, 0, 0, kek.length) - - const keyring = new RawAesKeyringNode({ - keyName: 'armory.engine.kek', - keyNamespace, - unencryptedMasterKey: isolatedKek, - wrappingSuite - }) - - return keyring - } - - private deriveKeyEncryptionKey(password: string): Buffer { - // Derive the Key Encryption Key (KEK) from the master password using PBKDF2 - const kek = crypto.pbkdf2Sync(password.normalize(), this.engineId.normalize(), 1000000, 32, 'sha256') - return kek - } - - private async encryptMaterKey(kek: Buffer, cleartext: Buffer): Promise { - // Encrypt the Master Key (MK) with the Key Encryption Key (KEK) - const keyring = this.getKeyEncryptionKeyring(kek) - const { result } = await encrypt(keyring, cleartext, { - encryptionContext: defaultEncryptionContext - }) - - return result - } - - private async decryptMasterKey(kek: Buffer, ciphertext: Uint8Array): Promise { - const keyring = this.getKeyEncryptionKeyring(kek) - const { plaintext, messageHeader } = await decrypt(keyring, ciphertext) - - // Verify the context wasn't changed - const { encryptionContext } = messageHeader - - Object.entries(defaultEncryptionContext).forEach(([key, value]) => { - if (encryptionContext[key] !== value) throw new Error('Encryption Context does not match expected values') - }) - - return plaintext - } - - async encrypt(cleartext: string | Buffer | Uint8Array): Promise { - const keyring = this.keyring - if (!keyring) throw new Error('Keyring not set') - - const { result } = await encrypt(keyring, cleartext, { - encryptionContext: defaultEncryptionContext - }) - - return result - } - - async decrypt(ciphertext: Buffer | Uint8Array | Hex): Promise { - const keyring = this.keyring - if (!keyring) throw new Error('Keyring not set') - - let ciphertextBuffer = ciphertext - if (typeof ciphertext === 'string') { - ciphertextBuffer = toBytes(ciphertext) - } - - const { plaintext, messageHeader } = await decrypt(keyring, ciphertextBuffer) - - // Verify the context wasn't changed - const { encryptionContext } = messageHeader - - Object.entries(defaultEncryptionContext).forEach(([key, value]) => { - if (encryptionContext[key] !== value) throw new Error('Encryption Context does not match expected values') - }) - - return plaintext - } - - private async generateMasterKey(kek: Buffer): Promise { - // Generate a new Master Key (MK) with AES256 - const mk = crypto.generateKeySync('aes', { length: 256 }) - const mkBuffer = mk.export() - - // Encrypt it with the Key Encryption Key (KEK) that was derived from the MP - const encryptedMk = await this.encryptMaterKey(kek, mkBuffer) - const encryptedMkString = toHex(encryptedMk) - - // Save the Result. - const existingEngine = await this.encryptionRepository.getEngine(this.engineId) - const engine = existingEngine - ? await this.encryptionRepository.saveMasterKey(this.engineId, encryptedMkString) - : await this.encryptionRepository.createEngine(this.engineId, encryptedMkString) - - if (!engine?.masterKey) { - throw new Error('Master Key was not saved') - } - - this.logger.log('Engine Master Key Setup Complete') - return encryptedMkString - } -} diff --git a/apps/policy-engine/src/encryption/encryption.module.ts b/apps/policy-engine/src/encryption/encryption.module.ts deleted file mode 100644 index 20d234a5e..000000000 --- a/apps/policy-engine/src/encryption/encryption.module.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { HttpModule } from '@nestjs/axios' -import { Module, ValidationPipe } from '@nestjs/common' -import { ConfigModule } from '@nestjs/config' -import { APP_PIPE } from '@nestjs/core' -import { PersistenceModule } from '../shared/module/persistence/persistence.module' -import { EncryptionService } from './core/encryption.service' -import { EncryptionRepository } from './persistence/repository/encryption.repository' - -@Module({ - imports: [ConfigModule.forRoot(), HttpModule, PersistenceModule], - controllers: [], - providers: [ - EncryptionService, - EncryptionRepository, - { - provide: APP_PIPE, - useClass: ValidationPipe - } - ], - exports: [EncryptionService] -}) -export class EncryptionModule {} diff --git a/apps/policy-engine/src/encryption/persistence/repository/encryption.repository.ts b/apps/policy-engine/src/encryption/persistence/repository/encryption.repository.ts deleted file mode 100644 index 5d8c0bdf6..000000000 --- a/apps/policy-engine/src/encryption/persistence/repository/encryption.repository.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common' -import { PrismaService } from '../../../shared/module/persistence/service/prisma.service' - -@Injectable() -export class EncryptionRepository { - private logger = new Logger(EncryptionRepository.name) - - constructor(private prismaService: PrismaService) {} - - async getEngine(engineId: string) { - return this.prismaService.engine.findUnique({ - where: { - id: engineId - } - }) - } - - async createEngine(engineId: string, masterKey: string, adminApiKey?: string) { - return this.prismaService.engine.create({ - data: { - id: engineId, - masterKey, - adminApiKey - } - }) - } - - async saveMasterKey(engineId: string, masterKey: string) { - return this.prismaService.engine.update({ - where: { - id: engineId, - masterKey: null // ONLY allow updating it if already null. We don't want to accidentally overwrite it! - }, - data: { - masterKey - } - }) - } -} diff --git a/apps/policy-engine/src/policy-engine.config.ts b/apps/policy-engine/src/policy-engine.config.ts index 72c6175bb..2cffdeefb 100644 --- a/apps/policy-engine/src/policy-engine.config.ts +++ b/apps/policy-engine/src/policy-engine.config.ts @@ -13,7 +13,8 @@ const configSchema = z.object({ url: z.string().startsWith('postgresql:') }), engine: z.object({ - id: z.string() + id: z.string(), + masterKey: z.string().optional() }), keyring: z.union([ z.object({ @@ -37,7 +38,8 @@ export const load = (): Config => { url: process.env.POLICY_ENGINE_DATABASE_URL }, engine: { - id: process.env.ENGINE_UID + id: process.env.ENGINE_UID, + masterKey: process.env.MASTER_KEY }, keyring: { type: process.env.KEYRING_TYPE, diff --git a/apps/policy-engine/src/policy-engine.constant.ts b/apps/policy-engine/src/policy-engine.constant.ts index 206027506..446431eba 100644 --- a/apps/policy-engine/src/policy-engine.constant.ts +++ b/apps/policy-engine/src/policy-engine.constant.ts @@ -1 +1,7 @@ +import { RawAesWrappingSuiteIdentifier } from '@aws-crypto/client-node' + export const REQUEST_HEADER_API_KEY = 'x-api-key' + +export const ENCRYPTION_KEY_NAMESPACE = 'armory.policy-engine' +export const ENCRYPTION_KEY_NAME = 'storage-encryption' +export const ENCRYPTION_WRAPPING_SUITE = RawAesWrappingSuiteIdentifier.AES256_GCM_IV12_TAG16_NO_PADDING diff --git a/apps/policy-engine/src/shared/factory/encryption-module-option.factory.ts b/apps/policy-engine/src/shared/factory/encryption-module-option.factory.ts new file mode 100644 index 000000000..d211f0678 --- /dev/null +++ b/apps/policy-engine/src/shared/factory/encryption-module-option.factory.ts @@ -0,0 +1,58 @@ +import { RawAesKeyringNode } from '@aws-crypto/client-node' +import { + EncryptionModuleOption, + decryptMasterKey, + generateKeyEncryptionKey, + isolateBuffer +} from '@narval/encryption-module' +import { toBytes } from '@narval/policy-engine-shared' +import { Injectable, Logger } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { EngineService } from '../../app/core/service/engine.service' +import { Config } from '../../policy-engine.config' +import { ENCRYPTION_KEY_NAME, ENCRYPTION_KEY_NAMESPACE, ENCRYPTION_WRAPPING_SUITE } from '../../policy-engine.constant' + +@Injectable() +export class EncryptionModuleOptionFactory { + private logger = new Logger(EncryptionModuleOptionFactory.name) + + constructor( + private engineService: EngineService, + private configService: ConfigService + ) {} + + async create(): Promise { + const keyring = this.configService.get('keyring', { infer: true }) + const engine = await this.engineService.getEngine() + + // NOTE: An undefined engine at boot time only happens during the + // provisioning. + if (!engine) { + this.logger.warn('Booting the encryption module without a keyring. Please, provision the engine.') + + return { + keyring: undefined + } + } + + if (keyring.type === 'raw') { + if (!engine.masterKey) { + throw new Error('Master key not set') + } + + const kek = generateKeyEncryptionKey(keyring.masterPassword, engine.id) + const unencryptedMasterKey = await decryptMasterKey(kek, toBytes(engine.masterKey)) + + return { + keyring: new RawAesKeyringNode({ + unencryptedMasterKey: isolateBuffer(unencryptedMasterKey), + keyName: ENCRYPTION_KEY_NAME, + keyNamespace: ENCRYPTION_KEY_NAMESPACE, + wrappingSuite: ENCRYPTION_WRAPPING_SUITE + }) + } + } + + throw new Error('Unsupported keyring type') + } +} diff --git a/apps/policy-engine/src/shared/guard/__test__/unit/admin-api-key.guard.spec.ts b/apps/policy-engine/src/shared/guard/__test__/unit/admin-api-key.guard.spec.ts index 977d3abb0..018cfc16e 100644 --- a/apps/policy-engine/src/shared/guard/__test__/unit/admin-api-key.guard.spec.ts +++ b/apps/policy-engine/src/shared/guard/__test__/unit/admin-api-key.guard.spec.ts @@ -20,14 +20,17 @@ describe(AdminApiKeyGuard.name, () => { } const mockEngineService = (adminApiKey: string = 'test-admin-api-key') => { - const engineService = mock() - engineService.getEngine.mockResolvedValue({ + const engine = { adminApiKey, id: 'test-engine-id', masterKey: 'test-master-key' - }) + } + + const serviceMock = mock() + serviceMock.getEngine.mockResolvedValue(engine) + serviceMock.getEngineOrThrow.mockResolvedValue(engine) - return engineService + return serviceMock } it(`throws an error when ${REQUEST_HEADER_API_KEY} header is missing`, async () => { diff --git a/apps/policy-engine/src/shared/guard/admin-api-key.guard.ts b/apps/policy-engine/src/shared/guard/admin-api-key.guard.ts index 9ef97d01a..bee1f3b7b 100644 --- a/apps/policy-engine/src/shared/guard/admin-api-key.guard.ts +++ b/apps/policy-engine/src/shared/guard/admin-api-key.guard.ts @@ -18,7 +18,7 @@ export class AdminApiKeyGuard implements CanActivate { }) } - const engine = await this.engineService.getEngine() + const engine = await this.engineService.getEngineOrThrow() return engine.adminApiKey === apiKey } diff --git a/apps/policy-engine/src/shared/module/key-value/core/service/__test__/integration/encrypt-key-value.service.spec.ts b/apps/policy-engine/src/shared/module/key-value/core/service/__test__/integration/encrypt-key-value.service.spec.ts new file mode 100644 index 000000000..fbc8e6412 --- /dev/null +++ b/apps/policy-engine/src/shared/module/key-value/core/service/__test__/integration/encrypt-key-value.service.spec.ts @@ -0,0 +1,52 @@ +import { EncryptionModule } from '@narval/encryption-module' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { load } from '../../../../../../../policy-engine.config' +import { getTestRawAesKeyring } from '../../../../../../../shared/testing/encryption.testing' +import { InMemoryKeyValueRepository } from '../../../../persistence/repository/in-memory-key-value.repository' +import { KeyValueRepository } from '../../../repository/key-value.repository' +import { EncryptKeyValueService } from '../../encrypt-key-value.service' + +describe(EncryptKeyValueService.name, () => { + let service: EncryptKeyValueService + let keyValueRepository: KeyValueRepository + let inMemoryKeyValueRepository: InMemoryKeyValueRepository + + beforeEach(async () => { + inMemoryKeyValueRepository = new InMemoryKeyValueRepository() + + const module = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + load: [load], + isGlobal: true + }), + EncryptionModule.register({ + keyring: getTestRawAesKeyring() + }) + ], + providers: [ + EncryptKeyValueService, + { + provide: KeyValueRepository, + useValue: inMemoryKeyValueRepository + } + ] + }).compile() + + service = module.get(EncryptKeyValueService) + keyValueRepository = module.get(KeyValueRepository) + }) + + describe('set', () => { + it('sets encrypt value in the key-value storage', async () => { + const key = 'test-key' + const value = 'plain value' + + await service.set(key, value) + + expect(await keyValueRepository.get(key)).not.toEqual(value) + expect(await service.get(key)).toEqual(value) + }) + }) +}) diff --git a/apps/policy-engine/src/shared/module/key-value/core/service/__test__/integration/key-value.service.spec.ts b/apps/policy-engine/src/shared/module/key-value/core/service/__test__/integration/key-value.service.spec.ts index 6611c7106..67185523f 100644 --- a/apps/policy-engine/src/shared/module/key-value/core/service/__test__/integration/key-value.service.spec.ts +++ b/apps/policy-engine/src/shared/module/key-value/core/service/__test__/integration/key-value.service.spec.ts @@ -1,17 +1,12 @@ import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' -import { EncryptionModule } from '../../../../../../../encryption/encryption.module' import { load } from '../../../../../../../policy-engine.config' -import { TestPrismaService } from '../../../../../../../shared/module/persistence/service/test-prisma.service' import { InMemoryKeyValueRepository } from '../../../../persistence/repository/in-memory-key-value.repository' import { KeyValueRepository } from '../../../repository/key-value.repository' import { KeyValueService } from '../../key-value.service' describe(KeyValueService.name, () => { let service: KeyValueService - // eslint-disable-next-line @typescript-eslint/no-unused-vars - let keyValueRepository: KeyValueRepository - let testPrismaService: TestPrismaService let inMemoryKeyValueRepository: InMemoryKeyValueRepository beforeEach(async () => { @@ -22,8 +17,7 @@ describe(KeyValueService.name, () => { ConfigModule.forRoot({ load: [load], isGlobal: true - }), - EncryptionModule + }) ], providers: [ KeyValueService, @@ -35,24 +29,15 @@ describe(KeyValueService.name, () => { }).compile() service = module.get(KeyValueService) - keyValueRepository = module.get(KeyValueRepository) - testPrismaService = module.get(TestPrismaService) - - await testPrismaService.truncateAll() - }) - - afterAll(async () => { - await testPrismaService.truncateAll() }) describe('set', () => { - it('sets encrypted value in the key-value storage', async () => { + it('sets dencrypted value in the key-value storage', async () => { const key = 'test-key' - const value = 'not encrypted value' + const value = 'plain value' await service.set(key, value) - // expect(await keyValueRepository.get(key)).not.toEqual(value) expect(await service.get(key)).toEqual(value) }) }) diff --git a/apps/policy-engine/src/shared/module/key-value/core/service/encrypt-key-value.service.ts b/apps/policy-engine/src/shared/module/key-value/core/service/encrypt-key-value.service.ts new file mode 100644 index 000000000..9bbd32acb --- /dev/null +++ b/apps/policy-engine/src/shared/module/key-value/core/service/encrypt-key-value.service.ts @@ -0,0 +1,43 @@ +import { EncryptionService } from '@narval/encryption-module' +import { Inject, Injectable } from '@nestjs/common' +import { KeyValueRepository } from '../repository/key-value.repository' + +/** + * The key-value service is the main interface to interact with any storage + * back-end. Since the storage backend lives outside the engine, it's considered + * untrusted so the engine will encrypt the data before it sends them to the + * storage. + */ +@Injectable() +export class EncryptKeyValueService { + constructor( + @Inject(KeyValueRepository) private keyValueRepository: KeyValueRepository, + private encryptionService: EncryptionService + ) {} + + async get(key: string): Promise { + const encryptedValue = await this.keyValueRepository.get(key) + + if (encryptedValue) { + const value = await this.encryptionService.decrypt(Buffer.from(encryptedValue, 'hex')) + + return value.toString() + } + + return null + } + + async set(key: string, value: string): Promise { + const encryptedValue = await this.encryptionService.encrypt(value) + + return this.keyValueRepository.set(key, encryptedValue.toString('hex')) + } + + async delete(key: string): Promise { + return this.keyValueRepository.delete(key) + } + + static encode(value: unknown): string { + return JSON.stringify(value) + } +} diff --git a/apps/policy-engine/src/shared/module/key-value/core/service/key-value.service.ts b/apps/policy-engine/src/shared/module/key-value/core/service/key-value.service.ts index 96b88008e..8215aad17 100644 --- a/apps/policy-engine/src/shared/module/key-value/core/service/key-value.service.ts +++ b/apps/policy-engine/src/shared/module/key-value/core/service/key-value.service.ts @@ -1,38 +1,15 @@ import { Inject, Injectable } from '@nestjs/common' import { KeyValueRepository } from '../repository/key-value.repository' -/** - * The key-value service is the main interface to interact with any storage - * back-end. Since the storage backend lives outside the engine, it's considered - * untrusted so the engine will encrypt the data before it sends them to the - * storage. - * - * It's because of that the key-value service assumes data is always encrypted. - * If you need non-encrypted data, you can use the key-value repository. - */ @Injectable() export class KeyValueService { constructor(@Inject(KeyValueRepository) private keyValueRepository: KeyValueRepository) {} async get(key: string): Promise { - // const encryptedValue = await this.keyValueRepository.get(key) - - // if (encryptedValue) { - // const value = await this.encryptionService.decrypt(Buffer.from(encryptedValue, 'hex')) - - // return value.toString() - // } - - // return null - return this.keyValueRepository.get(key) } async set(key: string, value: string): Promise { - // const encryptedValue = await this.encryptionService.encrypt(value) - - // return this.keyValueRepository.set(key, encryptedValue.toString('hex')) - return this.keyValueRepository.set(key, value) } diff --git a/apps/policy-engine/src/shared/module/key-value/key-value.module.ts b/apps/policy-engine/src/shared/module/key-value/key-value.module.ts index cb08f8a85..c0333a31e 100644 --- a/apps/policy-engine/src/shared/module/key-value/key-value.module.ts +++ b/apps/policy-engine/src/shared/module/key-value/key-value.module.ts @@ -1,18 +1,35 @@ -import { Module } from '@nestjs/common' -import { EncryptionModule } from '../../../encryption/encryption.module' +import { EncryptionModule } from '@narval/encryption-module' +import { Module, forwardRef } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { AppModule } from '../../../app/app.module' +import { EngineService } from '../../../app/core/service/engine.service' +import { EncryptionModuleOptionFactory } from '../../factory/encryption-module-option.factory' +import { PersistenceModule } from '../persistence/persistence.module' import { KeyValueRepository } from './core/repository/key-value.repository' +import { EncryptKeyValueService } from './core/service/encrypt-key-value.service' import { KeyValueService } from './core/service/key-value.service' import { InMemoryKeyValueRepository } from './persistence/repository/in-memory-key-value.repository' +import { PrismaKeyValueRepository } from './persistence/repository/prisma-key-value.repository' @Module({ - imports: [EncryptionModule], + imports: [ + PersistenceModule, + EncryptionModule.registerAsync({ + imports: [forwardRef(() => AppModule)], + inject: [ConfigService, EngineService], + useClass: EncryptionModuleOptionFactory + }) + ], providers: [ KeyValueService, + EncryptKeyValueService, + InMemoryKeyValueRepository, + PrismaKeyValueRepository, { provide: KeyValueRepository, - useClass: InMemoryKeyValueRepository + useExisting: PrismaKeyValueRepository } ], - exports: [KeyValueService, KeyValueRepository] + exports: [KeyValueService, EncryptKeyValueService] }) export class KeyValueModule {} diff --git a/apps/policy-engine/src/shared/module/key-value/persistence/repository/prisma-key-value.repository.ts b/apps/policy-engine/src/shared/module/key-value/persistence/repository/prisma-key-value.repository.ts new file mode 100644 index 000000000..630f236a6 --- /dev/null +++ b/apps/policy-engine/src/shared/module/key-value/persistence/repository/prisma-key-value.repository.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common' +import { PrismaService } from '../../../persistence/service/prisma.service' +import { KeyValueRepository } from '../../core/repository/key-value.repository' + +@Injectable() +export class PrismaKeyValueRepository implements KeyValueRepository { + constructor(private prismaService: PrismaService) {} + + async get(key: string): Promise { + const model = await this.prismaService.keyValue.findUnique({ + where: { key } + }) + + if (model) { + return model.value + } + + return null + } + + async set(key: string, value: string): Promise { + try { + await this.prismaService.keyValue.upsert({ + where: { key }, + create: { key, value }, + update: { value } + }) + + return true + } catch (error) { + return false + } + } + + async delete(key: string): Promise { + try { + await this.prismaService.keyValue.delete({ + where: { key } + }) + + return true + } catch (error) { + return false + } + } +} diff --git a/apps/policy-engine/src/shared/module/persistence/schema/migrations/20240301204146_init/migration.sql b/apps/policy-engine/src/shared/module/persistence/schema/migrations/20240312112602_init/migration.sql similarity index 52% rename from apps/policy-engine/src/shared/module/persistence/schema/migrations/20240301204146_init/migration.sql rename to apps/policy-engine/src/shared/module/persistence/schema/migrations/20240312112602_init/migration.sql index a57f19a4e..61f90609d 100644 --- a/apps/policy-engine/src/shared/module/persistence/schema/migrations/20240301204146_init/migration.sql +++ b/apps/policy-engine/src/shared/module/persistence/schema/migrations/20240312112602_init/migration.sql @@ -6,3 +6,11 @@ CREATE TABLE "engine" ( CONSTRAINT "engine_pkey" PRIMARY KEY ("id") ); + +-- CreateTable +CREATE TABLE "key_value" ( + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + + CONSTRAINT "key_value_pkey" PRIMARY KEY ("key") +); diff --git a/apps/policy-engine/src/shared/module/persistence/schema/schema.prisma b/apps/policy-engine/src/shared/module/persistence/schema/schema.prisma index 9ea1412f7..f202242c1 100644 --- a/apps/policy-engine/src/shared/module/persistence/schema/schema.prisma +++ b/apps/policy-engine/src/shared/module/persistence/schema/schema.prisma @@ -19,3 +19,12 @@ model Engine { @@map("engine") } + +// TODO: (@wcalderipe, 12/03/23) use hstore extension for better performance. +// See https://www.postgresql.org/docs/9.1/hstore.html +model KeyValue { + key String @id + value String + + @@map("key_value") +} diff --git a/apps/policy-engine/src/shared/schema/engine.schema.ts b/apps/policy-engine/src/shared/schema/engine.schema.ts index 7a5ce78cc..51415b9b0 100644 --- a/apps/policy-engine/src/shared/schema/engine.schema.ts +++ b/apps/policy-engine/src/shared/schema/engine.schema.ts @@ -1,7 +1,7 @@ import { z } from 'zod' export const engineSchema = z.object({ - id: z.string(), - masterKey: z.string(), - adminApiKey: z.string() + id: z.string().min(1), + adminApiKey: z.string().min(1), + masterKey: z.string().min(1).optional() }) diff --git a/apps/policy-engine/src/shared/testing/encryption.testing.ts b/apps/policy-engine/src/shared/testing/encryption.testing.ts new file mode 100644 index 000000000..252e1a8a1 --- /dev/null +++ b/apps/policy-engine/src/shared/testing/encryption.testing.ts @@ -0,0 +1,14 @@ +import { RawAesKeyringNode } from '@aws-crypto/client-node' +import { DEFAULT_WRAPPING_SUITE, generateKeyEncryptionKey } from '@narval/encryption-module' + +export const getTestRawAesKeyring = (options?: { password: string; salt: string }) => { + const password = options?.password || 'test-encryption-password' + const salt = options?.salt || 'test-encryption-salt' + + return new RawAesKeyringNode({ + keyName: 'test.key.name', + keyNamespace: 'test.key.namespace', + unencryptedMasterKey: generateKeyEncryptionKey(password, salt), + wrappingSuite: DEFAULT_WRAPPING_SUITE + }) +} diff --git a/package-lock.json b/package-lock.json index ee42869b1..2416a7db2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "jose": "^5.2.2", "lodash": "^4.17.21", "lowdb": "^7.0.1", + "nest-commander": "^3.12.5", "next": "14.0.4", "prism-react-renderer": "^2.3.1", "react": "18.2.0", @@ -7474,6 +7475,18 @@ "npm": ">=6.14.13" } }, + "node_modules/@golevelup/nestjs-discovery": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@golevelup/nestjs-discovery/-/nestjs-discovery-4.0.0.tgz", + "integrity": "sha512-iyZLYip9rhVMR0C93vo860xmboRrD5g5F5iEOfpeblGvYSz8ymQrL9RAST7x/Fp3n+TAXSeOLzDIASt+rak68g==", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "@nestjs/common": "^10.x", + "@nestjs/core": "^10.x" + } + }, "node_modules/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", @@ -16210,6 +16223,16 @@ "@types/node": "*" } }, + "node_modules/@types/inquirer": { + "version": "8.2.10", + "resolved": "https://registry.npmjs.org/@types/inquirer/-/inquirer-8.2.10.tgz", + "integrity": "sha512-IdD5NmHyVjWM8SHWo/kPBgtzXatwPkfwzyP3fN1jF2g9BWt5WO+8hL2F4o2GKIYsU40PpqeevuUWvkS/roXJkA==", + "peer": true, + "dependencies": { + "@types/through": "*", + "rxjs": "^7.2.0" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -16504,6 +16527,15 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/through": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/through/-/through-0.0.33.tgz", + "integrity": "sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==", + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -18008,7 +18040,6 @@ "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, "dependencies": { "type-fest": "^0.21.3" }, @@ -18023,7 +18054,6 @@ "version": "0.21.3", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, "engines": { "node": ">=10" }, @@ -19466,6 +19496,11 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==" + }, "node_modules/cheerio": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", @@ -19798,6 +19833,14 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "engines": { + "node": ">= 10" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -24228,6 +24271,30 @@ "node": ">=12.0.0" } }, + "node_modules/external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "dependencies": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/external-editor/node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -24361,7 +24428,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dev": true, "dependencies": { "escape-string-regexp": "^1.0.5" }, @@ -26296,6 +26362,66 @@ "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz", "integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==" }, + "node_modules/inquirer": { + "version": "8.2.6", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz", + "integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==", + "dependencies": { + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "external-editor": "^3.0.3", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^5.4.1", + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^6.0.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/inquirer/node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inquirer/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/internal-slot": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", @@ -32350,6 +32476,11 @@ "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==" }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==" + }, "node_modules/mylas": { "version": "2.1.13", "resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.13.tgz", @@ -32451,6 +32582,67 @@ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, + "node_modules/nest-commander": { + "version": "3.12.5", + "resolved": "https://registry.npmjs.org/nest-commander/-/nest-commander-3.12.5.tgz", + "integrity": "sha512-UDzUvCG59ma84/7uUUWGltXr7gGtG3smr7ILg+Guia5wFzQNhxNLtlqapzI3woFr5kuuWtVcLRL/4+diLefZrA==", + "dependencies": { + "@fig/complete-commander": "^3.0.0", + "@golevelup/nestjs-discovery": "4.0.0", + "commander": "11.1.0", + "cosmiconfig": "8.3.6", + "inquirer": "8.2.6" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@types/inquirer": "^8.1.3" + } + }, + "node_modules/nest-commander/node_modules/@fig/complete-commander": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@fig/complete-commander/-/complete-commander-3.0.0.tgz", + "integrity": "sha512-jxiF1O+xiqdM7jECmTTrSO5w35iKsVRcSCz9mu20R4bFgLJS+61VNHw2A3EY7gU1kKlLJye0TmkyTfAoPhIq7A==", + "dependencies": { + "prettier": "^3.1.0" + }, + "peerDependencies": { + "commander": "^11.1.0" + } + }, + "node_modules/nest-commander/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "engines": { + "node": ">=16" + } + }, + "node_modules/nest-commander/node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/next": { "version": "14.0.4", "resolved": "https://registry.npmjs.org/next/-/next-14.0.4.tgz", @@ -33144,6 +33336,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/p-cancelable": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", @@ -34582,7 +34782,6 @@ "version": "3.2.5", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", - "dev": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -36457,6 +36656,14 @@ "node": ">=12.0.0" } }, + "node_modules/run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -38504,8 +38711,7 @@ "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==" }, "node_modules/through2": { "version": "2.0.5", diff --git a/package.json b/package.json index 5b2ad96a2..73cafb5f8 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "jose": "^5.2.2", "lodash": "^4.17.21", "lowdb": "^7.0.1", + "nest-commander": "^3.12.5", "next": "14.0.4", "prism-react-renderer": "^2.3.1", "react": "18.2.0", diff --git a/packages/encryption-module/.eslintrc.json b/packages/encryption-module/.eslintrc.json new file mode 100644 index 000000000..9d9c0db55 --- /dev/null +++ b/packages/encryption-module/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/packages/encryption-module/README.md b/packages/encryption-module/README.md new file mode 100644 index 000000000..9d562d32e --- /dev/null +++ b/packages/encryption-module/README.md @@ -0,0 +1,38 @@ +# Encryption Module + +This is a NestJS module for encryption on top of `@aws-crypto/client-node` +tailored to meet the needs of the Armory server. + +## Getting started + +```typescript +import { RawAesKeyringNode, RawAesWrappingSuiteIdentifier } from '@aws-crypto/client-node' +import { EncryptionModule, decryptMasterKey, generateKeyEncryptionKey, isolateBuffer } from '@narval/encryption-module' +import { toBytes } from '@narval/policy-engine-shared' + +@Module({ + imports: [ + EncryptionModule.registerAsync({ + inject: [ConfigService], + useFactory: async (configService: ConfigService) => { + const salt = configService.get('ENCRYPTION_SALT') + const password = configService.get('ENCRYPTION_PASSWORD') + const masterKey = configService.get('ENCRYPTION_MASTER_KEY') + const kek = generateKeyEncryptionKey(password, salt) + const unencryptedMasterKey = await decryptMasterKey(kek, toBytes(masterKey)) + + return { + keyring: new RawAesKeyringNode({ + unencryptedMasterKey: isolateBuffer(unencryptedMasterKey), + keyName: 'arbitrary.key.name', + keyNamespace: 'arbitrary.key.namespace', + wrappingSuite: RawAesWrappingSuiteIdentifier.AES256_GCM_IV12_TAG16_NO_PADDING + }) + } + } + }) + ] +}) +``` + +> Note: the module also exposes `.register` method for sync registration. diff --git a/packages/encryption-module/jest.config.ts b/packages/encryption-module/jest.config.ts new file mode 100644 index 000000000..af6893aa7 --- /dev/null +++ b/packages/encryption-module/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'encryption-module', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }] + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/packages/encryption-module' +} diff --git a/packages/encryption-module/project.json b/packages/encryption-module/project.json new file mode 100644 index 000000000..5c802b971 --- /dev/null +++ b/packages/encryption-module/project.json @@ -0,0 +1,23 @@ +{ + "name": "encryption-module", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/encryption-module/src", + "projectType": "library", + "targets": { + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["packages/encryption-module/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "packages/encryption-module/jest.config.ts" + } + } + }, + "tags": [] +} diff --git a/packages/encryption-module/src/index.ts b/packages/encryption-module/src/index.ts new file mode 100644 index 000000000..2e58ae550 --- /dev/null +++ b/packages/encryption-module/src/index.ts @@ -0,0 +1,6 @@ +export * from './lib/encryption.constant' +export * from './lib/encryption.exception' +export * from './lib/encryption.module' +export * from './lib/encryption.service' +export * from './lib/encryption.type' +export * from './lib/encryption.util' diff --git a/packages/encryption-module/src/lib/__test__/unit/encryption.module.spec.ts b/packages/encryption-module/src/lib/__test__/unit/encryption.module.spec.ts new file mode 100644 index 000000000..d4fe0f5ae --- /dev/null +++ b/packages/encryption-module/src/lib/__test__/unit/encryption.module.spec.ts @@ -0,0 +1,22 @@ +import { RawAesKeyringNode, RawAesWrappingSuiteIdentifier } from '@aws-crypto/client-node' +import { EncryptionModule } from '../../encryption.module' +import { generateKeyEncryptionKey } from '../../encryption.util' + +describe(EncryptionModule.name, () => { + describe('registerAsync', () => { + it('creates a dynamic module with a custom keyring', async () => { + const module = EncryptionModule.registerAsync({ + useFactory: () => ({ + keyring: new RawAesKeyringNode({ + keyName: 'test.key.name', + keyNamespace: 'test.key.namespace', + unencryptedMasterKey: generateKeyEncryptionKey('test-password', 'test-salt'), + wrappingSuite: RawAesWrappingSuiteIdentifier.AES256_GCM_IV12_TAG16_NO_PADDING + }) + }) + }) + + expect(module).toBeDefined() + }) + }) +}) diff --git a/packages/encryption-module/src/lib/__test__/unit/encryption.service.spec.ts b/packages/encryption-module/src/lib/__test__/unit/encryption.service.spec.ts new file mode 100644 index 000000000..3ebc4aaa4 --- /dev/null +++ b/packages/encryption-module/src/lib/__test__/unit/encryption.service.spec.ts @@ -0,0 +1,45 @@ +import { RawAesKeyringNode, RawAesWrappingSuiteIdentifier } from '@aws-crypto/client-node' +import { Test } from '@nestjs/testing' +import { MODULE_OPTIONS_TOKEN } from '../../encryption.module-definition' +import { EncryptionService } from '../../encryption.service' +import { generateKeyEncryptionKey } from '../../encryption.util' + +describe(EncryptionService.name, () => { + let encryptionService: EncryptionService + + beforeEach(async () => { + const keyring = new RawAesKeyringNode({ + keyName: 'test.key.name', + keyNamespace: 'test.key.namespace', + unencryptedMasterKey: generateKeyEncryptionKey('test-password', 'test-salt'), + wrappingSuite: RawAesWrappingSuiteIdentifier.AES256_GCM_IV12_TAG16_NO_PADDING + }) + + const module = await Test.createTestingModule({ + providers: [ + EncryptionService, + { + provide: MODULE_OPTIONS_TOKEN, + useValue: { keyring } + } + ] + }).compile() + + encryptionService = module.get(EncryptionService) + }) + + it('encrypts given string', async () => { + const value = 'shh... this is a secret' + const cipher = await encryptionService.encrypt(value) + + expect(cipher).not.toEqual(value) + }) + + it('decrypts given cipher', async () => { + const value = 'shh... this is a secret' + const cipher = await encryptionService.encrypt(value) + const decrypted = await encryptionService.decrypt(cipher) + + expect(decrypted.toString()).toEqual(value) + }) +}) diff --git a/packages/encryption-module/src/lib/__test__/unit/encryption.util.spec.ts b/packages/encryption-module/src/lib/__test__/unit/encryption.util.spec.ts new file mode 100644 index 000000000..2d6cc0801 --- /dev/null +++ b/packages/encryption-module/src/lib/__test__/unit/encryption.util.spec.ts @@ -0,0 +1,20 @@ +import { generateKeyEncryptionKey } from '../../encryption.util' + +describe('generateKeyEncryptionKey', () => { + const password = 'test-password' + const salt = 'test-salt' + + it('generates a standard kek from password and salt', () => { + const kekOne = generateKeyEncryptionKey(password, salt) + const kekTwo = generateKeyEncryptionKey(password, salt) + + expect(kekOne).toEqual(kekTwo) + expect(kekOne.length).toEqual(32) + }) + + it('generates a kek with a custom length', () => { + const kek = generateKeyEncryptionKey(password, salt, { lenght: 64 }) + + expect(kek.length).toEqual(64) + }) +}) diff --git a/packages/encryption-module/src/lib/encryption.constant.ts b/packages/encryption-module/src/lib/encryption.constant.ts new file mode 100644 index 000000000..afdf1a6e5 --- /dev/null +++ b/packages/encryption-module/src/lib/encryption.constant.ts @@ -0,0 +1,10 @@ +import { RawAesWrappingSuiteIdentifier } from '@aws-crypto/client-node' + +export const DEFAULT_ENCRYPTION_CONTEXT = { + purpose: 'data-encryption', + app: 'armory.encryption-module' +} + +export const DEFAULT_KEY_NAMESPACE = 'narval.armory.engine' + +export const DEFAULT_WRAPPING_SUITE = RawAesWrappingSuiteIdentifier.AES256_GCM_IV12_TAG16_NO_PADDING diff --git a/packages/encryption-module/src/lib/encryption.exception.ts b/packages/encryption-module/src/lib/encryption.exception.ts new file mode 100644 index 000000000..62c45c22c --- /dev/null +++ b/packages/encryption-module/src/lib/encryption.exception.ts @@ -0,0 +1 @@ +export class EncryptionException extends Error {} diff --git a/packages/encryption-module/src/lib/encryption.module-definition.ts b/packages/encryption-module/src/lib/encryption.module-definition.ts new file mode 100644 index 000000000..007857e6c --- /dev/null +++ b/packages/encryption-module/src/lib/encryption.module-definition.ts @@ -0,0 +1,6 @@ +import { ConfigurableModuleBuilder } from '@nestjs/common' +import { EncryptionModuleOption } from './encryption.type' + +export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = new ConfigurableModuleBuilder() + .setFactoryMethodName('create') + .build() diff --git a/packages/encryption-module/src/lib/encryption.module.ts b/packages/encryption-module/src/lib/encryption.module.ts new file mode 100644 index 000000000..1876ef86f --- /dev/null +++ b/packages/encryption-module/src/lib/encryption.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } from './encryption.module-definition' +import { EncryptionService } from './encryption.service' + +export const EncryptionModuleOptionProvider = MODULE_OPTIONS_TOKEN + +@Module({ + providers: [EncryptionService], + exports: [EncryptionService] +}) +export class EncryptionModule extends ConfigurableModuleClass {} diff --git a/packages/encryption-module/src/lib/encryption.service.ts b/packages/encryption-module/src/lib/encryption.service.ts new file mode 100644 index 000000000..ed9181d78 --- /dev/null +++ b/packages/encryption-module/src/lib/encryption.service.ts @@ -0,0 +1,49 @@ +import { Hex, toBytes } from '@narval/policy-engine-shared' +import { Inject, Injectable } from '@nestjs/common' +import { DEFAULT_ENCRYPTION_CONTEXT } from './encryption.constant' +import { EncryptionException } from './encryption.exception' +import { MODULE_OPTIONS_TOKEN } from './encryption.module-definition' +import { EncryptionModuleOption, Keyring } from './encryption.type' +import { getClient } from './encryption.util' + +@Injectable() +export class EncryptionService { + constructor(@Inject(MODULE_OPTIONS_TOKEN) private options: EncryptionModuleOption) {} + + async encrypt(value: string | Buffer | Uint8Array): Promise { + const { encrypt } = getClient() + const { result } = await encrypt(this.getKeyring(), value, { + encryptionContext: DEFAULT_ENCRYPTION_CONTEXT + }) + + return result + } + + async decrypt(ciphertext: Buffer | Uint8Array | Hex): Promise { + let ciphertextBuffer = ciphertext + if (typeof ciphertext === 'string') { + ciphertextBuffer = toBytes(ciphertext) + } + + const { decrypt } = getClient() + const { plaintext, messageHeader } = await decrypt(this.getKeyring(), ciphertextBuffer) + + // Verify the context wasn't changed. + const { encryptionContext } = messageHeader + Object.entries(DEFAULT_ENCRYPTION_CONTEXT).forEach(([key, value]) => { + if (encryptionContext[key] !== value) { + throw new EncryptionException('Encryption context does not match expected values') + } + }) + + return plaintext + } + + getKeyring(): Keyring { + if (this.options.keyring) { + return this.options.keyring + } + + throw new EncryptionException('Missing keyring. It seems the encryption module was not properly registered') + } +} diff --git a/packages/encryption-module/src/lib/encryption.type.ts b/packages/encryption-module/src/lib/encryption.type.ts new file mode 100644 index 000000000..106e4d1a4 --- /dev/null +++ b/packages/encryption-module/src/lib/encryption.type.ts @@ -0,0 +1,7 @@ +import { KmsKeyringNode, RawAesKeyringNode } from '@aws-crypto/client-node' + +export type Keyring = RawAesKeyringNode | KmsKeyringNode + +export type EncryptionModuleOption = { + keyring?: Keyring +} diff --git a/packages/encryption-module/src/lib/encryption.util.ts b/packages/encryption-module/src/lib/encryption.util.ts new file mode 100644 index 000000000..97445bf54 --- /dev/null +++ b/packages/encryption-module/src/lib/encryption.util.ts @@ -0,0 +1,74 @@ +import { CommitmentPolicy, RawAesKeyringNode, buildClient as buildAwsClient } from '@aws-crypto/client-node' +import { toHex } from '@narval/policy-engine-shared' +import { generateKeySync, pbkdf2Sync } from 'crypto' +import { DEFAULT_ENCRYPTION_CONTEXT, DEFAULT_KEY_NAMESPACE, DEFAULT_WRAPPING_SUITE } from './encryption.constant' + +export const isolateBuffer = (buffer: Buffer): Buffer => { + const newBuffer = Buffer.alloc(buffer.length) + buffer.copy(newBuffer, 0, 0, buffer.length) + + return newBuffer +} + +export const generateKeyEncryptionKey = ( + password: string, + salt: string, + options?: { iterations?: number; lenght: number } +): Buffer => { + const iterations = options?.lenght || 1_000_000 + const length = options?.lenght || 32 + + const kek = pbkdf2Sync(password.normalize(), salt.normalize(), iterations, length, 'sha256') + + return kek +} + +const buildKeyEncryptionKeyring = (kek: Buffer) => { + return new RawAesKeyringNode({ + keyName: 'armory.engine.kek', + unencryptedMasterKey: isolateBuffer(kek), + keyNamespace: DEFAULT_KEY_NAMESPACE, + wrappingSuite: DEFAULT_WRAPPING_SUITE + }) +} + +export const getClient = () => { + return buildAwsClient(CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT) +} + +export const encryptMaterKey = async (kek: Buffer, cleartext: Buffer): Promise => { + // Encrypt the Master Key with the Key Encryption Key. + const keyring = buildKeyEncryptionKeyring(kek) + const { result } = await getClient().encrypt(keyring, cleartext, { + encryptionContext: DEFAULT_ENCRYPTION_CONTEXT + }) + + return result +} + +export const decryptMasterKey = async (kek: Buffer, ciphertext: Uint8Array): Promise => { + const keyring = buildKeyEncryptionKeyring(kek) + const { plaintext, messageHeader } = await getClient().decrypt(keyring, ciphertext) + const { encryptionContext } = messageHeader + + // Verify the context wasn't changed. + Object.entries(DEFAULT_ENCRYPTION_CONTEXT).forEach(([key, value]) => { + if (encryptionContext[key] !== value) { + throw new Error('Encryption Context does not match expected values') + } + }) + + return plaintext +} + +export const generateMasterKey = async (kek: Buffer): Promise => { + const mk = generateKeySync('aes', { length: 256 }) + const mkBuffer = mk.export() + + // Encrypt it with the Key Encryption Key (KEK) that was derived from + // the a password and salt. + const encryptedMk = await encryptMaterKey(kek, mkBuffer) + const encryptedMkString = toHex(encryptedMk) + + return encryptedMkString +} diff --git a/packages/encryption-module/tsconfig.json b/packages/encryption-module/tsconfig.json new file mode 100644 index 000000000..9f7af61b1 --- /dev/null +++ b/packages/encryption-module/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "forceConsistentCasingInFileNames": true, + "module": "commonjs", + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true, + "strict": true, + "strictPropertyInitialization": false + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/encryption-module/tsconfig.lib.json b/packages/encryption-module/tsconfig.lib.json new file mode 100644 index 000000000..c297a2487 --- /dev/null +++ b/packages/encryption-module/tsconfig.lib.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"], + "target": "es2021", + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/packages/encryption-module/tsconfig.spec.json b/packages/encryption-module/tsconfig.spec.json new file mode 100644 index 000000000..f6d8ffcc9 --- /dev/null +++ b/packages/encryption-module/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 2d39e0afc..8b6ad853b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -13,6 +13,7 @@ "noImplicitAny": true, "noImplicitThis": true, "paths": { + "@narval/encryption-module": ["packages/encryption-module/src/index.ts"], "@narval/policy-engine-shared": ["packages/policy-engine-shared/src/index.ts"], "@narval/signature": ["packages/signature/src/index.ts"], "@narval/transaction-engine-module": ["packages/transaction-engine-module/src/index.ts"],