From 69abb531ddad72109c7f7b171c3783550551aa0c Mon Sep 17 00:00:00 2001 From: Matt Schoch Date: Wed, 28 Feb 2024 12:31:44 -0500 Subject: [PATCH] WIP wiring up KEK, MK, CEK keygen & encrypting/storing the MK --- apps/policy-engine/.env.default | 3 + apps/policy-engine/src/app/app.module.ts | 4 +- .../src/keyring/core/keyring.service.ts | 113 ++++++++++++++++++ .../src/keyring/keyring.module.ts | 21 ++++ .../repository/keyring.repository.ts | 31 +++++ apps/policy-engine/src/main.ts | 2 +- .../policy-engine/src/policy-engine.config.ts | 12 +- .../service/test-prisma.service.ts | 14 +-- 8 files changed, 186 insertions(+), 14 deletions(-) create mode 100644 apps/policy-engine/src/keyring/core/keyring.service.ts create mode 100644 apps/policy-engine/src/keyring/keyring.module.ts create mode 100644 apps/policy-engine/src/keyring/persistence/repository/keyring.repository.ts diff --git a/apps/policy-engine/.env.default b/apps/policy-engine/.env.default index 9768b9a5f..2dc85fed3 100644 --- a/apps/policy-engine/.env.default +++ b/apps/policy-engine/.env.default @@ -4,3 +4,6 @@ PORT=3010 POLICY_ENGINE_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/engine?schema=public" +ENGINE_UID="local-dev-engine-instance-1" + +MASTER_PASSWORD="unsafe-local-dev-master-password" diff --git a/apps/policy-engine/src/app/app.module.ts b/apps/policy-engine/src/app/app.module.ts index f1cdca190..f81389bfd 100644 --- a/apps/policy-engine/src/app/app.module.ts +++ b/apps/policy-engine/src/app/app.module.ts @@ -2,6 +2,7 @@ import { HttpModule } from '@nestjs/axios' import { Module, ValidationPipe } from '@nestjs/common' import { ConfigModule } from '@nestjs/config' import { APP_PIPE } from '@nestjs/core' +import { KeyringModule } from '../keyring/keyring.module' import { load } from '../policy-engine.config' import { AppController } from './app.controller' import { AppService } from './app.service' @@ -16,7 +17,8 @@ import { EntityRepository } from './persistence/repository/entity.repository' load: [load], isGlobal: true }), - HttpModule + HttpModule, + KeyringModule ], controllers: [AppController, AdminController], providers: [ diff --git a/apps/policy-engine/src/keyring/core/keyring.service.ts b/apps/policy-engine/src/keyring/core/keyring.service.ts new file mode 100644 index 000000000..2d248c6a6 --- /dev/null +++ b/apps/policy-engine/src/keyring/core/keyring.service.ts @@ -0,0 +1,113 @@ +import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import crypto from 'crypto' +import { Config } from '../../policy-engine.config' +import { KeyringRepository } from '../persistence/repository/keyring.repository' + +const IV_LENGTH = 16 +const AUTH_TAG_LENGTH = 16 + +@Injectable() +export class KeyringService implements OnApplicationBootstrap { + private logger = new Logger(KeyringService.name) + + private engineUid: string + + private kek: Buffer + + private masterPassword: string + + private masterKey: Buffer + + private adminApiKey: Buffer + + constructor( + private keyringRepository: KeyringRepository, + @Inject(ConfigService) configService: ConfigService + ) { + this.engineUid = configService.get('engineUid', { infer: true }) + this.masterPassword = configService.get('masterPassword', { infer: true }) + } + + async onApplicationBootstrap(): Promise { + this.logger.log('Keyring Service boot') + let engine = await this.keyringRepository.getEngine(this.engineUid) + + // Derive the Key Encryption Key (KEK) from the master password using PBKDF2 + this.kek = this.deriveKek(this.masterPassword) + + if (!engine) { + // New Engine, set it up + engine = await this.firstTimeSetup() + } + + this.masterKey = this.decryptWithKey(engine.masterKey, this.kek) + this.adminApiKey = this.decryptWithKey(engine.adminApiKey, this.kek) + } + + private deriveKek(password: string): Buffer { + // Derive the Key Encryption Key (KEK) from the master password using PBKDF2 + const kek = crypto.pbkdf2Sync(password.normalize(), this.engineUid.normalize(), 1000000, 32, 'sha256') + this.logger.log('Derived KEK', { kek: kek.toString('hex') }) + return kek + } + + decryptWithKey(encryptedString: string, key: Buffer): Buffer { + const encryptedBuffer = Buffer.from(encryptedString, 'hex') + // IV and AuthTag are prepend/appended, so slice them off + const iv = encryptedBuffer.subarray(0, IV_LENGTH) + const authTag = encryptedBuffer.subarray(encryptedBuffer.length - AUTH_TAG_LENGTH) + const encrypted = encryptedBuffer.subarray(IV_LENGTH, encryptedBuffer.length - AUTH_TAG_LENGTH) + + // Decrypt the data with the key + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv, { authTagLength: AUTH_TAG_LENGTH }) + decipher.setAuthTag(authTag) + let decrypted = decipher.update(encrypted) + decrypted = Buffer.concat([decrypted, decipher.final()]) + + return decrypted + } + + encryptWithKey(data: Buffer, key: Buffer): string { + const iv = crypto.randomBytes(IV_LENGTH) + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv, { authTagLength: AUTH_TAG_LENGTH }) + let encrypted = cipher.update(data) + encrypted = Buffer.concat([encrypted, cipher.final()]) + const authTag = cipher.getAuthTag() + // Concatenate the IV, encrypted key, and auth tag since those are not-secret and needed for decryption + const result = Buffer.concat([iv, encrypted, authTag]) + return result.toString('hex') + } + + private async firstTimeSetup() { + // Generate a new Master Key (MK) with AES256 + const mk = crypto.generateKeySync('aes', { length: 256 }) + const mkBuffer = mk.export() + + // Generate an Admin API Key, just a random 32-byte string + const adminApiKeyBuffer = crypto.randomBytes(32) + + // Encrypt the Master Key (MK) with the Key Encryption Key (KEK) + const encryptedMk = this.encryptWithKey(mkBuffer, this.kek) + const encryptedApiKey = this.encryptWithKey(adminApiKeyBuffer, this.kek) + + // Save the Result. + const engine = await this.keyringRepository.createEngine(this.engineUid, encryptedMk, encryptedApiKey) + + this.logger.log('Engine Initial Setup Complete') + this.logger.log('Admin API Key -- DO NOT LOSE THIS', adminApiKeyBuffer.toString('hex')) + return engine + } + + // Verify if a given string matches our internal Admin Api Key + verifyAdminApiKey(apiKey: string): boolean { + return apiKey === this.adminApiKey.toString('hex') + } + + deriveContentEncryptionKey(keyId: string) { + // Derive a CEK from the MK+keyId using HKDF + const cek = crypto.hkdfSync('sha256', this.masterKey, keyId, 'content', 32) + this.logger.log('Derived KEK', { cek: Buffer.from(cek).toString('hex') }) + return Buffer.from(cek) + } +} diff --git a/apps/policy-engine/src/keyring/keyring.module.ts b/apps/policy-engine/src/keyring/keyring.module.ts new file mode 100644 index 000000000..d4db1ac59 --- /dev/null +++ b/apps/policy-engine/src/keyring/keyring.module.ts @@ -0,0 +1,21 @@ +import { HttpModule } from '@nestjs/axios' +import { Module, ValidationPipe } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { APP_PIPE } from '@nestjs/core' +import { KeyringRepository } from '../keyring/persistence/repository/keyring.repository' +import { PersistenceModule } from '../shared/module/persistence/persistence.module' +import { KeyringService } from './core/keyring.service' + +@Module({ + imports: [ConfigModule.forRoot(), HttpModule, PersistenceModule], + controllers: [], + providers: [ + KeyringService, + KeyringRepository, + { + provide: APP_PIPE, + useClass: ValidationPipe + } + ] +}) +export class KeyringModule {} diff --git a/apps/policy-engine/src/keyring/persistence/repository/keyring.repository.ts b/apps/policy-engine/src/keyring/persistence/repository/keyring.repository.ts new file mode 100644 index 000000000..3745089d2 --- /dev/null +++ b/apps/policy-engine/src/keyring/persistence/repository/keyring.repository.ts @@ -0,0 +1,31 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common' +import { PrismaService } from '../../../shared/module/persistence/service/prisma.service' + +@Injectable() +export class KeyringRepository implements OnModuleInit { + private logger = new Logger(KeyringRepository.name) + + constructor(private prismaService: PrismaService) {} + + async onModuleInit() { + this.logger.log('KeyringRepository initialized') + } + + async getEngine(engineUid: string) { + return this.prismaService.engine.findUnique({ + where: { + uid: engineUid + } + }) + } + + async createEngine(engineUid: string, masterKey: string, adminApiKey: string) { + return this.prismaService.engine.create({ + data: { + uid: engineUid, + masterKey, + adminApiKey + } + }) + } +} diff --git a/apps/policy-engine/src/main.ts b/apps/policy-engine/src/main.ts index 9c69e1132..b17a3c481 100644 --- a/apps/policy-engine/src/main.ts +++ b/apps/policy-engine/src/main.ts @@ -55,7 +55,7 @@ async function bootstrap() { ) ) - logger.log(`AuthZ is running on port ${port}`) + logger.log(`Policy Engine is running on port ${port}`) } bootstrap() diff --git a/apps/policy-engine/src/policy-engine.config.ts b/apps/policy-engine/src/policy-engine.config.ts index a72ffc9b3..7a6eb8a4a 100644 --- a/apps/policy-engine/src/policy-engine.config.ts +++ b/apps/policy-engine/src/policy-engine.config.ts @@ -10,8 +10,10 @@ const ConfigSchema = z.object({ env: z.nativeEnum(Env), port: z.coerce.number(), database: z.object({ - url: z.string().startsWith('file:') - }) + url: z.string().startsWith('postgresql:') + }), + engineUid: z.string().optional(), + masterPassword: z.string().optional() }) export type Config = z.infer @@ -21,8 +23,10 @@ export const load = (): Config => { env: process.env.NODE_ENV, port: process.env.PORT, database: { - url: process.env.ENGINE_DATABASE_URL - } + url: process.env.POLICY_ENGINE_DATABASE_URL + }, + engineUid: process.env.ENGINE_UID, + masterPassword: process.env.MASTER_PASSWORD }) if (result.success) { diff --git a/apps/policy-engine/src/shared/module/persistence/service/test-prisma.service.ts b/apps/policy-engine/src/shared/module/persistence/service/test-prisma.service.ts index 445094154..b64e99ec1 100644 --- a/apps/policy-engine/src/shared/module/persistence/service/test-prisma.service.ts +++ b/apps/policy-engine/src/shared/module/persistence/service/test-prisma.service.ts @@ -12,21 +12,19 @@ export class TestPrismaService { async truncateAll(): Promise { const tablenames = await this.prisma.$queryRaw< - Array<{ name: string }> - >`SELECT name FROM sqlite_master WHERE type='table'` + Array<{ tablename: string }> + >`SELECT tablename FROM pg_tables WHERE schemaname='public'` - for (const { name } of tablenames) { - if (name !== '_prisma_migrations') { + for (const { tablename } of tablenames) { + if (tablename !== '_prisma_migrations') { try { - await this.prisma.$executeRawUnsafe( - `DELETE FROM ${name}; UPDATE SQLITE_SEQUENCE SET seq = 0 WHERE name = '${name}';` - ) + await this.prisma.$executeRawUnsafe(`TRUNCATE TABLE "public"."${tablename}" CASCADE;`) } catch (error) { // The logger may be intentionally silented during tests. Thus, we use // console.log to ensure engineers will see the error in the stdout. // // eslint-disable-next-line no-console - console.error('TestPrismaService DELETE error', error) + console.error('TestPrismaService truncateAll error', error) } } }