From 36748ca3f06f1cd5988504bdcf8b83f2fc9bfbdd Mon Sep 17 00:00:00 2001 From: Matt Schoch Date: Thu, 14 Mar 2024 12:44:28 -0400 Subject: [PATCH 1/4] scaffold of app bootstrap & tenant provision --- Makefile | 1 + apps/vault/.env.default | 13 ++ apps/vault/.env.test.default | 13 ++ apps/vault/.eslintrc.json | 18 +++ apps/vault/.lintstagedrc.js | 6 + apps/vault/Makefile | 129 +++++++++++++++ apps/vault/README.md | 42 +++++ apps/vault/jest.config.ts | 19 +++ apps/vault/jest.e2e.ts | 9 ++ apps/vault/jest.integration.ts | 9 ++ apps/vault/jest.setup.ts | 30 ++++ apps/vault/jest.unit.ts | 9 ++ apps/vault/project.json | 81 ++++++++++ apps/vault/src/cli.ts | 8 + apps/vault/src/cli/cli.module.ts | 9 ++ .../src/cli/command/provision.command.ts | 49 ++++++ apps/vault/src/main.config.ts | 56 +++++++ apps/vault/src/main.constant.ts | 7 + apps/vault/src/main.module.ts | 35 ++++ apps/vault/src/main.ts | 61 +++++++ .../shared/exception/application.exception.ts | 25 +++ .../encryption-module-option.factory.ts | 58 +++++++ .../__test__/unit/admin-api-key.guard.spec.ts | 54 +++++++ .../src/shared/guard/admin-api-key.guard.ts | 25 +++ .../core/repository/key-value.repository.ts | 7 + .../encrypt-key-value.service.spec.ts | 52 ++++++ .../integration/key-value.service.spec.ts | 44 +++++ .../core/service/encrypt-key-value.service.ts | 43 +++++ .../core/service/key-value.service.ts | 23 +++ .../module/key-value/key-value.module.ts | 35 ++++ .../in-memory-key-value.repository.ts | 23 +++ .../repository/prisma-key-value.repository.ts | 46 ++++++ .../module/persistence/persistence.module.ts | 12 ++ .../20240314144559_init/migration.sql | 16 ++ .../schema/migrations/migration_lock.toml | 3 + .../module/persistence/schema/schema.prisma | 30 ++++ .../src/shared/module/persistence/seed.ts | 32 ++++ .../__test__/unit/prisma.service.spec.ts | 17 ++ .../persistence/service/prisma.service.ts | 54 +++++++ .../service/test-prisma.service.ts | 32 ++++ apps/vault/src/shared/schema/app.schema.ts | 7 + apps/vault/src/shared/schema/tenant.schema.ts | 10 ++ .../src/shared/testing/encryption.testing.ts | 14 ++ apps/vault/src/shared/type/domain.type.ts | 23 +++ apps/vault/src/shared/type/entities.types.ts | 43 +++++ .../src/tenant/__test__/e2e/tenant.spec.ts | 126 +++++++++++++++ .../tenant/core/service/bootstrap.service.ts | 23 +++ .../src/tenant/core/service/tenant.service.ts | 44 +++++ .../http/rest/controller/tenant.controller.ts | 26 +++ .../tenant/http/rest/dto/create-tenant.dto.ts | 8 + .../__test__/unit/tenant.repository.spec.ts | 66 ++++++++ .../repository/tenant.repository.ts | 83 ++++++++++ apps/vault/src/tenant/tenant.module.ts | 33 ++++ .../app-not-provisioned.exception.ts | 11 ++ .../src/vault/core/service/app.service.ts | 42 +++++ .../vault/core/service/provision.service.ts | 65 ++++++++ .../src/vault/core/service/signing.service.ts | 76 +++++++++ .../vault/src/vault/evaluation-request.dto.ts | 150 ++++++++++++++++++ .../__test__/unit/app.repository.spec.ts | 53 +++++++ .../persistence/repository/app.repository.ts | 37 +++++ .../vault/persistence/repository/mock_data.ts | 55 +++++++ apps/vault/src/vault/vault.controller.ts | 23 +++ apps/vault/src/vault/vault.module.ts | 44 +++++ apps/vault/src/vault/vault.service.ts | 13 ++ apps/vault/tsconfig.app.json | 12 ++ apps/vault/tsconfig.json | 17 ++ apps/vault/tsconfig.spec.json | 9 ++ apps/vault/webpack.config.js | 8 + 68 files changed, 2356 insertions(+) create mode 100644 apps/vault/.env.default create mode 100644 apps/vault/.env.test.default create mode 100644 apps/vault/.eslintrc.json create mode 100644 apps/vault/.lintstagedrc.js create mode 100644 apps/vault/Makefile create mode 100644 apps/vault/README.md create mode 100644 apps/vault/jest.config.ts create mode 100644 apps/vault/jest.e2e.ts create mode 100644 apps/vault/jest.integration.ts create mode 100644 apps/vault/jest.setup.ts create mode 100644 apps/vault/jest.unit.ts create mode 100644 apps/vault/project.json create mode 100644 apps/vault/src/cli.ts create mode 100644 apps/vault/src/cli/cli.module.ts create mode 100644 apps/vault/src/cli/command/provision.command.ts create mode 100644 apps/vault/src/main.config.ts create mode 100644 apps/vault/src/main.constant.ts create mode 100644 apps/vault/src/main.module.ts create mode 100644 apps/vault/src/main.ts create mode 100644 apps/vault/src/shared/exception/application.exception.ts create mode 100644 apps/vault/src/shared/factory/encryption-module-option.factory.ts create mode 100644 apps/vault/src/shared/guard/__test__/unit/admin-api-key.guard.spec.ts create mode 100644 apps/vault/src/shared/guard/admin-api-key.guard.ts create mode 100644 apps/vault/src/shared/module/key-value/core/repository/key-value.repository.ts create mode 100644 apps/vault/src/shared/module/key-value/core/service/__test__/integration/encrypt-key-value.service.spec.ts create mode 100644 apps/vault/src/shared/module/key-value/core/service/__test__/integration/key-value.service.spec.ts create mode 100644 apps/vault/src/shared/module/key-value/core/service/encrypt-key-value.service.ts create mode 100644 apps/vault/src/shared/module/key-value/core/service/key-value.service.ts create mode 100644 apps/vault/src/shared/module/key-value/key-value.module.ts create mode 100644 apps/vault/src/shared/module/key-value/persistence/repository/in-memory-key-value.repository.ts create mode 100644 apps/vault/src/shared/module/key-value/persistence/repository/prisma-key-value.repository.ts create mode 100644 apps/vault/src/shared/module/persistence/persistence.module.ts create mode 100644 apps/vault/src/shared/module/persistence/schema/migrations/20240314144559_init/migration.sql create mode 100644 apps/vault/src/shared/module/persistence/schema/migrations/migration_lock.toml create mode 100644 apps/vault/src/shared/module/persistence/schema/schema.prisma create mode 100644 apps/vault/src/shared/module/persistence/seed.ts create mode 100644 apps/vault/src/shared/module/persistence/service/__test__/unit/prisma.service.spec.ts create mode 100644 apps/vault/src/shared/module/persistence/service/prisma.service.ts create mode 100644 apps/vault/src/shared/module/persistence/service/test-prisma.service.ts create mode 100644 apps/vault/src/shared/schema/app.schema.ts create mode 100644 apps/vault/src/shared/schema/tenant.schema.ts create mode 100644 apps/vault/src/shared/testing/encryption.testing.ts create mode 100644 apps/vault/src/shared/type/domain.type.ts create mode 100644 apps/vault/src/shared/type/entities.types.ts create mode 100644 apps/vault/src/tenant/__test__/e2e/tenant.spec.ts create mode 100644 apps/vault/src/tenant/core/service/bootstrap.service.ts create mode 100644 apps/vault/src/tenant/core/service/tenant.service.ts create mode 100644 apps/vault/src/tenant/http/rest/controller/tenant.controller.ts create mode 100644 apps/vault/src/tenant/http/rest/dto/create-tenant.dto.ts create mode 100644 apps/vault/src/tenant/persistence/repository/__test__/unit/tenant.repository.spec.ts create mode 100644 apps/vault/src/tenant/persistence/repository/tenant.repository.ts create mode 100644 apps/vault/src/tenant/tenant.module.ts create mode 100644 apps/vault/src/vault/core/exception/app-not-provisioned.exception.ts create mode 100644 apps/vault/src/vault/core/service/app.service.ts create mode 100644 apps/vault/src/vault/core/service/provision.service.ts create mode 100644 apps/vault/src/vault/core/service/signing.service.ts create mode 100644 apps/vault/src/vault/evaluation-request.dto.ts create mode 100644 apps/vault/src/vault/persistence/repository/__test__/unit/app.repository.spec.ts create mode 100644 apps/vault/src/vault/persistence/repository/app.repository.ts create mode 100644 apps/vault/src/vault/persistence/repository/mock_data.ts create mode 100644 apps/vault/src/vault/vault.controller.ts create mode 100644 apps/vault/src/vault/vault.module.ts create mode 100644 apps/vault/src/vault/vault.service.ts create mode 100644 apps/vault/tsconfig.app.json create mode 100644 apps/vault/tsconfig.json create mode 100644 apps/vault/tsconfig.spec.json create mode 100644 apps/vault/webpack.config.js diff --git a/Makefile b/Makefile index f50c0a9d3..b0f143197 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ include ./apps/armory/Makefile include ./apps/devtool/Makefile include ./apps/policy-engine/Makefile +include ./apps/vault/Makefile include ./packages/policy-engine-shared/Makefile include ./packages/transaction-request-intent/Makefile include ./packages/signature/Makefile diff --git a/apps/vault/.env.default b/apps/vault/.env.default new file mode 100644 index 000000000..3e973deb7 --- /dev/null +++ b/apps/vault/.env.default @@ -0,0 +1,13 @@ +NODE_ENV=development + +PORT=3010 + +APP_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/vault?schema=public" + +APP_UID="local-dev-vault-instance-1" + +MASTER_PASSWORD="unsafe-local-dev-master-password" + +KEYRING_TYPE="raw" + +# MASTER_AWS_KMS_ARN="arn:aws:kms:us-east-2:728783560968:key/f6aa3ddb-47c3-4f31-977d-b93205bb23d1" diff --git a/apps/vault/.env.test.default b/apps/vault/.env.test.default new file mode 100644 index 000000000..0353a26aa --- /dev/null +++ b/apps/vault/.env.test.default @@ -0,0 +1,13 @@ +NODE_ENV=test + +PORT=3010 + +APP_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/vault-test?schema=public" + +APP_UID="local-dev-vault-instance-1" + +MASTER_PASSWORD="unsafe-local-test-master-password" + +KEYRING_TYPE="raw" + +# MASTER_AWS_KMS_ARN="arn:aws:kms:us-east-2:728783560968:key/f6aa3ddb-47c3-4f31-977d-b93205bb23d1" diff --git a/apps/vault/.eslintrc.json b/apps/vault/.eslintrc.json new file mode 100644 index 000000000..9d9c0db55 --- /dev/null +++ b/apps/vault/.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/apps/vault/.lintstagedrc.js b/apps/vault/.lintstagedrc.js new file mode 100644 index 000000000..3cc2540b7 --- /dev/null +++ b/apps/vault/.lintstagedrc.js @@ -0,0 +1,6 @@ +module.exports = { + '*.{ts,tsx}': (filenames) => [ + `eslint --no-error-on-unmatched-pattern ${filenames.join(' ')}; echo "ESLint completed with exit code $?"`, + `prettier --write ${filenames.join(' ')}` + ] +} diff --git a/apps/vault/Makefile b/apps/vault/Makefile new file mode 100644 index 000000000..860da7d68 --- /dev/null +++ b/apps/vault/Makefile @@ -0,0 +1,129 @@ +VAULT_PROJECT_NAME := vault +VAULT_PROJECT_DIR := ./apps/vault +VAULT_DATABASE_SCHEMA := ${VAULT_PROJECT_DIR}/src/shared/module/persistence/schema/schema.prisma + +# === Start === + +vault/start/dev: + npx nx serve ${VAULT_PROJECT_NAME} + +# === Setup === + +vault/setup: + make vault/copy-default-env + make vault/db/setup + make vault/test/db/setup + make vault/cli CMD=provision + +vault/copy-default-env: + cp ${VAULT_PROJECT_DIR}/.env.default ${VAULT_PROJECT_DIR}/.env + cp ${VAULT_PROJECT_DIR}/.env.test.default ${VAULT_PROJECT_DIR}/.env.test + +# === Build === + +vault/build/script: + npx tsc --project ${VAULT_PROJECT_DIR}/tsconfig.app.json + npx tsc-alias --project ${VAULT_PROJECT_DIR}/tsconfig.app.json + +# == Code format == + +vault/format: + npx nx format:write --projects ${VAULT_PROJECT_NAME} + +vault/lint: + npx nx lint ${VAULT_PROJECT_NAME} -- --fix + +vault/format/check: + npx nx format:check --projects ${VAULT_PROJECT_NAME} + +vault/lint/check: + npx nx lint ${VAULT_PROJECT_NAME} + +# === Database === + +vault/db/generate-types: + npx prisma generate \ + --schema ${VAULT_DATABASE_SCHEMA} + +vault/db/migrate: + npx dotenv -e ${VAULT_PROJECT_DIR}/.env -- \ + prisma migrate dev \ + --schema ${VAULT_DATABASE_SCHEMA} + +vault/db/setup: + @echo "" + @echo "${TERM_GREEN}🛠️ Setting up Vault development database${TERM_NO_COLOR}" + @echo "" + npx dotenv -e ${VAULT_PROJECT_DIR}/.env -- \ + prisma migrate reset \ + --schema ${VAULT_DATABASE_SCHEMA} \ + --force + make vault/db/seed + + @echo "" + @echo "${TERM_GREEN}🛠️ Setting up Vault test database${TERM_NO_COLOR}" + @echo "" + make vault/test/db/setup + +vault/db/create-migration: + npx dotenv -e ${VAULT_PROJECT_DIR}/.env -- \ + prisma migrate dev \ + --schema ${VAULT_DATABASE_SCHEMA} \ + --name ${NAME} + +# To maintain seed data within their respective modules and then import them +# into the main seed.ts file for execution, it's necessary to compile the +# project and resolve its path aliases before running the vanilla JavaScript +# seed entry point. +vault/db/seed: + npx dotenv -e ${VAULT_PROJECT_DIR}/.env -- \ + ts-node -r tsconfig-paths/register --project ${VAULT_PROJECT_DIR}/tsconfig.app.json ${VAULT_PROJECT_DIR}/src/shared/module/persistence/seed.ts + + +# === Testing === + +vault/test/db/setup: + npx dotenv -e ${VAULT_PROJECT_DIR}/.env.test --override -- \ + prisma migrate reset \ + --schema ${VAULT_DATABASE_SCHEMA} \ + --skip-seed \ + --force + + +vault/test/type: + make vault/db/generate-types + npx tsc \ + --project ${VAULT_PROJECT_DIR}/tsconfig.app.json \ + --noEmit + +vault/test/unit: + npx nx test:unit ${VAULT_PROJECT_NAME} -- ${ARGS} + +vault/test/unit/watch: + make vault/test/unit ARGS=--watch + +vault/test/integration: + npx nx test:integration ${VAULT_PROJECT_NAME} -- ${ARGS} + +vault/test/integration/watch: + make vault/test/integration ARGS=--watch + +vault/test/e2e: + npx nx test:e2e ${VAULT_PROJECT_NAME} -- ${ARGS} + +vault/test/e2e/watch: + make vault/test/e2e ARGS=--watch + +vault/test: + make vault/test/unit + make vault/test/integration + make vault/test/e2e + +# === CLI === + +vault/cli: + npx dotenv -e ${VAULT_PROJECT_DIR}/.env -- \ + ts-node -r tsconfig-paths/register \ + --project ${VAULT_PROJECT_DIR}/tsconfig.app.json \ + ${VAULT_PROJECT_DIR}/src/cli.ts ${CMD} + diff --git a/apps/vault/README.md b/apps/vault/README.md new file mode 100644 index 000000000..8d9dc2d2b --- /dev/null +++ b/apps/vault/README.md @@ -0,0 +1,42 @@ +# Vault + +TBD + +## Requirements + +## Getting started + +```bash +make vault/setup +``` + +## Running + +```bash +make vault/start/dev +``` + +## Testing + +```bash +make vault/test/type +make vault/test/unit +make vault/test/integration +make vault/test/e2e +``` + +## Formatting + +```bash +make vault/format +make vault/lint + +make vault/format/check +make vault/lint/check +``` + +## CLI + +```bash +make vault/cli CMD=help +``` diff --git a/apps/vault/jest.config.ts b/apps/vault/jest.config.ts new file mode 100644 index 000000000..b3d7101ff --- /dev/null +++ b/apps/vault/jest.config.ts @@ -0,0 +1,19 @@ +import type { Config } from 'jest' + +const config: Config = { + displayName: 'vault', + moduleFileExtensions: ['ts', 'js', 'html'], + preset: '../../jest.preset.js', + setupFiles: ['/jest.setup.ts'], + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': [ + 'ts-jest', + { + tsconfig: '/tsconfig.spec.json' + } + ] + } +} + +export default config diff --git a/apps/vault/jest.e2e.ts b/apps/vault/jest.e2e.ts new file mode 100644 index 000000000..b7d56d62b --- /dev/null +++ b/apps/vault/jest.e2e.ts @@ -0,0 +1,9 @@ +import type { Config } from 'jest' +import sharedConfig from './jest.config' + +const config: Config = { + ...sharedConfig, + testMatch: ['/**/__test__/e2e/**/*.spec.ts'] +} + +export default config diff --git a/apps/vault/jest.integration.ts b/apps/vault/jest.integration.ts new file mode 100644 index 000000000..533d40428 --- /dev/null +++ b/apps/vault/jest.integration.ts @@ -0,0 +1,9 @@ +import type { Config } from 'jest' +import sharedConfig from './jest.config' + +const config: Config = { + ...sharedConfig, + testMatch: ['/**/__test__/integration/**/*.spec.ts'] +} + +export default config diff --git a/apps/vault/jest.setup.ts b/apps/vault/jest.setup.ts new file mode 100644 index 000000000..1de8a7a32 --- /dev/null +++ b/apps/vault/jest.setup.ts @@ -0,0 +1,30 @@ +import dotenv from 'dotenv' +import fs from 'fs' +import nock from 'nock' + +const testEnvFile = `${__dirname}/.env.test` + +// Ensure a test environment variable file exists because of the override config +// loading mechanics below. +if (!fs.existsSync(testEnvFile)) { + throw new Error('No .env.test file found. Please create one by running "make vault/copy-default-env".') +} + +// By default, dotenv always loads .env and then you can override with .env.test +// But this is confusing, because then you have to look in multiple files to know which envs are loaded +// So we will clear all envs and then load .env.test +// NOTE: This will also override any CLI-declared envs (e.g. `MY_ENV=test jest`) +for (const prop in process.env) { + if (Object.prototype.hasOwnProperty.call(process.env, prop)) { + delete process.env[prop] + } +} + +dotenv.config({ path: testEnvFile, override: true }) + +// Disable outgoing HTTP requests to avoid flaky tests. +nock.disableNetConnect() + +// Enable outgoing HTTP requests to 127.0.0.1 to allow E2E tests with +// supertestwith supertest to work. +nock.enableNetConnect('127.0.0.1') diff --git a/apps/vault/jest.unit.ts b/apps/vault/jest.unit.ts new file mode 100644 index 000000000..9376919b3 --- /dev/null +++ b/apps/vault/jest.unit.ts @@ -0,0 +1,9 @@ +import type { Config } from 'jest' +import sharedConfig from './jest.config' + +const config: Config = { + ...sharedConfig, + testMatch: ['/**/__test__/unit/**/*.spec.ts'] +} + +export default config diff --git a/apps/vault/project.json b/apps/vault/project.json new file mode 100644 index 000000000..dc9e77fb9 --- /dev/null +++ b/apps/vault/project.json @@ -0,0 +1,81 @@ +{ + "name": "vault", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/vault/src", + "projectType": "application", + "targets": { + "build": { + "executor": "@nx/webpack:webpack", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "production", + "options": { + "target": "node", + "compiler": "tsc", + "outputPath": "dist/apps/vault", + "main": "apps/vault/src/main.ts", + "tsConfig": "apps/vault/tsconfig.app.json", + "isolatedConfig": true, + "webpackConfig": "apps/vault/webpack.config.js" + }, + "configurations": { + "development": {}, + "production": {} + } + }, + "serve": { + "executor": "@nx/js:node", + "defaultConfiguration": "development", + "options": { + "buildTarget": "vault:build" + }, + "configurations": { + "development": { + "buildTarget": "vault:build:development" + }, + "production": { + "buildTarget": "vault:build:production" + } + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["apps/vault/**/*.ts"] + } + }, + "test:type": { + "executor": "nx:run-commands", + "options": { + "command": "npx tsc --noEmit --project apps/vault/tsconfig.app.json" + } + }, + "test:unit": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "apps/vault/jest.unit.ts", + "verbose": true + } + }, + "test:integration": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "apps/vault/jest.integration.ts", + "verbose": true, + "runInBand": true + } + }, + "test:e2e": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "apps/vault/jest.e2e.ts", + "verbose": true, + "runInBand": true + } + } + }, + "tags": [] +} diff --git a/apps/vault/src/cli.ts b/apps/vault/src/cli.ts new file mode 100644 index 000000000..f1262d231 --- /dev/null +++ b/apps/vault/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/vault/src/cli/cli.module.ts b/apps/vault/src/cli/cli.module.ts new file mode 100644 index 000000000..5fdb87a2d --- /dev/null +++ b/apps/vault/src/cli/cli.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common' +import { VaultModule } from '../vault/vault.module' +import { ProvisionCommand } from './command/provision.command' + +@Module({ + imports: [VaultModule], + providers: [ProvisionCommand] +}) +export class CliModule {} diff --git a/apps/vault/src/cli/command/provision.command.ts b/apps/vault/src/cli/command/provision.command.ts new file mode 100644 index 000000000..088e304dd --- /dev/null +++ b/apps/vault/src/cli/command/provision.command.ts @@ -0,0 +1,49 @@ +import { ConfigService } from '@nestjs/config' +import { Command, CommandRunner } from 'nest-commander' +import { Config } from '../../main.config' +import { AppService } from '../../vault/core/service/app.service' +import { ProvisionService } from '../../vault/core/service/provision.service' + +@Command({ + name: 'provision', + description: 'Provision the app for the first time' +}) +export class ProvisionCommand extends CommandRunner { + constructor( + private provisionService: ProvisionService, + private appService: AppService, + private configService: ConfigService + ) { + super() + } + + async run(): Promise { + const app = await this.appService.getApp() + + if (app && app.masterKey) { + return console.log('App already provisioned') + } + + await this.provisionService.provision() + + try { + const keyring = this.configService.get('keyring', { infer: true }) + const app = await this.appService.getAppOrThrow() + + console.log('App ID:', app.id) + console.log('App admin API key:', app.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? ${app.masterKey ? '✅' : '❌'}`) + } + + if (keyring.type === 'awskms') { + console.log(`Is encryption master KMS ARN set? ${keyring.masterAwsKmsArn ? '✅' : '❌'}`) + } + } catch (error) { + console.log('Something went wrong provisioning the app', error) + } + } +} diff --git a/apps/vault/src/main.config.ts b/apps/vault/src/main.config.ts new file mode 100644 index 000000000..505bfff08 --- /dev/null +++ b/apps/vault/src/main.config.ts @@ -0,0 +1,56 @@ +import { z } from 'zod' + +export enum Env { + DEVELOPMENT = 'development', + TEST = 'test', + PRODUCTION = 'production' +} + +const configSchema = z.object({ + env: z.nativeEnum(Env), + port: z.coerce.number(), + database: z.object({ + url: z.string().startsWith('postgresql:') + }), + app: z.object({ + id: z.string(), + masterKey: z.string().optional() + }), + keyring: z.union([ + z.object({ + type: z.literal('raw'), + masterPassword: z.string() + }), + z.object({ + type: z.literal('awskms'), + masterAwsKmsArn: z.string() + }) + ]) +}) + +export type Config = z.infer + +export const load = (): Config => { + const result = configSchema.safeParse({ + env: process.env.NODE_ENV, + port: process.env.PORT, + database: { + url: process.env.APP_DATABASE_URL + }, + app: { + id: process.env.APP_UID, + masterKey: process.env.MASTER_KEY + }, + keyring: { + type: process.env.KEYRING_TYPE, + masterAwsKmsArn: process.env.MASTER_AWS_KMS_ARN, + masterPassword: process.env.MASTER_PASSWORD + } + }) + + if (result.success) { + return result.data + } + + throw new Error(`Invalid application configuration: ${result.error.message}`) +} diff --git a/apps/vault/src/main.constant.ts b/apps/vault/src/main.constant.ts new file mode 100644 index 000000000..c2dd2de1f --- /dev/null +++ b/apps/vault/src/main.constant.ts @@ -0,0 +1,7 @@ +import { RawAesWrappingSuiteIdentifier } from '@aws-crypto/client-node' + +export const REQUEST_HEADER_API_KEY = 'x-api-key' + +export const ENCRYPTION_KEY_NAMESPACE = 'armory.vault' +export const ENCRYPTION_KEY_NAME = 'storage-encryption' +export const ENCRYPTION_WRAPPING_SUITE = RawAesWrappingSuiteIdentifier.AES256_GCM_IV12_TAG16_NO_PADDING diff --git a/apps/vault/src/main.module.ts b/apps/vault/src/main.module.ts new file mode 100644 index 000000000..002e0ca6e --- /dev/null +++ b/apps/vault/src/main.module.ts @@ -0,0 +1,35 @@ +import { EncryptionModule } from '@narval/encryption-module' +import { Module, ValidationPipe, forwardRef } from '@nestjs/common' +import { ConfigModule, ConfigService } from '@nestjs/config' +import { APP_PIPE } from '@nestjs/core' +import { load } from './main.config' +import { EncryptionModuleOptionFactory } from './shared/factory/encryption-module-option.factory' +import { TenantModule } from './tenant/tenant.module' +import { AppService } from './vault/core/service/app.service' +import { VaultModule } from './vault/vault.module' + +@Module({ + imports: [ + // Infrastructure + ConfigModule.forRoot({ + load: [load], + isGlobal: true + }), + EncryptionModule.registerAsync({ + imports: [forwardRef(() => VaultModule)], + inject: [ConfigService, AppService], + useClass: EncryptionModuleOptionFactory + }), + + // Domain + VaultModule, + TenantModule + ], + providers: [ + { + provide: APP_PIPE, + useClass: ValidationPipe + } + ] +}) +export class MainModule {} diff --git a/apps/vault/src/main.ts b/apps/vault/src/main.ts new file mode 100644 index 000000000..605643b5e --- /dev/null +++ b/apps/vault/src/main.ts @@ -0,0 +1,61 @@ +import { INestApplication, Logger, ValidationPipe } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { NestFactory } from '@nestjs/core' +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' +import { lastValueFrom, map, of, switchMap } from 'rxjs' +import { MainModule } from './main.module' + +/** + * Adds Swagger documentation to the application. + * + * @param app - The INestApplication instance. + * @returns The modified INestApplication instance. + */ +const withSwagger = (app: INestApplication): INestApplication => { + const document = SwaggerModule.createDocument( + app, + new DocumentBuilder() + .setTitle('Vault') + .setDescription('The next generation of authorization for web3') + .setVersion('1.0') + .build() + ) + SwaggerModule.setup('docs', app, document) + + return app +} + +/** + * Adds global pipes to the application. + * + * @param app - The INestApplication instance. + * @returns The modified INestApplication instance. + */ +const withGlobalPipes = (app: INestApplication): INestApplication => { + app.useGlobalPipes(new ValidationPipe()) + + return app +} + +async function bootstrap() { + const logger = new Logger('AppBootstrap') + const application = await NestFactory.create(MainModule, { bodyParser: true }) + const configService = application.get(ConfigService) + const port = configService.get('PORT') + + if (!port) { + throw new Error('Missing PORT environment variable') + } + + await lastValueFrom( + of(application).pipe( + map(withSwagger), + map(withGlobalPipes), + switchMap((app) => app.listen(port)) + ) + ) + + logger.log(`App is running on port ${port}`) +} + +bootstrap() diff --git a/apps/vault/src/shared/exception/application.exception.ts b/apps/vault/src/shared/exception/application.exception.ts new file mode 100644 index 000000000..5f4f90d20 --- /dev/null +++ b/apps/vault/src/shared/exception/application.exception.ts @@ -0,0 +1,25 @@ +import { HttpException, HttpStatus } from '@nestjs/common' + +export type ApplicationExceptionParams = { + message: string + suggestedHttpStatusCode: HttpStatus + context?: unknown + origin?: Error +} + +export class ApplicationException extends HttpException { + readonly context: unknown + readonly origin?: Error + + constructor(params: ApplicationExceptionParams) { + super(params.message, params.suggestedHttpStatusCode) + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, ApplicationException) + } + + this.name = this.constructor.name + this.context = params.context + this.origin = params.origin + } +} diff --git a/apps/vault/src/shared/factory/encryption-module-option.factory.ts b/apps/vault/src/shared/factory/encryption-module-option.factory.ts new file mode 100644 index 000000000..5992a6908 --- /dev/null +++ b/apps/vault/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 { Config } from '../../main.config' +import { ENCRYPTION_KEY_NAME, ENCRYPTION_KEY_NAMESPACE, ENCRYPTION_WRAPPING_SUITE } from '../../main.constant' +import { AppService } from '../../vault/core/service/app.service' + +@Injectable() +export class EncryptionModuleOptionFactory { + private logger = new Logger(EncryptionModuleOptionFactory.name) + + constructor( + private appService: AppService, + private configService: ConfigService + ) {} + + async create(): Promise { + const keyring = this.configService.get('keyring', { infer: true }) + const app = await this.appService.getApp() + + // NOTE: An undefined app at boot time only happens during the + // provisioning. + if (!app) { + this.logger.warn('Booting the encryption module without a keyring. Please, provision the app.') + + return { + keyring: undefined + } + } + + if (keyring.type === 'raw') { + if (!app.masterKey) { + throw new Error('Master key not set') + } + + const kek = generateKeyEncryptionKey(keyring.masterPassword, app.id) + const unencryptedMasterKey = await decryptMasterKey(kek, toBytes(app.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/vault/src/shared/guard/__test__/unit/admin-api-key.guard.spec.ts b/apps/vault/src/shared/guard/__test__/unit/admin-api-key.guard.spec.ts new file mode 100644 index 000000000..8b0e6bee3 --- /dev/null +++ b/apps/vault/src/shared/guard/__test__/unit/admin-api-key.guard.spec.ts @@ -0,0 +1,54 @@ +import { ExecutionContext } from '@nestjs/common' +import { mock } from 'jest-mock-extended' +import { REQUEST_HEADER_API_KEY } from '../../../../main.constant' +import { AppService } from '../../../../vault/core/service/app.service' +import { ApplicationException } from '../../../exception/application.exception' +import { AdminApiKeyGuard } from '../../admin-api-key.guard' + +describe(AdminApiKeyGuard.name, () => { + const mockExecutionContext = (apiKey?: string) => { + const headers = { + [REQUEST_HEADER_API_KEY]: apiKey + } + const request = { headers } + + return { + switchToHttp: () => ({ + getRequest: () => request + }) + } as ExecutionContext + } + + const mockAppService = (adminApiKey: string = 'test-admin-api-key') => { + const app = { + adminApiKey, + id: 'test-app-id', + masterKey: 'test-master-key' + } + + const serviceMock = mock() + serviceMock.getApp.mockResolvedValue(app) + serviceMock.getAppOrThrow.mockResolvedValue(app) + + return serviceMock + } + + it(`throws an error when ${REQUEST_HEADER_API_KEY} header is missing`, async () => { + const guard = new AdminApiKeyGuard(mockAppService()) + + await expect(guard.canActivate(mockExecutionContext())).rejects.toThrow(ApplicationException) + }) + + it(`returns true when ${REQUEST_HEADER_API_KEY} matches the app admin api key`, async () => { + const adminApiKey = 'test-admin-api-key' + const guard = new AdminApiKeyGuard(mockAppService(adminApiKey)) + + expect(await guard.canActivate(mockExecutionContext(adminApiKey))).toEqual(true) + }) + + it(`returns false when ${REQUEST_HEADER_API_KEY} does not matches the app admin api key`, async () => { + const guard = new AdminApiKeyGuard(mockAppService('test-admin-api-key')) + + expect(await guard.canActivate(mockExecutionContext('another-api-key'))).toEqual(false) + }) +}) diff --git a/apps/vault/src/shared/guard/admin-api-key.guard.ts b/apps/vault/src/shared/guard/admin-api-key.guard.ts new file mode 100644 index 000000000..8ad1e70ae --- /dev/null +++ b/apps/vault/src/shared/guard/admin-api-key.guard.ts @@ -0,0 +1,25 @@ +import { CanActivate, ExecutionContext, HttpStatus, Injectable } from '@nestjs/common' +import { REQUEST_HEADER_API_KEY } from '../../main.constant' +import { AppService } from '../../vault/core/service/app.service' +import { ApplicationException } from '../exception/application.exception' + +@Injectable() +export class AdminApiKeyGuard implements CanActivate { + constructor(private appService: AppService) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest() + const apiKey = req.headers[REQUEST_HEADER_API_KEY] + + if (!apiKey) { + throw new ApplicationException({ + message: `Missing or invalid ${REQUEST_HEADER_API_KEY} header`, + suggestedHttpStatusCode: HttpStatus.UNAUTHORIZED + }) + } + + const app = await this.appService.getAppOrThrow() + + return app.adminApiKey === apiKey + } +} diff --git a/apps/vault/src/shared/module/key-value/core/repository/key-value.repository.ts b/apps/vault/src/shared/module/key-value/core/repository/key-value.repository.ts new file mode 100644 index 000000000..b70505d50 --- /dev/null +++ b/apps/vault/src/shared/module/key-value/core/repository/key-value.repository.ts @@ -0,0 +1,7 @@ +export const KeyValueRepository = Symbol('KeyValueRepository') + +export interface KeyValueRepository { + get(key: string): Promise + set(key: string, value: string): Promise + delete(key: string): Promise +} diff --git a/apps/vault/src/shared/module/key-value/core/service/__test__/integration/encrypt-key-value.service.spec.ts b/apps/vault/src/shared/module/key-value/core/service/__test__/integration/encrypt-key-value.service.spec.ts new file mode 100644 index 000000000..edb8c238c --- /dev/null +++ b/apps/vault/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 '../../../../../../../main.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/vault/src/shared/module/key-value/core/service/__test__/integration/key-value.service.spec.ts b/apps/vault/src/shared/module/key-value/core/service/__test__/integration/key-value.service.spec.ts new file mode 100644 index 000000000..a45376f73 --- /dev/null +++ b/apps/vault/src/shared/module/key-value/core/service/__test__/integration/key-value.service.spec.ts @@ -0,0 +1,44 @@ +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { load } from '../../../../../../../main.config' +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 + let inMemoryKeyValueRepository: InMemoryKeyValueRepository + + beforeEach(async () => { + inMemoryKeyValueRepository = new InMemoryKeyValueRepository() + + const module = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + load: [load], + isGlobal: true + }) + ], + providers: [ + KeyValueService, + { + provide: KeyValueRepository, + useValue: inMemoryKeyValueRepository + } + ] + }).compile() + + service = module.get(KeyValueService) + }) + + describe('set', () => { + it('sets dencrypted value in the key-value storage', async () => { + const key = 'test-key' + const value = 'plain value' + + await service.set(key, value) + + expect(await service.get(key)).toEqual(value) + }) + }) +}) diff --git a/apps/vault/src/shared/module/key-value/core/service/encrypt-key-value.service.ts b/apps/vault/src/shared/module/key-value/core/service/encrypt-key-value.service.ts new file mode 100644 index 000000000..ccdc8a74b --- /dev/null +++ b/apps/vault/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 app, it's considered + * untrusted so the app 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/vault/src/shared/module/key-value/core/service/key-value.service.ts b/apps/vault/src/shared/module/key-value/core/service/key-value.service.ts new file mode 100644 index 000000000..8215aad17 --- /dev/null +++ b/apps/vault/src/shared/module/key-value/core/service/key-value.service.ts @@ -0,0 +1,23 @@ +import { Inject, Injectable } from '@nestjs/common' +import { KeyValueRepository } from '../repository/key-value.repository' + +@Injectable() +export class KeyValueService { + constructor(@Inject(KeyValueRepository) private keyValueRepository: KeyValueRepository) {} + + async get(key: string): Promise { + return this.keyValueRepository.get(key) + } + + async set(key: string, value: string): Promise { + return this.keyValueRepository.set(key, value) + } + + async delete(key: string): Promise { + return this.keyValueRepository.delete(key) + } + + static encode(value: unknown): string { + return JSON.stringify(value) + } +} diff --git a/apps/vault/src/shared/module/key-value/key-value.module.ts b/apps/vault/src/shared/module/key-value/key-value.module.ts new file mode 100644 index 000000000..85e4731da --- /dev/null +++ b/apps/vault/src/shared/module/key-value/key-value.module.ts @@ -0,0 +1,35 @@ +import { EncryptionModule } from '@narval/encryption-module' +import { Module, forwardRef } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { AppService } from '../../../vault/core/service/app.service' +import { VaultModule } from '../../../vault/vault.module' +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: [ + PersistenceModule, + EncryptionModule.registerAsync({ + imports: [forwardRef(() => VaultModule)], + inject: [ConfigService, AppService], + useClass: EncryptionModuleOptionFactory + }) + ], + providers: [ + KeyValueService, + EncryptKeyValueService, + InMemoryKeyValueRepository, + PrismaKeyValueRepository, + { + provide: KeyValueRepository, + useExisting: PrismaKeyValueRepository + } + ], + exports: [KeyValueService, EncryptKeyValueService] +}) +export class KeyValueModule {} diff --git a/apps/vault/src/shared/module/key-value/persistence/repository/in-memory-key-value.repository.ts b/apps/vault/src/shared/module/key-value/persistence/repository/in-memory-key-value.repository.ts new file mode 100644 index 000000000..df88feab2 --- /dev/null +++ b/apps/vault/src/shared/module/key-value/persistence/repository/in-memory-key-value.repository.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common' +import { KeyValueRepository } from '../../core/repository/key-value.repository' + +@Injectable() +export class InMemoryKeyValueRepository implements KeyValueRepository { + private store = new Map() + + async get(key: string): Promise { + return this.store.get(key) || null + } + + async set(key: string, value: string): Promise { + this.store.set(key, value) + + return true + } + + async delete(key: string): Promise { + this.store.delete(key) + + return true + } +} diff --git a/apps/vault/src/shared/module/key-value/persistence/repository/prisma-key-value.repository.ts b/apps/vault/src/shared/module/key-value/persistence/repository/prisma-key-value.repository.ts new file mode 100644 index 000000000..630f236a6 --- /dev/null +++ b/apps/vault/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/vault/src/shared/module/persistence/persistence.module.ts b/apps/vault/src/shared/module/persistence/persistence.module.ts new file mode 100644 index 000000000..7b3beebd8 --- /dev/null +++ b/apps/vault/src/shared/module/persistence/persistence.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { load } from '../../../main.config' +import { PrismaService } from './service/prisma.service' +import { TestPrismaService } from './service/test-prisma.service' + +@Module({ + imports: [ConfigModule.forRoot({ load: [load] })], + exports: [PrismaService, TestPrismaService], + providers: [PrismaService, TestPrismaService] +}) +export class PersistenceModule {} diff --git a/apps/vault/src/shared/module/persistence/schema/migrations/20240314144559_init/migration.sql b/apps/vault/src/shared/module/persistence/schema/migrations/20240314144559_init/migration.sql new file mode 100644 index 000000000..c416f563b --- /dev/null +++ b/apps/vault/src/shared/module/persistence/schema/migrations/20240314144559_init/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "vault" ( + "id" TEXT NOT NULL, + "master_key" TEXT, + "admin_api_key" TEXT, + + CONSTRAINT "vault_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/vault/src/shared/module/persistence/schema/migrations/migration_lock.toml b/apps/vault/src/shared/module/persistence/schema/migrations/migration_lock.toml new file mode 100644 index 000000000..fbffa92c2 --- /dev/null +++ b/apps/vault/src/shared/module/persistence/schema/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/apps/vault/src/shared/module/persistence/schema/schema.prisma b/apps/vault/src/shared/module/persistence/schema/schema.prisma new file mode 100644 index 000000000..332237bf8 --- /dev/null +++ b/apps/vault/src/shared/module/persistence/schema/schema.prisma @@ -0,0 +1,30 @@ +generator client { + provider = "prisma-client-js" + // Output into a separate subdirectory so multiple schemas can be used in a + // monorepo. + // + // Reference: https://github.com/nrwl/nx-recipes/tree/main/nestjs-prisma + output = "../../../../../../../node_modules/@prisma/client/vault" +} + +datasource db { + provider = "postgresql" + url = env("APP_DATABASE_URL") +} + +model Vault { + id String @id + masterKey String? @map("master_key") + adminApiKey String? @map("admin_api_key") + + @@map("vault") +} + +// 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/vault/src/shared/module/persistence/seed.ts b/apps/vault/src/shared/module/persistence/seed.ts new file mode 100644 index 000000000..113330ff6 --- /dev/null +++ b/apps/vault/src/shared/module/persistence/seed.ts @@ -0,0 +1,32 @@ +/* eslint-disable */ +import { Logger } from '@nestjs/common' +import { PrismaClient, Vault } from '@prisma/client/vault' + +const prisma = new PrismaClient() + +const vault: Vault = { + id: '7d704a62-d15e-4382-a826-1eb41563043b', + adminApiKey: 'admin-api-key-xxx', + masterKey: 'master-key-xxx' +} + +async function main() { + const logger = new Logger('VaultSeed') + + logger.log('Seeding Vault database') + await prisma.$transaction(async (txn) => { + // await txn.vault.create({ data: vault }) + }) + + logger.log('Vault database germinated 🌱') +} + +main() + .then(async () => { + await prisma.$disconnect() + }) + .catch(async (e) => { + console.error(e) + await prisma.$disconnect() + process.exit(1) + }) diff --git a/apps/vault/src/shared/module/persistence/service/__test__/unit/prisma.service.spec.ts b/apps/vault/src/shared/module/persistence/service/__test__/unit/prisma.service.spec.ts new file mode 100644 index 000000000..e7709946f --- /dev/null +++ b/apps/vault/src/shared/module/persistence/service/__test__/unit/prisma.service.spec.ts @@ -0,0 +1,17 @@ +import { ConfigService } from '@nestjs/config' +import { mock } from 'jest-mock-extended' +import { PrismaService } from '../../prisma.service' + +describe(PrismaService.name, () => { + describe('constructor', () => { + it('does not throw when APP_DATABASE_URL is present', () => { + const configServiceMock = mock({ + get: jest.fn().mockReturnValue('postgresql://test:test@localhost:5432/test?schema=public') + }) + + expect(() => { + new PrismaService(configServiceMock) + }).not.toThrow() + }) + }) +}) diff --git a/apps/vault/src/shared/module/persistence/service/prisma.service.ts b/apps/vault/src/shared/module/persistence/service/prisma.service.ts new file mode 100644 index 000000000..553d9c940 --- /dev/null +++ b/apps/vault/src/shared/module/persistence/service/prisma.service.ts @@ -0,0 +1,54 @@ +import { Inject, Injectable, Logger, OnApplicationShutdown, OnModuleDestroy, OnModuleInit } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { PrismaClient } from '@prisma/client/vault' +import { Config } from '../../../../main.config' + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy, OnApplicationShutdown { + private logger = new Logger(PrismaService.name) + + constructor(@Inject(ConfigService) configService: ConfigService) { + const url = configService.get('database.url', { infer: true }) + + super({ + datasources: { + db: { url } + } + }) + } + + async onModuleInit() { + this.logger.log({ + message: 'Connecting to Prisma on database module initialization' + }) + + await this.$connect() + } + + async onModuleDestroy() { + this.logger.log({ + message: 'Disconnecting from Prisma on module destroy' + }) + + await this.$disconnect() + } + + // In Prisma v5, the `beforeExit` is no longer available. Instead, we use + // NestJS' application shutdown to disconnect from the database. The shutdown + // hooks are called when the process receives a termination event lig SIGhooks + // are called when the process receives a termination event lig SIGTERM. + // + // See also https://www.prisma.io/docs/guides/upgrade-guides/upgrading-versions/upgrading-to-prisma-5#removal-of-the-beforeexit-hook-from-the-library-engine + onApplicationShutdown(signal: string) { + this.logger.log({ + message: 'Disconnecting from Prisma on application shutdown', + signal + }) + + // The $disconnect method returns a promise, so idealy we should wait for it + // to finish. However, the onApplicationShutdown, returns `void` making it + // impossible to ensure the database will be properly disconnected before + // the shutdown. + this.$disconnect() + } +} diff --git a/apps/vault/src/shared/module/persistence/service/test-prisma.service.ts b/apps/vault/src/shared/module/persistence/service/test-prisma.service.ts new file mode 100644 index 000000000..0bfa9ad36 --- /dev/null +++ b/apps/vault/src/shared/module/persistence/service/test-prisma.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common' +import { PrismaClient } from '@prisma/client/vault' +import { PrismaService } from './prisma.service' + +@Injectable() +export class TestPrismaService { + constructor(private prisma: PrismaService) {} + + getClient(): PrismaClient { + return this.prisma + } + + async truncateAll(): Promise { + const tablenames = await this.prisma.$queryRaw< + Array<{ tablename: string }> + >`SELECT tablename FROM pg_tables WHERE schemaname='public'` + + for (const { tablename } of tablenames) { + if (tablename !== '_prisma_migrations') { + try { + 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 truncateAll error', error) + } + } + } + } +} diff --git a/apps/vault/src/shared/schema/app.schema.ts b/apps/vault/src/shared/schema/app.schema.ts new file mode 100644 index 000000000..135d952e8 --- /dev/null +++ b/apps/vault/src/shared/schema/app.schema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod' + +export const appSchema = z.object({ + id: z.string().min(1), + adminApiKey: z.string().min(1), + masterKey: z.string().min(1).optional() +}) diff --git a/apps/vault/src/shared/schema/tenant.schema.ts b/apps/vault/src/shared/schema/tenant.schema.ts new file mode 100644 index 000000000..c9e1098a2 --- /dev/null +++ b/apps/vault/src/shared/schema/tenant.schema.ts @@ -0,0 +1,10 @@ +import { z } from 'zod' + +export const tenantSchema = z.object({ + clientId: z.string(), + clientSecret: z.string(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date() +}) + +export const tenantIndexSchema = z.array(z.string()) diff --git a/apps/vault/src/shared/testing/encryption.testing.ts b/apps/vault/src/shared/testing/encryption.testing.ts new file mode 100644 index 000000000..252e1a8a1 --- /dev/null +++ b/apps/vault/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/apps/vault/src/shared/type/domain.type.ts b/apps/vault/src/shared/type/domain.type.ts new file mode 100644 index 000000000..39f44c7dc --- /dev/null +++ b/apps/vault/src/shared/type/domain.type.ts @@ -0,0 +1,23 @@ +import { ApprovalRequirement } from '@narval/policy-engine-shared' +import { z } from 'zod' +import { appSchema } from '../schema/app.schema' +import { tenantSchema } from '../schema/tenant.schema' + +export type Tenant = z.infer + +export type App = z.infer + +export type MatchedRule = { + policyName: string + policyId: string + type: 'permit' | 'forbid' + approvalsSatisfied: ApprovalRequirement[] + approvalsMissing: ApprovalRequirement[] +} + +export type VerifiedApproval = { + signature: string + userId: string + credentialId: string // The credential used for this approval + address?: string // Address, if the Credential is a EOA private key TODO: Do we need this? +} diff --git a/apps/vault/src/shared/type/entities.types.ts b/apps/vault/src/shared/type/entities.types.ts new file mode 100644 index 000000000..65a079d69 --- /dev/null +++ b/apps/vault/src/shared/type/entities.types.ts @@ -0,0 +1,43 @@ +import { AccountClassification, AccountType, Address, UserRole } from '@narval/policy-engine-shared' + +export type Organization = { + uid: string +} + +export type User = { + id: string // Pubkey + role: UserRole +} + +export type UserGroup = { + id: string + users: string[] // userIds +} + +export type Wallet = { + id: string + address: Address + accountType: AccountType + chainId?: number + assignees?: string[] // userIds +} + +export type WalletGroup = { + id: string + wallets: string[] // walletIds +} + +export type AddressBookAccount = { + id: string + address: Address + chainId: number + classification: AccountClassification +} + +export type Token = { + id: string + address: Address + symbol: string + chainId: number + decimals: number +} diff --git a/apps/vault/src/tenant/__test__/e2e/tenant.spec.ts b/apps/vault/src/tenant/__test__/e2e/tenant.spec.ts new file mode 100644 index 000000000..d455d40b0 --- /dev/null +++ b/apps/vault/src/tenant/__test__/e2e/tenant.spec.ts @@ -0,0 +1,126 @@ +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 { Config, load } from '../../../main.config' +import { REQUEST_HEADER_API_KEY } from '../../../main.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 { CreateTenantDto } from '../../../tenant/http/rest/dto/create-tenant.dto' +import { AppService } from '../../../vault/core/service/app.service' +import { TenantRepository } from '../../persistence/repository/tenant.repository' +import { TenantModule } from '../../tenant.module' + +describe('Tenant', () => { + let app: INestApplication + let module: TestingModule + let testPrismaService: TestPrismaService + let tenantRepository: TenantRepository + let appService: AppService + let configService: ConfigService + + const adminApiKey = 'test-admin-api-key' + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + load: [load], + isGlobal: true + }), + TenantModule + ] + }) + .overrideProvider(KeyValueRepository) + .useValue(new InMemoryKeyValueRepository()) + .overrideProvider(EncryptionModuleOptionProvider) + .useValue({ + keyring: getTestRawAesKeyring() + }) + .compile() + + app = module.createNestApplication() + + appService = module.get(AppService) + tenantRepository = module.get(TenantRepository) + testPrismaService = module.get(TestPrismaService) + configService = module.get>(ConfigService) + + await testPrismaService.truncateAll() + + await appService.save({ + id: configService.get('app.id', { infer: true }), + masterKey: 'unsafe-test-master-key', + adminApiKey + }) + + await app.init() + }) + + afterAll(async () => { + await testPrismaService.truncateAll() + await module.close() + await app.close() + }) + + describe('POST /tenants', () => { + const clientId = uuid() + + const payload: CreateTenantDto = { + clientId + } + + it('creates a new tenant', async () => { + const { status, body } = await request(app.getHttpServer()) + .post('/tenants') + .set(REQUEST_HEADER_API_KEY, adminApiKey) + .send(payload) + const actualTenant = await tenantRepository.findByClientId(clientId) + + expect(body).toMatchObject({ + clientId, + clientSecret: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String) + }) + expect(body).toEqual({ + ...actualTenant, + createdAt: actualTenant?.createdAt.toISOString(), + updatedAt: actualTenant?.updatedAt.toISOString() + }) + expect(status).toEqual(HttpStatus.CREATED) + }) + + it('responds with an error when clientId already exist', async () => { + await request(app.getHttpServer()).post('/tenants').set(REQUEST_HEADER_API_KEY, adminApiKey).send(payload) + + const { status, body } = await request(app.getHttpServer()) + .post('/tenants') + .set(REQUEST_HEADER_API_KEY, adminApiKey) + .send(payload) + + expect(body).toEqual({ + message: 'Tenant already exist', + statusCode: HttpStatus.BAD_REQUEST + }) + expect(status).toEqual(HttpStatus.BAD_REQUEST) + }) + + it('responds with forbidden when admin api key is invalid', async () => { + const { status, body } = await request(app.getHttpServer()) + .post('/tenants') + .set(REQUEST_HEADER_API_KEY, 'invalid-api-key') + .send(payload) + + expect(body).toMatchObject({ + message: 'Forbidden resource', + statusCode: HttpStatus.FORBIDDEN + }) + expect(status).toEqual(HttpStatus.FORBIDDEN) + }) + }) +}) diff --git a/apps/vault/src/tenant/core/service/bootstrap.service.ts b/apps/vault/src/tenant/core/service/bootstrap.service.ts new file mode 100644 index 000000000..c69c495c0 --- /dev/null +++ b/apps/vault/src/tenant/core/service/bootstrap.service.ts @@ -0,0 +1,23 @@ +import { Injectable, Logger } from '@nestjs/common' +import { TenantService } from './tenant.service' + +@Injectable() +export class BootstrapService { + private logger = new Logger(BootstrapService.name) + + constructor(private tenantService: TenantService) {} + + async boot(): Promise { + this.logger.log('Start app bootstrap') + + await this.syncTenants() + } + + private async syncTenants(): Promise { + const tenants = await this.tenantService.findAll() + + this.logger.log('Start syncing tenants', { + tenantsCount: tenants.length + }) + } +} diff --git a/apps/vault/src/tenant/core/service/tenant.service.ts b/apps/vault/src/tenant/core/service/tenant.service.ts new file mode 100644 index 000000000..a721a037e --- /dev/null +++ b/apps/vault/src/tenant/core/service/tenant.service.ts @@ -0,0 +1,44 @@ +import { HttpStatus, Injectable, Logger } from '@nestjs/common' +import { ApplicationException } from '../../../shared/exception/application.exception' +import { Tenant } from '../../../shared/type/domain.type' +import { TenantRepository } from '../../persistence/repository/tenant.repository' + +@Injectable() +export class TenantService { + private logger = new Logger(TenantService.name) + + constructor(private tenantRepository: TenantRepository) {} + + async findByClientId(clientId: string): Promise { + return this.tenantRepository.findByClientId(clientId) + } + + async onboard(tenant: Tenant): Promise { + const exists = await this.tenantRepository.findByClientId(tenant.clientId) + + if (exists) { + throw new ApplicationException({ + message: 'Tenant already exist', + suggestedHttpStatusCode: HttpStatus.BAD_REQUEST, + context: { clientId: tenant.clientId } + }) + } + + try { + await this.tenantRepository.save(tenant) + + return tenant + } catch (error) { + throw new ApplicationException({ + message: 'Failed to onboard new tenant', + suggestedHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY, + origin: error, + context: { tenant } + }) + } + } + + async findAll(): Promise { + return this.tenantRepository.findAll() + } +} diff --git a/apps/vault/src/tenant/http/rest/controller/tenant.controller.ts b/apps/vault/src/tenant/http/rest/controller/tenant.controller.ts new file mode 100644 index 000000000..a5e223884 --- /dev/null +++ b/apps/vault/src/tenant/http/rest/controller/tenant.controller.ts @@ -0,0 +1,26 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common' +import { randomBytes } from 'crypto' +import { v4 as uuid } from 'uuid' +import { AdminApiKeyGuard } from '../../../../shared/guard/admin-api-key.guard' +import { TenantService } from '../../../core/service/tenant.service' +import { CreateTenantDto } from '../dto/create-tenant.dto' + +@Controller('/tenants') +@UseGuards(AdminApiKeyGuard) +export class TenantController { + constructor(private tenantService: TenantService) {} + + @Post() + async create(@Body() body: CreateTenantDto) { + const now = new Date() + + const tenant = await this.tenantService.onboard({ + clientId: body.clientId || uuid(), + clientSecret: randomBytes(42).toString('hex'), + createdAt: now, + updatedAt: now + }) + + return tenant + } +} diff --git a/apps/vault/src/tenant/http/rest/dto/create-tenant.dto.ts b/apps/vault/src/tenant/http/rest/dto/create-tenant.dto.ts new file mode 100644 index 000000000..d545788f1 --- /dev/null +++ b/apps/vault/src/tenant/http/rest/dto/create-tenant.dto.ts @@ -0,0 +1,8 @@ +import { ApiPropertyOptional } from '@nestjs/swagger' +import { IsString } from 'class-validator' + +export class CreateTenantDto { + @IsString() + @ApiPropertyOptional() + clientId?: string +} diff --git a/apps/vault/src/tenant/persistence/repository/__test__/unit/tenant.repository.spec.ts b/apps/vault/src/tenant/persistence/repository/__test__/unit/tenant.repository.spec.ts new file mode 100644 index 000000000..9350787a2 --- /dev/null +++ b/apps/vault/src/tenant/persistence/repository/__test__/unit/tenant.repository.spec.ts @@ -0,0 +1,66 @@ +import { EncryptionModule } from '@narval/encryption-module' +import { Test } from '@nestjs/testing' +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' + +describe(TenantRepository.name, () => { + let repository: TenantRepository + let inMemoryKeyValueRepository: InMemoryKeyValueRepository + + const clientId = 'test-client-id' + + beforeEach(async () => { + inMemoryKeyValueRepository = new InMemoryKeyValueRepository() + + const module = await Test.createTestingModule({ + imports: [ + EncryptionModule.register({ + keyring: getTestRawAesKeyring() + }) + ], + providers: [ + KeyValueService, + TenantRepository, + EncryptKeyValueService, + { + provide: KeyValueRepository, + useValue: inMemoryKeyValueRepository + } + ] + }).compile() + + repository = module.get(TenantRepository) + }) + + describe('save', () => { + const now = new Date() + + const tenant: Tenant = { + clientId, + clientSecret: 'test-client-secret', + createdAt: now, + updatedAt: now + } + + it('saves a new tenant', async () => { + await repository.save(tenant) + + const value = await inMemoryKeyValueRepository.get(repository.getKey(tenant.clientId)) + const actualTenant = await repository.findByClientId(tenant.clientId) + + expect(value).not.toEqual(null) + expect(tenant).toEqual(actualTenant) + }) + + it('indexes the new tenant', async () => { + await repository.save(tenant) + + expect(await repository.getTenantIndex()).toEqual([tenant.clientId]) + }) + }) +}) diff --git a/apps/vault/src/tenant/persistence/repository/tenant.repository.ts b/apps/vault/src/tenant/persistence/repository/tenant.repository.ts new file mode 100644 index 000000000..bb41b8872 --- /dev/null +++ b/apps/vault/src/tenant/persistence/repository/tenant.repository.ts @@ -0,0 +1,83 @@ +import { Injectable } from '@nestjs/common' +import { compact } from 'lodash/fp' +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 encryptKeyValueService: EncryptKeyValueService) {} + + async findByClientId(clientId: string): Promise { + const value = await this.encryptKeyValueService.get(this.getKey(clientId)) + + if (value) { + return this.decode(value) + } + + return null + } + + async save(tenant: Tenant): Promise { + await this.encryptKeyValueService.set(this.getKey(tenant.clientId), this.encode(tenant)) + await this.index(tenant) + + return tenant + } + + async getTenantIndex(): Promise { + const index = await this.encryptKeyValueService.get(this.getIndexKey()) + + if (index) { + return this.decodeIndex(index) + } + + return [] + } + + // TODO: (@wcalderipe, 07/03/24) we need to rethink this strategy. If we use a + // SQL database, this could generate a massive amount of queries; thus, + // degrading the performance. + // + // An option is to move these general queries `findBy`, findAll`, etc to the + // KeyValeuRepository implementation letting each implementation pick the best + // strategy to solve the problem (e.g. where query in SQL) + async findAll(): Promise { + const ids = await this.getTenantIndex() + const tenants = await Promise.all(ids.map((id) => this.findByClientId(id))) + + return compact(tenants) + } + + getKey(clientId: string): string { + return `tenant:${clientId}` + } + + getIndexKey(): string { + return 'tenant:index' + } + + private async index(tenant: Tenant): Promise { + const currentIndex = await this.getTenantIndex() + + await this.encryptKeyValueService.set(this.getIndexKey(), this.encodeIndex([...currentIndex, tenant.clientId])) + + return true + } + + private encode(tenant: Tenant): string { + return EncryptKeyValueService.encode(tenantSchema.parse(tenant)) + } + + private decode(value: string): Tenant { + return tenantSchema.parse(JSON.parse(value)) + } + + private encodeIndex(value: string[]): string { + return EncryptKeyValueService.encode(tenantIndexSchema.parse(value)) + } + + private decodeIndex(value: string): string[] { + return tenantIndexSchema.parse(JSON.parse(value)) + } +} diff --git a/apps/vault/src/tenant/tenant.module.ts b/apps/vault/src/tenant/tenant.module.ts new file mode 100644 index 000000000..856f58916 --- /dev/null +++ b/apps/vault/src/tenant/tenant.module.ts @@ -0,0 +1,33 @@ +import { HttpModule } from '@nestjs/axios' +import { Module, OnApplicationBootstrap, ValidationPipe } from '@nestjs/common' +import { APP_PIPE } from '@nestjs/core' +import { AdminApiKeyGuard } from '../shared/guard/admin-api-key.guard' +import { KeyValueModule } from '../shared/module/key-value/key-value.module' +import { VaultModule } from '../vault/vault.module' +import { BootstrapService } from './core/service/bootstrap.service' +import { TenantService } from './core/service/tenant.service' +import { TenantController } from './http/rest/controller/tenant.controller' +import { TenantRepository } from './persistence/repository/tenant.repository' + +@Module({ + // NOTE: The AdminApiKeyGuard is the only reason we need the VaultModule. + imports: [HttpModule, KeyValueModule, VaultModule], + controllers: [TenantController], + providers: [ + AdminApiKeyGuard, + BootstrapService, + TenantRepository, + TenantService, + { + provide: APP_PIPE, + useClass: ValidationPipe + } + ] +}) +export class TenantModule implements OnApplicationBootstrap { + constructor(private bootstrapService: BootstrapService) {} + + async onApplicationBootstrap() { + await this.bootstrapService.boot() + } +} diff --git a/apps/vault/src/vault/core/exception/app-not-provisioned.exception.ts b/apps/vault/src/vault/core/exception/app-not-provisioned.exception.ts new file mode 100644 index 000000000..318d25c4f --- /dev/null +++ b/apps/vault/src/vault/core/exception/app-not-provisioned.exception.ts @@ -0,0 +1,11 @@ +import { HttpStatus } from '@nestjs/common' +import { ApplicationException } from '../../../shared/exception/application.exception' + +export class AppNotProvisionedException extends ApplicationException { + constructor() { + super({ + message: 'The app instance was not provisioned', + suggestedHttpStatusCode: HttpStatus.INTERNAL_SERVER_ERROR + }) + } +} diff --git a/apps/vault/src/vault/core/service/app.service.ts b/apps/vault/src/vault/core/service/app.service.ts new file mode 100644 index 000000000..94f554ffe --- /dev/null +++ b/apps/vault/src/vault/core/service/app.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { Config } from '../../../main.config' +import { App } from '../../../shared/type/domain.type' +import { AppRepository } from '../../persistence/repository/app.repository' +import { AppNotProvisionedException } from '../exception/app-not-provisioned.exception' + +@Injectable() +export class AppService { + constructor( + private configService: ConfigService, + private appRepository: AppRepository + ) {} + + async getAppOrThrow(): Promise { + const app = await this.getApp() + + if (app) { + return app + } + + throw new AppNotProvisionedException() + } + + async getApp(): Promise { + const app = await this.appRepository.findById(this.getId()) + + if (app) { + return app + } + + return null + } + + async save(app: App): Promise { + return this.appRepository.save(app) + } + + private getId(): string { + return this.configService.get('app.id', { infer: true }) + } +} diff --git a/apps/vault/src/vault/core/service/provision.service.ts b/apps/vault/src/vault/core/service/provision.service.ts new file mode 100644 index 000000000..d29bc8ae7 --- /dev/null +++ b/apps/vault/src/vault/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 '../../../main.config' +import { AppService } from './app.service' + +@Injectable() +export class ProvisionService { + private logger = new Logger(ProvisionService.name) + + constructor( + private configService: ConfigService, + private appService: AppService + ) {} + + async provision(): Promise { + this.logger.log('Start app provision') + + const app = await this.appService.getApp() + + const isFirstTime = app === null + + // IMPORTANT: The order of internal methods call matters. + + if (isFirstTime) { + await this.createApp() + await this.maybeSetupEncryption() + } + } + + private async createApp(): Promise { + this.logger.log('Generate admin API key and save app') + + await this.appService.save({ + id: this.getAppId(), + adminApiKey: randomBytes(20).toString('hex') + }) + } + + private async maybeSetupEncryption(): Promise { + // Get the app's latest state. + const app = await this.appService.getAppOrThrow() + + if (app.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 app master key') + + const { masterPassword } = keyring + const kek = generateKeyEncryptionKey(masterPassword, this.getAppId()) + const masterKey = await generateMasterKey(kek) + + await this.appService.save({ ...app, masterKey }) + } + } + + private getAppId(): string { + return this.configService.get('app.id', { infer: true }) + } +} diff --git a/apps/vault/src/vault/core/service/signing.service.ts b/apps/vault/src/vault/core/service/signing.service.ts new file mode 100644 index 000000000..8728ac5c5 --- /dev/null +++ b/apps/vault/src/vault/core/service/signing.service.ts @@ -0,0 +1,76 @@ +import { JsonWebKey, toHex } from '@narval/policy-engine-shared' +import { + Alg, + Payload, + SigningAlg, + buildSignerEip191, + buildSignerEs256k, + privateKeyToJwk, + signJwt +} from '@narval/signature' +import { Injectable } from '@nestjs/common' +import { secp256k1 } from '@noble/curves/secp256k1' + +// Optional additional configs, such as for MPC-based DKG. +type KeyGenerationOptions = { + keyId: string +} + +type KeyGenerationResponse = { + publicKey: JsonWebKey + privateKey?: JsonWebKey +} + +type SignOptions = { + alg?: SigningAlg +} + +@Injectable() +export class SigningService { + constructor() {} + + async generateSigningKey(alg: Alg, options?: KeyGenerationOptions): Promise { + if (alg === Alg.ES256K) { + const privateKey = toHex(secp256k1.utils.randomPrivateKey()) + const privateJwk = privateKeyToJwk(privateKey, options?.keyId) + + // Remove the privateKey from the public jwk + const publicJwk = { + ...privateJwk, + d: undefined + } + + return { + publicKey: publicJwk, + privateKey: privateJwk + } + } + + throw new Error('Unsupported algorithm') + } + + async sign(payload: Payload, jwk: JsonWebKey, opts: SignOptions = {}): Promise { + const alg: SigningAlg = opts.alg || jwk.alg + if (alg === SigningAlg.ES256K) { + if (!jwk.d) { + throw new Error('Missing private key') + } + const pk = jwk.d + + const jwt = await signJwt(payload, jwk, opts, buildSignerEs256k(pk)) + + return jwt + } else if (alg === SigningAlg.EIP191) { + if (!jwk.d) { + throw new Error('Missing private key') + } + const pk = jwk.d + + const jwt = await signJwt(payload, jwk, opts, buildSignerEip191(pk)) + + return jwt + } + + throw new Error('Unsupported algorithm') + } +} diff --git a/apps/vault/src/vault/evaluation-request.dto.ts b/apps/vault/src/vault/evaluation-request.dto.ts new file mode 100644 index 000000000..7660f50a6 --- /dev/null +++ b/apps/vault/src/vault/evaluation-request.dto.ts @@ -0,0 +1,150 @@ +import { AccessList, AccountId, Action, Address, BaseActionDto, FiatCurrency, Hex } from '@narval/policy-engine-shared' +import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger' +import { Transform, Type } from 'class-transformer' +import { IsDefined, IsEthereumAddress, IsIn, IsOptional, IsString, ValidateNested } from 'class-validator' + +export class TransactionRequestDto { + @IsString() + @IsDefined() + @IsEthereumAddress() + @Transform(({ value }) => value.toLowerCase()) + @ApiProperty({ + required: true, + format: 'EthereumAddress' + }) + from: Address + + @IsString() + @IsEthereumAddress() + @Transform(({ value }) => value.toLowerCase()) + @ApiProperty({ + format: 'EthereumAddress' + }) + to?: Address | null + + @IsString() + @ApiProperty({ + type: 'string', + format: 'Hexadecimal' + }) + data?: Hex + + @IsOptional() + @Transform(({ value }) => BigInt(value)) + @ApiProperty({ + format: 'bigint', + required: false, + type: 'string' + }) + gas?: bigint + @IsOptional() + @Transform(({ value }) => BigInt(value)) + @ApiProperty({ + format: 'bigint', + required: false, + type: 'string' + }) + maxFeePerGas?: bigint + @IsOptional() + @Transform(({ value }) => BigInt(value)) + @ApiProperty({ + format: 'bigint', + required: false, + type: 'string' + }) + maxPriorityFeePerGas?: bigint + + @ApiProperty() + nonce?: number + + value?: Hex + + chainId: number + + accessList?: AccessList + + type?: '2' +} + +export class SignTransactionRequestDataDto extends BaseActionDto { + @IsIn(Object.values(Action)) + @IsDefined() + @ApiProperty({ + enum: Object.values(Action), + default: Action.SIGN_TRANSACTION + }) + action: typeof Action.SIGN_TRANSACTION + + @IsString() + @IsDefined() + @ApiProperty() + resourceId: string + + @ValidateNested() + @IsDefined() + @ApiProperty({ + type: TransactionRequestDto + }) + transactionRequest: TransactionRequestDto +} + +export class SignMessageRequestDataDto extends BaseActionDto { + @IsIn(Object.values(Action)) + @IsDefined() + @ApiProperty({ + enum: Object.values(Action), + default: Action.SIGN_MESSAGE + }) + action: typeof Action.SIGN_MESSAGE + + @IsString() + @IsDefined() + @ApiProperty() + resourceId: string + + @IsString() + @IsDefined() + @ApiProperty() + message: string // TODO: Is this string hex or raw? +} + +export class HistoricalTransferDto { + amount: string + from: AccountId + to: string + chainId: number + token: string + rates: { [keyof in FiatCurrency]: string } + initiatedBy: string + timestamp: number +} + +@ApiExtraModels(SignTransactionRequestDataDto, SignMessageRequestDataDto) +export class EvaluationRequestDto { + @IsDefined() + @ApiProperty() + authentication: string + + @IsOptional() + @ApiProperty({ + isArray: true + }) + approvals?: string[] + + @ValidateNested() + @Type((opts) => { + return opts?.object.request.action === Action.SIGN_TRANSACTION + ? SignTransactionRequestDataDto + : SignMessageRequestDataDto + }) + @IsDefined() + @ApiProperty({ + oneOf: [{ $ref: getSchemaPath(SignTransactionRequestDataDto) }, { $ref: getSchemaPath(SignMessageRequestDataDto) }] + }) + request: SignTransactionRequestDataDto | SignMessageRequestDataDto + + @IsOptional() + @ValidateNested() + @ApiProperty() + transfers?: HistoricalTransferDto[] +} diff --git a/apps/vault/src/vault/persistence/repository/__test__/unit/app.repository.spec.ts b/apps/vault/src/vault/persistence/repository/__test__/unit/app.repository.spec.ts new file mode 100644 index 000000000..e0b3c8286 --- /dev/null +++ b/apps/vault/src/vault/persistence/repository/__test__/unit/app.repository.spec.ts @@ -0,0 +1,53 @@ +import { EncryptionModule } from '@narval/encryption-module' +import { Test } from '@nestjs/testing' +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 { App } from '../../../../../shared/type/domain.type' +import { AppRepository } from '../../app.repository' + +describe(AppRepository.name, () => { + let repository: AppRepository + let inMemoryKeyValueRepository: InMemoryKeyValueRepository + + beforeEach(async () => { + inMemoryKeyValueRepository = new InMemoryKeyValueRepository() + + const module = await Test.createTestingModule({ + imports: [ + EncryptionModule.register({ + keyring: getTestRawAesKeyring() + }) + ], + providers: [ + KeyValueService, + AppRepository, + { + provide: KeyValueRepository, + useValue: inMemoryKeyValueRepository + } + ] + }).compile() + + repository = module.get(AppRepository) + }) + + describe('save', () => { + const app: App = { + id: 'test-app-id', + adminApiKey: 'unsafe-test-admin-api-key', + masterKey: 'unsafe-test-master-key' + } + + it('saves a new app', async () => { + await repository.save(app) + + const value = await inMemoryKeyValueRepository.get(repository.getKey(app.id)) + const actualApp = await repository.findById(app.id) + + expect(value).not.toEqual(null) + expect(app).toEqual(actualApp) + }) + }) +}) diff --git a/apps/vault/src/vault/persistence/repository/app.repository.ts b/apps/vault/src/vault/persistence/repository/app.repository.ts new file mode 100644 index 000000000..bd90b7349 --- /dev/null +++ b/apps/vault/src/vault/persistence/repository/app.repository.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common' +import { KeyValueService } from '../../../shared/module/key-value/core/service/key-value.service' +import { appSchema } from '../../../shared/schema/app.schema' +import { App } from '../../../shared/type/domain.type' + +@Injectable() +export class AppRepository { + constructor(private keyValueService: KeyValueService) {} + + async findById(id: string): Promise { + const value = await this.keyValueService.get(this.getKey(id)) + + if (value) { + return this.decode(value) + } + + return null + } + + async save(app: App): Promise { + await this.keyValueService.set(this.getKey(app.id), this.encode(app)) + + return app + } + + getKey(id: string): string { + return `app:${id}` + } + + private encode(app: App): string { + return JSON.stringify(app) + } + + private decode(value: string): App { + return appSchema.parse(JSON.parse(value)) + } +} diff --git a/apps/vault/src/vault/persistence/repository/mock_data.ts b/apps/vault/src/vault/persistence/repository/mock_data.ts new file mode 100644 index 000000000..b8b2aecce --- /dev/null +++ b/apps/vault/src/vault/persistence/repository/mock_data.ts @@ -0,0 +1,55 @@ +import { Action, EvaluationRequest, FIXTURE, Request, TransactionRequest } from '@narval/policy-engine-shared' +import { Payload, SigningAlg, buildSignerEip191, hash, privateKeyToJwk, signJwt } from '@narval/signature' +import { UNSAFE_PRIVATE_KEY } from 'packages/policy-engine-shared/src/lib/dev.fixture' +import { toHex } from 'viem' + +export const ONE_ETH = BigInt('1000000000000000000') + +export const generateInboundRequest = async (): Promise => { + const txRequest: TransactionRequest = { + from: FIXTURE.WALLET.Engineering.address, + to: FIXTURE.WALLET.Treasury.address, + chainId: 137, + value: toHex(ONE_ETH), + data: '0x00000000', + nonce: 192, + type: '2' + } + + const request: Request = { + action: Action.SIGN_TRANSACTION, + nonce: 'random-nonce-111', + transactionRequest: txRequest, + resourceId: FIXTURE.WALLET.Engineering.id + } + + const message = hash(request) + const payload: Payload = { + requestHash: message + } + + // const aliceSignature = await FIXTURE.ACCOUNT.Alice.signMessage({ message }) + const aliceSignature = await signJwt( + payload, + privateKeyToJwk(UNSAFE_PRIVATE_KEY.Alice), + { alg: SigningAlg.EIP191 }, + buildSignerEip191(UNSAFE_PRIVATE_KEY.Alice) + ) + const bobSignature = await signJwt( + payload, + privateKeyToJwk(UNSAFE_PRIVATE_KEY.Bob), + { alg: SigningAlg.EIP191 }, + buildSignerEip191(UNSAFE_PRIVATE_KEY.Bob) + ) + const carolSignature = await signJwt( + payload, + privateKeyToJwk(UNSAFE_PRIVATE_KEY.Carol), + { alg: SigningAlg.EIP191 }, + buildSignerEip191(UNSAFE_PRIVATE_KEY.Carol) + ) + return { + authentication: aliceSignature, + request, + approvals: [bobSignature, carolSignature] + } +} diff --git a/apps/vault/src/vault/vault.controller.ts b/apps/vault/src/vault/vault.controller.ts new file mode 100644 index 000000000..8a99c30a2 --- /dev/null +++ b/apps/vault/src/vault/vault.controller.ts @@ -0,0 +1,23 @@ +import { Controller, Get, Logger } from '@nestjs/common' +import { VaultService } from './vault.service' + +@Controller() +export class VaultController { + private logger = new Logger(VaultController.name) + + constructor(private readonly appService: VaultService) {} + + @Get() + healthcheck() { + return 'Running' + } + + @Get('/ping') + ping() { + this.logger.log({ + message: 'Received ping' + }) + + return 'pong' + } +} diff --git a/apps/vault/src/vault/vault.module.ts b/apps/vault/src/vault/vault.module.ts new file mode 100644 index 000000000..b3d5331f5 --- /dev/null +++ b/apps/vault/src/vault/vault.module.ts @@ -0,0 +1,44 @@ +import { EncryptionModule } from '@narval/encryption-module' +import { HttpModule } from '@nestjs/axios' +import { Module, ValidationPipe, forwardRef } from '@nestjs/common' +import { ConfigModule, ConfigService } from '@nestjs/config' +import { APP_PIPE } from '@nestjs/core' +import { load } from '../main.config' +import { EncryptionModuleOptionFactory } from '../shared/factory/encryption-module-option.factory' +import { KeyValueModule } from '../shared/module/key-value/key-value.module' +import { AppService } from './core/service/app.service' +import { ProvisionService } from './core/service/provision.service' +import { SigningService } from './core/service/signing.service' +import { AppRepository } from './persistence/repository/app.repository' +import { VaultController } from './vault.controller' +import { VaultService } from './vault.service' + +@Module({ + imports: [ + ConfigModule.forRoot({ + load: [load], + isGlobal: true + }), + HttpModule, + forwardRef(() => KeyValueModule), + EncryptionModule.registerAsync({ + imports: [VaultModule], + inject: [ConfigService, AppService], + useClass: EncryptionModuleOptionFactory + }) + ], + controllers: [VaultController], + providers: [ + AppService, + AppRepository, + VaultService, + ProvisionService, + SigningService, + { + provide: APP_PIPE, + useClass: ValidationPipe + } + ], + exports: [AppService, ProvisionService] +}) +export class VaultModule {} diff --git a/apps/vault/src/vault/vault.service.ts b/apps/vault/src/vault/vault.service.ts new file mode 100644 index 000000000..11a9f8dbf --- /dev/null +++ b/apps/vault/src/vault/vault.service.ts @@ -0,0 +1,13 @@ +import { EvaluationResponse } from '@narval/policy-engine-shared' +import { Injectable } from '@nestjs/common' +import { SigningService } from './core/service/signing.service' + +@Injectable() +export class VaultService { + constructor(private signingService: SigningService) {} + + async sign(): Promise { + console.log('Signing Called') + return null + } +} diff --git a/apps/vault/tsconfig.app.json b/apps/vault/tsconfig.app.json new file mode 100644 index 000000000..a2ce76529 --- /dev/null +++ b/apps/vault/tsconfig.app.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["node"], + "emitDecoratorMetadata": true, + "target": "es2021" + }, + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"], + "include": ["src/**/*.ts"] +} diff --git a/apps/vault/tsconfig.json b/apps/vault/tsconfig.json new file mode 100644 index 000000000..af5a8f3ac --- /dev/null +++ b/apps/vault/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "compilerOptions": { + "esModuleInterop": true, + "resolveJsonModule": true + } +} diff --git a/apps/vault/tsconfig.spec.json b/apps/vault/tsconfig.spec.json new file mode 100644 index 000000000..f6d8ffcc9 --- /dev/null +++ b/apps/vault/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/apps/vault/webpack.config.js b/apps/vault/webpack.config.js new file mode 100644 index 000000000..bb8d268d2 --- /dev/null +++ b/apps/vault/webpack.config.js @@ -0,0 +1,8 @@ +const { composePlugins, withNx } = require('@nx/webpack') + +// Nx plugins for webpack. +module.exports = composePlugins(withNx(), (config) => { + // Update the webpack config as needed here. + // e.g. `config.plugins.push(new MyPlugin())` + return config +}) From e548cbc87fe2f88365a3e8b299933ab0d7525779 Mon Sep 17 00:00:00 2001 From: Matt Schoch Date: Thu, 14 Mar 2024 16:01:14 -0400 Subject: [PATCH 2/4] Wiring up POST /import/private-key --- apps/vault/.env.default | 2 +- apps/vault/.env.test.default | 2 +- apps/vault/src/main.constant.ts | 1 + .../shared/decorator/client-id.decorator.ts | 12 ++ .../__test__/unit/client-secret.guard.spec.ts | 70 ++++++++ .../src/shared/guard/client-secret.guard.ts | 30 ++++ apps/vault/src/shared/schema/wallet.schema.ts | 7 + apps/vault/src/shared/type/domain.type.ts | 17 +- apps/vault/src/tenant/tenant.module.ts | 3 +- .../__test__/unit/import.service.spec.ts | 61 +++++++ .../src/vault/core/service/import.service.ts | 32 ++++ .../vault/src/vault/evaluation-request.dto.ts | 150 ------------------ .../http/rest/controller/import.controller.ts | 21 +++ .../http/rest/dto/import-private-key-dto.ts | 14 ++ .../dto/import-private-key-response-dto.ts | 18 +++ .../repository/wallet.repository.ts | 39 +++++ apps/vault/src/vault/vault.module.ts | 13 +- 17 files changed, 322 insertions(+), 170 deletions(-) create mode 100644 apps/vault/src/shared/decorator/client-id.decorator.ts create mode 100644 apps/vault/src/shared/guard/__test__/unit/client-secret.guard.spec.ts create mode 100644 apps/vault/src/shared/guard/client-secret.guard.ts create mode 100644 apps/vault/src/shared/schema/wallet.schema.ts create mode 100644 apps/vault/src/vault/core/service/__test__/unit/import.service.spec.ts create mode 100644 apps/vault/src/vault/core/service/import.service.ts delete mode 100644 apps/vault/src/vault/evaluation-request.dto.ts create mode 100644 apps/vault/src/vault/http/rest/controller/import.controller.ts create mode 100644 apps/vault/src/vault/http/rest/dto/import-private-key-dto.ts create mode 100644 apps/vault/src/vault/http/rest/dto/import-private-key-response-dto.ts create mode 100644 apps/vault/src/vault/persistence/repository/wallet.repository.ts diff --git a/apps/vault/.env.default b/apps/vault/.env.default index 3e973deb7..08f1d7471 100644 --- a/apps/vault/.env.default +++ b/apps/vault/.env.default @@ -1,6 +1,6 @@ NODE_ENV=development -PORT=3010 +PORT=3011 APP_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/vault?schema=public" diff --git a/apps/vault/.env.test.default b/apps/vault/.env.test.default index 0353a26aa..9e2890a4e 100644 --- a/apps/vault/.env.test.default +++ b/apps/vault/.env.test.default @@ -1,6 +1,6 @@ NODE_ENV=test -PORT=3010 +PORT=3011 APP_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/vault-test?schema=public" diff --git a/apps/vault/src/main.constant.ts b/apps/vault/src/main.constant.ts index c2dd2de1f..18cdcd608 100644 --- a/apps/vault/src/main.constant.ts +++ b/apps/vault/src/main.constant.ts @@ -1,6 +1,7 @@ import { RawAesWrappingSuiteIdentifier } from '@aws-crypto/client-node' export const REQUEST_HEADER_API_KEY = 'x-api-key' +export const REQUEST_HEADER_CLIENT_ID = 'x-client-id' export const ENCRYPTION_KEY_NAMESPACE = 'armory.vault' export const ENCRYPTION_KEY_NAME = 'storage-encryption' diff --git a/apps/vault/src/shared/decorator/client-id.decorator.ts b/apps/vault/src/shared/decorator/client-id.decorator.ts new file mode 100644 index 000000000..de897282f --- /dev/null +++ b/apps/vault/src/shared/decorator/client-id.decorator.ts @@ -0,0 +1,12 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common' +import { REQUEST_HEADER_CLIENT_ID } from '../../main.constant' + +export const ClientId = createParamDecorator((data: unknown, context: ExecutionContext): string => { + const req = context.switchToHttp().getRequest() + const clientId = req.headers[REQUEST_HEADER_CLIENT_ID] + if (!clientId || typeof clientId !== 'string') { + throw new Error(`Missing or invalid ${REQUEST_HEADER_CLIENT_ID} header`) + } + + return clientId +}) diff --git a/apps/vault/src/shared/guard/__test__/unit/client-secret.guard.spec.ts b/apps/vault/src/shared/guard/__test__/unit/client-secret.guard.spec.ts new file mode 100644 index 000000000..d0d86e9b1 --- /dev/null +++ b/apps/vault/src/shared/guard/__test__/unit/client-secret.guard.spec.ts @@ -0,0 +1,70 @@ +import { ExecutionContext } from '@nestjs/common' +import { mock } from 'jest-mock-extended' +import { REQUEST_HEADER_API_KEY, REQUEST_HEADER_CLIENT_ID } from '../../../../main.constant' +import { TenantService } from '../../../../tenant/core/service/tenant.service' +import { ApplicationException } from '../../../exception/application.exception' +import { Tenant } from '../../../type/domain.type' +import { ClientSecretGuard } from '../../client-secret.guard' + +describe(ClientSecretGuard.name, () => { + const CLIENT_ID = 'tenant-a' + + const mockExecutionContext = ({ clientSecret, clientId }: { clientSecret?: string; clientId?: string }) => { + const headers = { + [REQUEST_HEADER_API_KEY]: clientSecret, + [REQUEST_HEADER_CLIENT_ID]: clientId + } + const request = { headers } + + return { + switchToHttp: () => ({ + getRequest: () => request + }) + } as ExecutionContext + } + + const mockService = (clientSecret: string = 'tenant-a-secret-key') => { + const tenant: Tenant = { + clientId: CLIENT_ID, + clientSecret: clientSecret, + updatedAt: new Date(), + createdAt: new Date() + } + + const serviceMock = mock() + serviceMock.findByClientId.mockResolvedValue(tenant) + + return serviceMock + } + + it(`throws an error when ${REQUEST_HEADER_API_KEY} header is missing`, async () => { + const guard = new ClientSecretGuard(mockService()) + + await expect(guard.canActivate(mockExecutionContext({ clientId: CLIENT_ID }))).rejects.toThrow(ApplicationException) + }) + + it(`throws an error when ${REQUEST_HEADER_CLIENT_ID} header is missing`, async () => { + const guard = new ClientSecretGuard(mockService('my-secret')) + + await expect(guard.canActivate(mockExecutionContext({ clientSecret: 'my-secret' }))).rejects.toThrow( + ApplicationException + ) + }) + + it(`returns true when ${REQUEST_HEADER_API_KEY} matches the client secret key`, async () => { + const adminApiKey = 'test-client-api-key' + const guard = new ClientSecretGuard(mockService(adminApiKey)) + + expect(await guard.canActivate(mockExecutionContext({ clientId: CLIENT_ID, clientSecret: adminApiKey }))).toEqual( + true + ) + }) + + it(`returns false when ${REQUEST_HEADER_API_KEY} does not matches the client secret key`, async () => { + const guard = new ClientSecretGuard(mockService('test-admin-api-key')) + + expect( + await guard.canActivate(mockExecutionContext({ clientId: CLIENT_ID, clientSecret: 'wrong-secret' })) + ).toEqual(false) + }) +}) diff --git a/apps/vault/src/shared/guard/client-secret.guard.ts b/apps/vault/src/shared/guard/client-secret.guard.ts new file mode 100644 index 000000000..05ab349ab --- /dev/null +++ b/apps/vault/src/shared/guard/client-secret.guard.ts @@ -0,0 +1,30 @@ +import { CanActivate, ExecutionContext, HttpStatus, Injectable } from '@nestjs/common' +import { REQUEST_HEADER_API_KEY, REQUEST_HEADER_CLIENT_ID } from '../../main.constant' +import { TenantService } from '../../tenant/core/service/tenant.service' +import { ApplicationException } from '../exception/application.exception' + +@Injectable() +export class ClientSecretGuard implements CanActivate { + constructor(private tenantService: TenantService) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest() + const clientSecret = req.headers[REQUEST_HEADER_API_KEY] + const clientId = req.headers[REQUEST_HEADER_CLIENT_ID] + + if (!clientSecret) { + throw new ApplicationException({ + message: `Missing or invalid ${REQUEST_HEADER_API_KEY} header`, + suggestedHttpStatusCode: HttpStatus.UNAUTHORIZED + }) + } else if (!clientId) { + throw new ApplicationException({ + message: `Missing or invalid ${REQUEST_HEADER_CLIENT_ID} header`, + suggestedHttpStatusCode: HttpStatus.UNAUTHORIZED + }) + } + const tenant = await this.tenantService.findByClientId(clientId) + + return tenant?.clientSecret?.toLowerCase() === clientSecret.toLowerCase() + } +} diff --git a/apps/vault/src/shared/schema/wallet.schema.ts b/apps/vault/src/shared/schema/wallet.schema.ts new file mode 100644 index 000000000..b426171bd --- /dev/null +++ b/apps/vault/src/shared/schema/wallet.schema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod' + +export const walletSchema = z.object({ + id: z.string().min(1), + privateKey: z.string().regex(/^(0x)?([A-Fa-f0-9]{64})$/), + address: z.string().regex(/^0x([A-Fa-f0-9]{40})$/) +}) diff --git a/apps/vault/src/shared/type/domain.type.ts b/apps/vault/src/shared/type/domain.type.ts index 39f44c7dc..3c38ff609 100644 --- a/apps/vault/src/shared/type/domain.type.ts +++ b/apps/vault/src/shared/type/domain.type.ts @@ -1,23 +1,10 @@ -import { ApprovalRequirement } from '@narval/policy-engine-shared' import { z } from 'zod' import { appSchema } from '../schema/app.schema' import { tenantSchema } from '../schema/tenant.schema' +import { walletSchema } from '../schema/wallet.schema' export type Tenant = z.infer export type App = z.infer -export type MatchedRule = { - policyName: string - policyId: string - type: 'permit' | 'forbid' - approvalsSatisfied: ApprovalRequirement[] - approvalsMissing: ApprovalRequirement[] -} - -export type VerifiedApproval = { - signature: string - userId: string - credentialId: string // The credential used for this approval - address?: string // Address, if the Credential is a EOA private key TODO: Do we need this? -} +export type Wallet = z.infer diff --git a/apps/vault/src/tenant/tenant.module.ts b/apps/vault/src/tenant/tenant.module.ts index 856f58916..92e1002d5 100644 --- a/apps/vault/src/tenant/tenant.module.ts +++ b/apps/vault/src/tenant/tenant.module.ts @@ -22,7 +22,8 @@ import { TenantRepository } from './persistence/repository/tenant.repository' provide: APP_PIPE, useClass: ValidationPipe } - ] + ], + exports: [TenantService, TenantRepository] }) export class TenantModule implements OnApplicationBootstrap { constructor(private bootstrapService: BootstrapService) {} diff --git a/apps/vault/src/vault/core/service/__test__/unit/import.service.spec.ts b/apps/vault/src/vault/core/service/__test__/unit/import.service.spec.ts new file mode 100644 index 000000000..95e22f26d --- /dev/null +++ b/apps/vault/src/vault/core/service/__test__/unit/import.service.spec.ts @@ -0,0 +1,61 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { Wallet } from '../../../../../shared/type/domain.type' +import { WalletRepository } from '../../../../persistence/repository/wallet.repository' +import { ImportService } from '../../import.service' + +describe('ImportService', () => { + let importService: ImportService + let walletRepository: WalletRepository + + const PRIVATE_KEY = '0x7cfef3303797cbc7515d9ce22ffe849c701b0f2812f999b0847229c47951fca5' + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ImportService, + { + provide: WalletRepository, + useValue: { + // mock the methods of WalletRepository that are used in ImportService + // for example: + save: jest.fn().mockResolvedValue({ + id: 'walletId', + address: '0x2c4895215973CbBd778C32c456C074b99daF8Bf1', + privateKey: PRIVATE_KEY + }) + } + } + ] + }).compile() + + importService = module.get(ImportService) + walletRepository = module.get(WalletRepository) + }) + + describe('importPrivateKey', () => { + it('should import private key and return a wallet', async () => { + const tenantId = 'tenantId' + const privateKey = PRIVATE_KEY + const walletId = 'walletId' + + const wallet: Wallet = await importService.importPrivateKey(tenantId, privateKey, walletId) + + expect(wallet).toEqual({ id: 'walletId', address: '0x2c4895215973CbBd778C32c456C074b99daF8Bf1', privateKey }) + expect(walletRepository.save).toHaveBeenCalledWith(tenantId, { + id: walletId, + privateKey, + address: '0x2c4895215973CbBd778C32c456C074b99daF8Bf1' + }) + }) + }) + + describe('generateWalletId', () => { + it('should generate a wallet ID based on the address, lowercased', () => { + const address = '0x2c4895215973CbBd778C32c456C074b99daF8Bf1' + + const walletId: string = importService.generateWalletId(address) + + expect(walletId).toEqual('eip155:eoa:0x2c4895215973cbbd778c32c456c074b99daf8bf1') + }) + }) +}) diff --git a/apps/vault/src/vault/core/service/import.service.ts b/apps/vault/src/vault/core/service/import.service.ts new file mode 100644 index 000000000..19e818e55 --- /dev/null +++ b/apps/vault/src/vault/core/service/import.service.ts @@ -0,0 +1,32 @@ +import { Hex } from '@narval/policy-engine-shared' +import { Injectable, Logger } from '@nestjs/common' +import { privateKeyToAddress } from 'viem/accounts' +import { Wallet } from '../../../shared/type/domain.type' +import { WalletRepository } from '../../persistence/repository/wallet.repository' + +@Injectable() +export class ImportService { + private logger = new Logger(ImportService.name) + + constructor(private walletRepository: WalletRepository) {} + + async importPrivateKey(tenantId: string, privateKey: Hex, walletId?: string): Promise { + this.logger.log('Importing private key', { + tenantId + }) + const address = privateKeyToAddress(privateKey) + const id = walletId || this.generateWalletId(address) + + const wallet = await this.walletRepository.save(tenantId, { + id, + privateKey, + address + }) + + return wallet + } + + generateWalletId(address: Hex): string { + return `eip155:eoa:${address.toLowerCase()}` + } +} diff --git a/apps/vault/src/vault/evaluation-request.dto.ts b/apps/vault/src/vault/evaluation-request.dto.ts deleted file mode 100644 index 7660f50a6..000000000 --- a/apps/vault/src/vault/evaluation-request.dto.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { AccessList, AccountId, Action, Address, BaseActionDto, FiatCurrency, Hex } from '@narval/policy-engine-shared' -import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger' -import { Transform, Type } from 'class-transformer' -import { IsDefined, IsEthereumAddress, IsIn, IsOptional, IsString, ValidateNested } from 'class-validator' - -export class TransactionRequestDto { - @IsString() - @IsDefined() - @IsEthereumAddress() - @Transform(({ value }) => value.toLowerCase()) - @ApiProperty({ - required: true, - format: 'EthereumAddress' - }) - from: Address - - @IsString() - @IsEthereumAddress() - @Transform(({ value }) => value.toLowerCase()) - @ApiProperty({ - format: 'EthereumAddress' - }) - to?: Address | null - - @IsString() - @ApiProperty({ - type: 'string', - format: 'Hexadecimal' - }) - data?: Hex - - @IsOptional() - @Transform(({ value }) => BigInt(value)) - @ApiProperty({ - format: 'bigint', - required: false, - type: 'string' - }) - gas?: bigint - @IsOptional() - @Transform(({ value }) => BigInt(value)) - @ApiProperty({ - format: 'bigint', - required: false, - type: 'string' - }) - maxFeePerGas?: bigint - @IsOptional() - @Transform(({ value }) => BigInt(value)) - @ApiProperty({ - format: 'bigint', - required: false, - type: 'string' - }) - maxPriorityFeePerGas?: bigint - - @ApiProperty() - nonce?: number - - value?: Hex - - chainId: number - - accessList?: AccessList - - type?: '2' -} - -export class SignTransactionRequestDataDto extends BaseActionDto { - @IsIn(Object.values(Action)) - @IsDefined() - @ApiProperty({ - enum: Object.values(Action), - default: Action.SIGN_TRANSACTION - }) - action: typeof Action.SIGN_TRANSACTION - - @IsString() - @IsDefined() - @ApiProperty() - resourceId: string - - @ValidateNested() - @IsDefined() - @ApiProperty({ - type: TransactionRequestDto - }) - transactionRequest: TransactionRequestDto -} - -export class SignMessageRequestDataDto extends BaseActionDto { - @IsIn(Object.values(Action)) - @IsDefined() - @ApiProperty({ - enum: Object.values(Action), - default: Action.SIGN_MESSAGE - }) - action: typeof Action.SIGN_MESSAGE - - @IsString() - @IsDefined() - @ApiProperty() - resourceId: string - - @IsString() - @IsDefined() - @ApiProperty() - message: string // TODO: Is this string hex or raw? -} - -export class HistoricalTransferDto { - amount: string - from: AccountId - to: string - chainId: number - token: string - rates: { [keyof in FiatCurrency]: string } - initiatedBy: string - timestamp: number -} - -@ApiExtraModels(SignTransactionRequestDataDto, SignMessageRequestDataDto) -export class EvaluationRequestDto { - @IsDefined() - @ApiProperty() - authentication: string - - @IsOptional() - @ApiProperty({ - isArray: true - }) - approvals?: string[] - - @ValidateNested() - @Type((opts) => { - return opts?.object.request.action === Action.SIGN_TRANSACTION - ? SignTransactionRequestDataDto - : SignMessageRequestDataDto - }) - @IsDefined() - @ApiProperty({ - oneOf: [{ $ref: getSchemaPath(SignTransactionRequestDataDto) }, { $ref: getSchemaPath(SignMessageRequestDataDto) }] - }) - request: SignTransactionRequestDataDto | SignMessageRequestDataDto - - @IsOptional() - @ValidateNested() - @ApiProperty() - transfers?: HistoricalTransferDto[] -} diff --git a/apps/vault/src/vault/http/rest/controller/import.controller.ts b/apps/vault/src/vault/http/rest/controller/import.controller.ts new file mode 100644 index 000000000..47daf4a9a --- /dev/null +++ b/apps/vault/src/vault/http/rest/controller/import.controller.ts @@ -0,0 +1,21 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common' +import { ClientId } from '../../../../shared/decorator/client-id.decorator' +import { ClientSecretGuard } from '../../../../shared/guard/client-secret.guard' +import { ImportService } from '../../../core/service/import.service' +import { ImportPrivateKeyDto } from '../dto/import-private-key-dto' +import { ImportPrivateKeyResponseDto } from '../dto/import-private-key-response-dto' + +@Controller('/import') +@UseGuards(ClientSecretGuard) +export class ImportController { + constructor(private importService: ImportService) {} + + @Post('/private-key') + async create(@ClientId() clientId: string, @Body() body: ImportPrivateKeyDto) { + const importedKey = await this.importService.importPrivateKey(clientId, body.privateKey, body.walletId) + + const response = new ImportPrivateKeyResponseDto(importedKey) + + return response + } +} diff --git a/apps/vault/src/vault/http/rest/dto/import-private-key-dto.ts b/apps/vault/src/vault/http/rest/dto/import-private-key-dto.ts new file mode 100644 index 000000000..ce6bbc50f --- /dev/null +++ b/apps/vault/src/vault/http/rest/dto/import-private-key-dto.ts @@ -0,0 +1,14 @@ +import { Hex, IsHexString } from '@narval/policy-engine-shared' +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' +import { IsOptional, IsString } from 'class-validator' + +export class ImportPrivateKeyDto { + @IsHexString() + @ApiProperty() + privateKey: Hex + + @IsString() + @IsOptional() + @ApiPropertyOptional() + walletId?: string // If not provided, it will be derived as `eip155:eoa:${address}:` +} diff --git a/apps/vault/src/vault/http/rest/dto/import-private-key-response-dto.ts b/apps/vault/src/vault/http/rest/dto/import-private-key-response-dto.ts new file mode 100644 index 000000000..13e141e79 --- /dev/null +++ b/apps/vault/src/vault/http/rest/dto/import-private-key-response-dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger' +import { IsEthereumAddress, IsString } from 'class-validator' +import { Wallet } from '../../../../shared/type/domain.type' + +export class ImportPrivateKeyResponseDto { + constructor(wallet: Wallet) { + this.id = wallet.id + this.address = wallet.address + } + + @IsString() + @ApiProperty() + id: string + + @IsEthereumAddress() + @ApiProperty() + address: string +} diff --git a/apps/vault/src/vault/persistence/repository/wallet.repository.ts b/apps/vault/src/vault/persistence/repository/wallet.repository.ts new file mode 100644 index 000000000..ca3fb0214 --- /dev/null +++ b/apps/vault/src/vault/persistence/repository/wallet.repository.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common' +import { EncryptKeyValueService } from '../../../shared/module/key-value/core/service/encrypt-key-value.service' +import { walletSchema } from '../../../shared/schema/wallet.schema' +import { Wallet } from '../../../shared/type/domain.type' + +@Injectable() +export class WalletRepository { + private KEY_PREFIX = 'wallet:' + + constructor(private keyValueService: EncryptKeyValueService) {} + + getKey(tenantId: string, id: string): string { + return `${this.KEY_PREFIX}:${tenantId}:${id}` + } + + async findById(tenantId: string, id: string): Promise { + const value = await this.keyValueService.get(this.getKey(id, tenantId)) + + if (value) { + return this.decode(value) + } + + return null + } + + async save(tenantId: string, wallet: Wallet): Promise { + await this.keyValueService.set(this.getKey(tenantId, wallet.id), this.encode(wallet)) + + return wallet + } + + private encode(wallet: Wallet): string { + return JSON.stringify(wallet) + } + + private decode(value: string): Wallet { + return walletSchema.parse(JSON.parse(value)) + } +} diff --git a/apps/vault/src/vault/vault.module.ts b/apps/vault/src/vault/vault.module.ts index b3d5331f5..9f4d7b842 100644 --- a/apps/vault/src/vault/vault.module.ts +++ b/apps/vault/src/vault/vault.module.ts @@ -5,11 +5,16 @@ import { ConfigModule, ConfigService } from '@nestjs/config' import { APP_PIPE } from '@nestjs/core' import { load } from '../main.config' import { EncryptionModuleOptionFactory } from '../shared/factory/encryption-module-option.factory' +import { ClientSecretGuard } from '../shared/guard/client-secret.guard' import { KeyValueModule } from '../shared/module/key-value/key-value.module' +import { TenantModule } from '../tenant/tenant.module' import { AppService } from './core/service/app.service' +import { ImportService } from './core/service/import.service' import { ProvisionService } from './core/service/provision.service' import { SigningService } from './core/service/signing.service' +import { ImportController } from './http/rest/controller/import.controller' import { AppRepository } from './persistence/repository/app.repository' +import { WalletRepository } from './persistence/repository/wallet.repository' import { VaultController } from './vault.controller' import { VaultService } from './vault.service' @@ -25,15 +30,19 @@ import { VaultService } from './vault.service' imports: [VaultModule], inject: [ConfigService, AppService], useClass: EncryptionModuleOptionFactory - }) + }), + forwardRef(() => TenantModule) ], - controllers: [VaultController], + controllers: [VaultController, ImportController], providers: [ AppService, AppRepository, + ClientSecretGuard, + ImportService, VaultService, ProvisionService, SigningService, + WalletRepository, { provide: APP_PIPE, useClass: ValidationPipe From 54600645ded370562db19da99317f8822e3990a2 Mon Sep 17 00:00:00 2001 From: Matt Schoch Date: Thu, 14 Mar 2024 17:57:17 -0400 Subject: [PATCH 3/4] Hooking up a sign-transaction (no auth) flow --- apps/armory/src/main.ts | 2 +- .../src/engine/evaluation-request.dto.ts | 117 +------------ apps/policy-engine/src/main.ts | 2 +- apps/vault/jest.config.ts | 3 +- apps/vault/src/main.ts | 2 +- apps/vault/src/shared/schema/wallet.schema.ts | 11 +- .../vault/src/vault/__test__/e2e/sign.spec.ts | 162 ++++++++++++++++++ .../__test__/unit/signing.service.spec.ts | 81 +++++++++ .../src/vault/core/service/signing.service.ts | 125 +++++++------- .../http/rest/controller/sign.controller.ts | 20 +++ .../vault/http/rest/dto/sign-request.dto.ts | 19 ++ .../repository/wallet.repository.ts | 2 +- apps/vault/src/vault/vault.module.ts | 3 +- .../policy-engine-shared/src/lib/dto/index.ts | 2 + .../lib/dto/sign-message-request-data-dto.ts | 24 +++ .../dto/sign-transaction-request-data.dto.ts | 116 +++++++++++++ 16 files changed, 515 insertions(+), 176 deletions(-) create mode 100644 apps/vault/src/vault/__test__/e2e/sign.spec.ts create mode 100644 apps/vault/src/vault/core/service/__test__/unit/signing.service.spec.ts create mode 100644 apps/vault/src/vault/http/rest/controller/sign.controller.ts create mode 100644 apps/vault/src/vault/http/rest/dto/sign-request.dto.ts create mode 100644 packages/policy-engine-shared/src/lib/dto/sign-message-request-data-dto.ts create mode 100644 packages/policy-engine-shared/src/lib/dto/sign-transaction-request-data.dto.ts diff --git a/apps/armory/src/main.ts b/apps/armory/src/main.ts index a5d7b49dd..1fa31cfa1 100644 --- a/apps/armory/src/main.ts +++ b/apps/armory/src/main.ts @@ -37,7 +37,7 @@ const withSwagger = (app: INestApplication): INestApplication => { * @returns The modified INestApplication instance. */ const withGlobalPipes = (app: INestApplication): INestApplication => { - app.useGlobalPipes(new ValidationPipe()) + app.useGlobalPipes(new ValidationPipe({ transform: true })) return app } diff --git a/apps/policy-engine/src/engine/evaluation-request.dto.ts b/apps/policy-engine/src/engine/evaluation-request.dto.ts index 7660f50a6..4676668cb 100644 --- a/apps/policy-engine/src/engine/evaluation-request.dto.ts +++ b/apps/policy-engine/src/engine/evaluation-request.dto.ts @@ -1,112 +1,13 @@ -import { AccessList, AccountId, Action, Address, BaseActionDto, FiatCurrency, Hex } from '@narval/policy-engine-shared' +import { + AccountId, + Action, + FiatCurrency, + SignMessageRequestDataDto, + SignTransactionRequestDataDto +} from '@narval/policy-engine-shared' import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger' -import { Transform, Type } from 'class-transformer' -import { IsDefined, IsEthereumAddress, IsIn, IsOptional, IsString, ValidateNested } from 'class-validator' - -export class TransactionRequestDto { - @IsString() - @IsDefined() - @IsEthereumAddress() - @Transform(({ value }) => value.toLowerCase()) - @ApiProperty({ - required: true, - format: 'EthereumAddress' - }) - from: Address - - @IsString() - @IsEthereumAddress() - @Transform(({ value }) => value.toLowerCase()) - @ApiProperty({ - format: 'EthereumAddress' - }) - to?: Address | null - - @IsString() - @ApiProperty({ - type: 'string', - format: 'Hexadecimal' - }) - data?: Hex - - @IsOptional() - @Transform(({ value }) => BigInt(value)) - @ApiProperty({ - format: 'bigint', - required: false, - type: 'string' - }) - gas?: bigint - @IsOptional() - @Transform(({ value }) => BigInt(value)) - @ApiProperty({ - format: 'bigint', - required: false, - type: 'string' - }) - maxFeePerGas?: bigint - @IsOptional() - @Transform(({ value }) => BigInt(value)) - @ApiProperty({ - format: 'bigint', - required: false, - type: 'string' - }) - maxPriorityFeePerGas?: bigint - - @ApiProperty() - nonce?: number - - value?: Hex - - chainId: number - - accessList?: AccessList - - type?: '2' -} - -export class SignTransactionRequestDataDto extends BaseActionDto { - @IsIn(Object.values(Action)) - @IsDefined() - @ApiProperty({ - enum: Object.values(Action), - default: Action.SIGN_TRANSACTION - }) - action: typeof Action.SIGN_TRANSACTION - - @IsString() - @IsDefined() - @ApiProperty() - resourceId: string - - @ValidateNested() - @IsDefined() - @ApiProperty({ - type: TransactionRequestDto - }) - transactionRequest: TransactionRequestDto -} - -export class SignMessageRequestDataDto extends BaseActionDto { - @IsIn(Object.values(Action)) - @IsDefined() - @ApiProperty({ - enum: Object.values(Action), - default: Action.SIGN_MESSAGE - }) - action: typeof Action.SIGN_MESSAGE - - @IsString() - @IsDefined() - @ApiProperty() - resourceId: string - - @IsString() - @IsDefined() - @ApiProperty() - message: string // TODO: Is this string hex or raw? -} +import { Type } from 'class-transformer' +import { IsDefined, IsOptional, ValidateNested } from 'class-validator' export class HistoricalTransferDto { amount: string diff --git a/apps/policy-engine/src/main.ts b/apps/policy-engine/src/main.ts index 66ca0a8fc..724cf85c6 100644 --- a/apps/policy-engine/src/main.ts +++ b/apps/policy-engine/src/main.ts @@ -32,7 +32,7 @@ const withSwagger = (app: INestApplication): INestApplication => { * @returns The modified INestApplication instance. */ const withGlobalPipes = (app: INestApplication): INestApplication => { - app.useGlobalPipes(new ValidationPipe()) + app.useGlobalPipes(new ValidationPipe({ transform: true })) return app } diff --git a/apps/vault/jest.config.ts b/apps/vault/jest.config.ts index b3d7101ff..d19762a82 100644 --- a/apps/vault/jest.config.ts +++ b/apps/vault/jest.config.ts @@ -13,7 +13,8 @@ const config: Config = { tsconfig: '/tsconfig.spec.json' } ] - } + }, + workerThreads: true // EXPERIMENTAL; lets BigInt serialization work } export default config diff --git a/apps/vault/src/main.ts b/apps/vault/src/main.ts index 605643b5e..1e86aa6b6 100644 --- a/apps/vault/src/main.ts +++ b/apps/vault/src/main.ts @@ -32,7 +32,7 @@ const withSwagger = (app: INestApplication): INestApplication => { * @returns The modified INestApplication instance. */ const withGlobalPipes = (app: INestApplication): INestApplication => { - app.useGlobalPipes(new ValidationPipe()) + app.useGlobalPipes(new ValidationPipe({ transform: true })) return app } diff --git a/apps/vault/src/shared/schema/wallet.schema.ts b/apps/vault/src/shared/schema/wallet.schema.ts index b426171bd..8618dd2b8 100644 --- a/apps/vault/src/shared/schema/wallet.schema.ts +++ b/apps/vault/src/shared/schema/wallet.schema.ts @@ -1,7 +1,14 @@ +import { Hex } from '@narval/policy-engine-shared' import { z } from 'zod' export const walletSchema = z.object({ id: z.string().min(1), - privateKey: z.string().regex(/^(0x)?([A-Fa-f0-9]{64})$/), - address: z.string().regex(/^0x([A-Fa-f0-9]{40})$/) + privateKey: z + .string() + .regex(/^(0x)?([A-Fa-f0-9]{64})$/) + .transform((val: string): Hex => val as Hex), + address: z + .string() + .regex(/^0x([A-Fa-f0-9]{40})$/) + .transform((val: string): Hex => val as Hex) }) diff --git a/apps/vault/src/vault/__test__/e2e/sign.spec.ts b/apps/vault/src/vault/__test__/e2e/sign.spec.ts new file mode 100644 index 000000000..160598323 --- /dev/null +++ b/apps/vault/src/vault/__test__/e2e/sign.spec.ts @@ -0,0 +1,162 @@ +import { EncryptionModuleOptionProvider } from '@narval/encryption-module' +import { HttpStatus, INestApplication, ValidationPipe } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { Test, TestingModule } from '@nestjs/testing' +import request from 'supertest' +import { v4 as uuid } from 'uuid' +import { load } from '../../../main.config' +import { REQUEST_HEADER_API_KEY, REQUEST_HEADER_CLIENT_ID } from '../../../main.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 { getTestRawAesKeyring } from '../../../shared/testing/encryption.testing' +import { Tenant, Wallet } from '../../../shared/type/domain.type' +import { TenantService } from '../../../tenant/core/service/tenant.service' +import { TenantModule } from '../../../tenant/tenant.module' +import { WalletRepository } from '../../persistence/repository/wallet.repository' + +describe('Sign', () => { + let app: INestApplication + let module: TestingModule + + const adminApiKey = 'test-admin-api-key' + const clientId = uuid() + const tenant: Tenant = { + clientId, + clientSecret: adminApiKey, + createdAt: new Date(), + updatedAt: new Date() + } + + const wallet: Wallet = { + id: 'eip155:eoa:0xc3bdcdb4f593aa5a5d81cd425f6fc3265d962157', + address: '0xc3bdcdb4F593AA5A5D81cD425f6Fc3265D962157', + privateKey: '0x7cfef3303797cbc7515d9ce22ffe849c701b0f2812f999b0847229c47951fca5' + } + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + load: [load], + isGlobal: true + }), + TenantModule + ] + }) + .overrideProvider(KeyValueRepository) + .useValue(new InMemoryKeyValueRepository()) + .overrideProvider(EncryptionModuleOptionProvider) + .useValue({ + keyring: getTestRawAesKeyring() + }) + .overrideProvider(TenantService) + .useValue({ + findAll: jest.fn().mockResolvedValue([tenant]), + findByClientId: jest.fn().mockResolvedValue(tenant) + }) + .overrideProvider(WalletRepository) + .useValue({ + findById: jest.fn().mockResolvedValue(wallet) + }) + .compile() + + app = module.createNestApplication() + + // Use global pipes + // THIS IS NEEDED to make sure it parses/transforms properly. + app.useGlobalPipes(new ValidationPipe({ transform: true })) + + await app.init() + }) + + afterAll(async () => { + // await testPrismaService.truncateAll() + await module.close() + await app.close() + }) + + describe('POST /sign', () => { + it('has client secret guard', async () => { + const { status } = await request(app.getHttpServer()) + .post('/sign') + .set(REQUEST_HEADER_API_KEY, adminApiKey) + // .set(REQUEST_HEADER_CLIENT_ID, clientId) NO CLIENT SECRET + .send({}) + + expect(status).toEqual(HttpStatus.UNAUTHORIZED) + }) + + it('validates nested txn data', async () => { + // ValidationPipe & Transforms can easily be implemented incorrectly, so make sure this is running. + + const payload = { + request: { + action: 'signTransaction', + nonce: 'random-nonce-111', + transactionRequest: { + from: '0xc3bdcdb4F593AA5A5D81cD425f6Fc3265D962157', + to: '04B12F0863b83c7162429f0Ebb0DfdA20E1aA97B', // INVALID + chainId: 137, + value: '0x5af3107a4000', + data: '0x', + nonce: 317, + type: '2', + gas: '21004', + maxFeePerGas: '291175227375', + maxPriorityFeePerGas: '81000000000' + }, + resourceId: 'eip155:eoa:0xc3bdcdb4f593aa5a5d81cd425f6fc3265d962157' + } + } + + const { status, body } = await request(app.getHttpServer()) + .post('/sign') + .set(REQUEST_HEADER_API_KEY, adminApiKey) + .set(REQUEST_HEADER_CLIENT_ID, clientId) + .send(payload) + + expect(status).toEqual(HttpStatus.BAD_REQUEST) + + expect(body).toEqual({ + error: 'Bad Request', + message: ['request.transactionRequest.to must be an Ethereum address'], + statusCode: HttpStatus.BAD_REQUEST + }) + }) + + it('signs', async () => { + const payload = { + request: { + action: 'signTransaction', + nonce: 'random-nonce-111', + transactionRequest: { + from: '0xc3bdcdb4F593AA5A5D81cD425f6Fc3265D962157', + to: '0x04B12F0863b83c7162429f0Ebb0DfdA20E1aA97B', + chainId: 137, + value: '0x5af3107a4000', + data: '0x', + nonce: 317, + type: '2', + gas: '21004', + maxFeePerGas: '291175227375', + maxPriorityFeePerGas: '81000000000' + }, + resourceId: 'eip155:eoa:0xc3bdcdb4f593aa5a5d81cd425f6fc3265d962157' + } + } + + const { status, body } = await request(app.getHttpServer()) + .post('/sign') + .set(REQUEST_HEADER_API_KEY, adminApiKey) + .set(REQUEST_HEADER_CLIENT_ID, clientId) + .send(payload) + + expect(status).toEqual(HttpStatus.CREATED) + + expect(body).toEqual({ + signature: + '0x02f875818982013d8512dbf9ea008543cb655fef82520c9404b12f0863b83c7162429f0ebb0dfda20e1aa97b865af3107a400080c080a00de78cbb96f83ef1b8d6be4d55b4046b2706c7d63ce0a815bae2b1ea4f891e6ba06f7648a9c9710b171d55e056c4abca268857f607a8a4a257d945fc44ace9f076' + }) + }) + }) +}) diff --git a/apps/vault/src/vault/core/service/__test__/unit/signing.service.spec.ts b/apps/vault/src/vault/core/service/__test__/unit/signing.service.spec.ts new file mode 100644 index 000000000..8c0c9ab6f --- /dev/null +++ b/apps/vault/src/vault/core/service/__test__/unit/signing.service.spec.ts @@ -0,0 +1,81 @@ +import { Request } from '@narval/policy-engine-shared' +import { Test } from '@nestjs/testing' +import { Hex, TransactionSerializable, hexToBigInt, parseTransaction, serializeTransaction } from 'viem' +import { Wallet } from '../../../../../shared/type/domain.type' +import { WalletRepository } from '../../../../persistence/repository/wallet.repository' +import { SigningService } from '../../signing.service' + +describe('SigningService', () => { + let signingService: SigningService + + const wallet: Wallet = { + id: 'eip155:eoa:0xc3bdcdb4f593aa5a5d81cd425f6fc3265d962157', + address: '0xc3bdcdb4F593AA5A5D81cD425f6Fc3265D962157', + privateKey: '0x7cfef3303797cbc7515d9ce22ffe849c701b0f2812f999b0847229c47951fca5' + } + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [ + SigningService, + { + provide: WalletRepository, + useValue: { + findById: jest.fn().mockResolvedValue(wallet) + } + } + ] + }).compile() + + signingService = moduleRef.get(SigningService) + }) + + describe('sign', () => { + it('should sign the request and return a string', async () => { + // Mock the dependencies and setup the test data + const tenantId = 'tenantId' + const request: Request = { + action: 'signTransaction', + nonce: 'random-nonce-111', + resourceId: 'eip155:eoa:0xc3bdcdb4f593aa5a5d81cd425f6fc3265d962157', + transactionRequest: { + from: '0xc3bdcdb4F593AA5A5D81cD425f6Fc3265D962157', + to: '0x04B12F0863b83c7162429f0Ebb0DfdA20E1aA97B', + chainId: 137, + value: '0x5af3107a4000', + data: '0x', + nonce: 317, + type: '2', + gas: 21004n, + maxFeePerGas: 291175227375n, + maxPriorityFeePerGas: 81000000000n + } + } + + const expectedSignature = + '0x02f875818982013d8512dbf9ea008543cb655fef82520c9404b12f0863b83c7162429f0ebb0dfda20e1aa97b865af3107a400080c080a00de78cbb96f83ef1b8d6be4d55b4046b2706c7d63ce0a815bae2b1ea4f891e6ba06f7648a9c9710b171d55e056c4abca268857f607a8a4a257d945fc44ace9f076' + + // Call the sign method + const result = await signingService.sign(tenantId, request) + + // Assert the result + expect(result).toEqual(expectedSignature) + }) + + // Just for testing formatting & stuff + it('should serialize/deserialize', async () => { + const txRequest: TransactionSerializable = { + // from: '0xc3bdcdb4F593AA5A5D81cD425f6Fc3265D962157', + to: '0x04B12F0863b83c7162429f0Ebb0DfdA20E1aA97B'.toLowerCase() as Hex, + chainId: 137, + value: hexToBigInt('0x5af3107a4000'), + type: 'eip1559' + } + + const serialized = serializeTransaction(txRequest) + const deserialized = parseTransaction(serialized) + + expect(deserialized).toEqual(txRequest) + }) + }) +}) diff --git a/apps/vault/src/vault/core/service/signing.service.ts b/apps/vault/src/vault/core/service/signing.service.ts index 8728ac5c5..982eb09be 100644 --- a/apps/vault/src/vault/core/service/signing.service.ts +++ b/apps/vault/src/vault/core/service/signing.service.ts @@ -1,76 +1,81 @@ -import { JsonWebKey, toHex } from '@narval/policy-engine-shared' +import { Action, Hex, Request, SignTransactionAction } from '@narval/policy-engine-shared' +import { HttpStatus, Injectable } from '@nestjs/common' import { - Alg, - Payload, - SigningAlg, - buildSignerEip191, - buildSignerEs256k, - privateKeyToJwk, - signJwt -} from '@narval/signature' -import { Injectable } from '@nestjs/common' -import { secp256k1 } from '@noble/curves/secp256k1' - -// Optional additional configs, such as for MPC-based DKG. -type KeyGenerationOptions = { - keyId: string -} - -type KeyGenerationResponse = { - publicKey: JsonWebKey - privateKey?: JsonWebKey -} - -type SignOptions = { - alg?: SigningAlg -} + TransactionRequest, + checksumAddress, + createWalletClient, + extractChain, + hexToBigInt, + http, + transactionType +} from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import * as chains from 'viem/chains' +import { ApplicationException } from '../../../shared/exception/application.exception' +import { WalletRepository } from '../../persistence/repository/wallet.repository' @Injectable() export class SigningService { - constructor() {} + constructor(private walletRepository: WalletRepository) {} - async generateSigningKey(alg: Alg, options?: KeyGenerationOptions): Promise { - if (alg === Alg.ES256K) { - const privateKey = toHex(secp256k1.utils.randomPrivateKey()) - const privateJwk = privateKeyToJwk(privateKey, options?.keyId) - - // Remove the privateKey from the public jwk - const publicJwk = { - ...privateJwk, - d: undefined - } - - return { - publicKey: publicJwk, - privateKey: privateJwk - } + async sign(tenantId: string, request: Request): Promise { + if (request.action === Action.SIGN_TRANSACTION) { + return this.signTransaction(tenantId, request) } - throw new Error('Unsupported algorithm') + throw new Error('Action not supported') } - async sign(payload: Payload, jwk: JsonWebKey, opts: SignOptions = {}): Promise { - const alg: SigningAlg = opts.alg || jwk.alg - if (alg === SigningAlg.ES256K) { - if (!jwk.d) { - throw new Error('Missing private key') - } - const pk = jwk.d - - const jwt = await signJwt(payload, jwk, opts, buildSignerEs256k(pk)) + async signTransaction(tenantId: string, action: SignTransactionAction): Promise { + const { transactionRequest, resourceId } = action + const wallet = await this.walletRepository.findById(tenantId, resourceId) + if (!wallet) { + throw new ApplicationException({ + message: 'Wallet not found', + suggestedHttpStatusCode: HttpStatus.BAD_REQUEST, + context: { clientId: tenantId, resourceId } + }) + } - return jwt - } else if (alg === SigningAlg.EIP191) { - if (!jwk.d) { - throw new Error('Missing private key') - } - const pk = jwk.d + const account = privateKeyToAccount(wallet.privateKey) + const chain = extractChain({ + chains: Object.values(chains), + id: transactionRequest.chainId + }) - const jwt = await signJwt(payload, jwk, opts, buildSignerEip191(pk)) + const client = createWalletClient({ + account, + chain, + transport: http('') // clear the RPC so we don't call any chain stuff here. + }) - return jwt + const txRequest: TransactionRequest = { + from: checksumAddress(account.address), + to: transactionRequest.to, + nonce: transactionRequest.nonce, + data: transactionRequest.data, + gas: transactionRequest.gas, + maxFeePerGas: transactionRequest.maxFeePerGas, + maxPriorityFeePerGas: transactionRequest.maxPriorityFeePerGas, + type: transactionType['0x2'], + value: transactionRequest.value ? hexToBigInt(transactionRequest.value) : undefined } - throw new Error('Unsupported algorithm') + const signature = await client.signTransaction(txRequest) + // /* + // TEMPORARY + // for testing, uncomment the below lines to actually SEND the tx to the chain. + // */ + + // const c2 = createWalletClient({ + // account, + // chain, + // transport: http('https://polygon-mainnet.g.alchemy.com/v2/zBfj-qB2fQVXyTlbD8DRitsNn_ukCJAp') // clear the RPC so we don't call any chain stuff here. + // }) + // console.log('sending transaction') + // const hash = await c2.sendRawTransaction({ serializedTransaction: signature }) + // console.log('sent transaction', hash) + + return signature } } diff --git a/apps/vault/src/vault/http/rest/controller/sign.controller.ts b/apps/vault/src/vault/http/rest/controller/sign.controller.ts new file mode 100644 index 000000000..28429622a --- /dev/null +++ b/apps/vault/src/vault/http/rest/controller/sign.controller.ts @@ -0,0 +1,20 @@ +import { Request } from '@narval/policy-engine-shared' +import { Body, Controller, Post, UseGuards } from '@nestjs/common' +import { ClientId } from '../../../../shared/decorator/client-id.decorator' +import { ClientSecretGuard } from '../../../../shared/guard/client-secret.guard' +import { SigningService } from '../../../core/service/signing.service' +import { SignRequestDto } from '../dto/sign-request.dto' + +@Controller('/sign') +@UseGuards(ClientSecretGuard) +export class SignController { + constructor(private signingService: SigningService) {} + + @Post() + async sign(@ClientId() clientId: string, @Body() body: SignRequestDto) { + const request: Request = body.request + const result = await this.signingService.sign(clientId, request) + + return { signature: result } + } +} diff --git a/apps/vault/src/vault/http/rest/dto/sign-request.dto.ts b/apps/vault/src/vault/http/rest/dto/sign-request.dto.ts new file mode 100644 index 000000000..36e90e342 --- /dev/null +++ b/apps/vault/src/vault/http/rest/dto/sign-request.dto.ts @@ -0,0 +1,19 @@ +import { Action, SignMessageRequestDataDto, SignTransactionRequestDataDto } from '@narval/policy-engine-shared' +import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger' +import { Type } from 'class-transformer' +import { IsDefined, ValidateNested } from 'class-validator' + +@ApiExtraModels(SignTransactionRequestDataDto, SignMessageRequestDataDto) +export class SignRequestDto { + @Type((opts) => { + return opts?.object.request.action === Action.SIGN_TRANSACTION + ? SignTransactionRequestDataDto + : SignMessageRequestDataDto + }) + @IsDefined() + @ApiProperty({ + oneOf: [{ $ref: getSchemaPath(SignTransactionRequestDataDto) }, { $ref: getSchemaPath(SignMessageRequestDataDto) }] + }) + @ValidateNested() + request: SignTransactionRequestDataDto | SignMessageRequestDataDto +} diff --git a/apps/vault/src/vault/persistence/repository/wallet.repository.ts b/apps/vault/src/vault/persistence/repository/wallet.repository.ts index ca3fb0214..a5645f883 100644 --- a/apps/vault/src/vault/persistence/repository/wallet.repository.ts +++ b/apps/vault/src/vault/persistence/repository/wallet.repository.ts @@ -14,7 +14,7 @@ export class WalletRepository { } async findById(tenantId: string, id: string): Promise { - const value = await this.keyValueService.get(this.getKey(id, tenantId)) + const value = await this.keyValueService.get(this.getKey(tenantId, id)) if (value) { return this.decode(value) diff --git a/apps/vault/src/vault/vault.module.ts b/apps/vault/src/vault/vault.module.ts index 9f4d7b842..4b93842a6 100644 --- a/apps/vault/src/vault/vault.module.ts +++ b/apps/vault/src/vault/vault.module.ts @@ -13,6 +13,7 @@ import { ImportService } from './core/service/import.service' import { ProvisionService } from './core/service/provision.service' import { SigningService } from './core/service/signing.service' import { ImportController } from './http/rest/controller/import.controller' +import { SignController } from './http/rest/controller/sign.controller' import { AppRepository } from './persistence/repository/app.repository' import { WalletRepository } from './persistence/repository/wallet.repository' import { VaultController } from './vault.controller' @@ -33,7 +34,7 @@ import { VaultService } from './vault.service' }), forwardRef(() => TenantModule) ], - controllers: [VaultController, ImportController], + controllers: [VaultController, ImportController, SignController], providers: [ AppService, AppRepository, diff --git a/packages/policy-engine-shared/src/lib/dto/index.ts b/packages/policy-engine-shared/src/lib/dto/index.ts index 5d6b07444..6e69c84b0 100644 --- a/packages/policy-engine-shared/src/lib/dto/index.ts +++ b/packages/policy-engine-shared/src/lib/dto/index.ts @@ -1,3 +1,5 @@ export * from './base-action-request.dto' export * from './base-action.dto' +export * from './sign-message-request-data-dto' +export * from './sign-transaction-request-data.dto' export * from './signature.dto' diff --git a/packages/policy-engine-shared/src/lib/dto/sign-message-request-data-dto.ts b/packages/policy-engine-shared/src/lib/dto/sign-message-request-data-dto.ts new file mode 100644 index 000000000..3cc83fa68 --- /dev/null +++ b/packages/policy-engine-shared/src/lib/dto/sign-message-request-data-dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger' +import { IsDefined, IsIn, IsString } from 'class-validator' +import { Action } from '../type/action.type' +import { BaseActionDto } from './' + +export class SignMessageRequestDataDto extends BaseActionDto { + @IsIn(Object.values(Action)) + @IsDefined() + @ApiProperty({ + enum: Object.values(Action), + default: Action.SIGN_MESSAGE + }) + action: typeof Action.SIGN_MESSAGE + + @IsString() + @IsDefined() + @ApiProperty() + resourceId: string + + @IsString() + @IsDefined() + @ApiProperty() + message: string // TODO: Is this string hex or raw? +} diff --git a/packages/policy-engine-shared/src/lib/dto/sign-transaction-request-data.dto.ts b/packages/policy-engine-shared/src/lib/dto/sign-transaction-request-data.dto.ts new file mode 100644 index 000000000..7cd2f29c8 --- /dev/null +++ b/packages/policy-engine-shared/src/lib/dto/sign-transaction-request-data.dto.ts @@ -0,0 +1,116 @@ +import { ApiProperty } from '@nestjs/swagger' +import { Transform, Type } from 'class-transformer' +import { IsDefined, IsEthereumAddress, IsIn, IsInt, IsOptional, IsString, Min, ValidateNested } from 'class-validator' +import { IsHexString } from '../decorators/is-hex-string.decorator' +import { Action } from '../type/action.type' +import { Address, Hex } from '../type/domain.type' +import { BaseActionDto } from './' + +class AccessListDto { + @IsString() + @IsDefined() + @IsEthereumAddress() + @Transform(({ value }) => value.toLowerCase()) + address: Address + + @IsString() + @IsHexString() + storageKeys: Hex[] +} + +export class TransactionRequestDto { + @IsString() + @IsDefined() + @IsEthereumAddress() + @Transform(({ value }) => value.toLowerCase()) + @ApiProperty({ + required: true, + format: 'EthereumAddress' + }) + from: Address + + @IsString() + @IsEthereumAddress() + @Transform(({ value }) => value.toLowerCase()) + @ApiProperty({ + format: 'EthereumAddress' + }) + to?: Address | null + + @IsOptional() + @IsString() + @ApiProperty({ + type: 'string', + format: 'Hexadecimal' + }) + data?: Hex + + @IsOptional() + @ApiProperty({ + format: 'bigint', + required: false, + type: 'string' + }) + @Transform(({ value }) => BigInt(value)) + gas?: bigint + + @IsOptional() + @Transform(({ value }) => BigInt(value)) + @ApiProperty({ + format: 'bigint', + required: false, + type: 'string' + }) + maxFeePerGas?: bigint + + @IsOptional() + @Transform(({ value }) => BigInt(value)) + @ApiProperty({ + format: 'bigint', + required: false, + type: 'string' + }) + maxPriorityFeePerGas?: bigint + + @ApiProperty() + nonce?: number + + @IsHexString() + @IsOptional() + value?: Hex + + @IsInt() + @Min(1) + chainId: number + + @Type(() => AccessListDto) + @ValidateNested({ each: true }) + accessList?: AccessListDto[] + + @IsString() + @IsOptional() + type?: '2' +} + +export class SignTransactionRequestDataDto extends BaseActionDto { + @IsIn(Object.values(Action)) + @IsDefined() + @ApiProperty({ + enum: Object.values(Action), + default: Action.SIGN_TRANSACTION + }) + action: typeof Action.SIGN_TRANSACTION + + @IsString() + @IsDefined() + @ApiProperty() + resourceId: string + + @ApiProperty({ + type: TransactionRequestDto + }) + @IsDefined() + @Type(() => TransactionRequestDto) + @ValidateNested() + transactionRequest: TransactionRequestDto +} From 6719ed2179cb0e925588b3cdd3f053635b440095 Mon Sep 17 00:00:00 2001 From: Matt Schoch Date: Fri, 15 Mar 2024 13:03:34 -0400 Subject: [PATCH 4/4] Ensuring validationPipe is added in the specific module --- apps/vault/src/vault/__test__/e2e/sign.spec.ts | 6 +----- apps/vault/src/vault/vault.module.ts | 5 ++++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/vault/src/vault/__test__/e2e/sign.spec.ts b/apps/vault/src/vault/__test__/e2e/sign.spec.ts index 160598323..983d125af 100644 --- a/apps/vault/src/vault/__test__/e2e/sign.spec.ts +++ b/apps/vault/src/vault/__test__/e2e/sign.spec.ts @@ -1,5 +1,5 @@ import { EncryptionModuleOptionProvider } from '@narval/encryption-module' -import { HttpStatus, INestApplication, ValidationPipe } from '@nestjs/common' +import { HttpStatus, INestApplication } from '@nestjs/common' import { ConfigModule } from '@nestjs/config' import { Test, TestingModule } from '@nestjs/testing' import request from 'supertest' @@ -62,10 +62,6 @@ describe('Sign', () => { app = module.createNestApplication() - // Use global pipes - // THIS IS NEEDED to make sure it parses/transforms properly. - app.useGlobalPipes(new ValidationPipe({ transform: true })) - await app.init() }) diff --git a/apps/vault/src/vault/vault.module.ts b/apps/vault/src/vault/vault.module.ts index 4b93842a6..7affa09f5 100644 --- a/apps/vault/src/vault/vault.module.ts +++ b/apps/vault/src/vault/vault.module.ts @@ -46,7 +46,10 @@ import { VaultService } from './vault.service' WalletRepository, { provide: APP_PIPE, - useClass: ValidationPipe + useFactory: () => + new ValidationPipe({ + transform: true + }) } ], exports: [AppService, ProvisionService]