From be569739700cd232bf97568db201943d78e0ede5 Mon Sep 17 00:00:00 2001 From: Matt Schoch Date: Mon, 25 Mar 2024 13:35:53 -0400 Subject: [PATCH] Adding encrypted privateKey import --- .../src/vault/__test__/e2e/import.spec.ts | 151 ++++++++++++++++++ .../src/vault/core/service/import.service.ts | 46 +++++- .../http/rest/controller/import.controller.ts | 19 ++- .../http/rest/dto/import-private-key-dto.ts | 14 +- packages/signature/src/index.ts | 1 + .../src/lib/__test__/unit/encrypt.spec.ts | 15 ++ packages/signature/src/lib/encrypt.ts | 20 +++ packages/signature/src/lib/schemas.ts | 7 +- 8 files changed, 266 insertions(+), 7 deletions(-) create mode 100644 apps/vault/src/vault/__test__/e2e/import.spec.ts create mode 100644 packages/signature/src/lib/__test__/unit/encrypt.spec.ts create mode 100644 packages/signature/src/lib/encrypt.ts diff --git a/apps/vault/src/vault/__test__/e2e/import.spec.ts b/apps/vault/src/vault/__test__/e2e/import.spec.ts new file mode 100644 index 000000000..ee62769d1 --- /dev/null +++ b/apps/vault/src/vault/__test__/e2e/import.spec.ts @@ -0,0 +1,151 @@ +import { EncryptionModuleOptionProvider } from '@narval/encryption-module' +import { RsaPublicKey, rsaEncrypt, rsaPublicKeySchema, secp256k1PrivateKeyToJwk } from '@narval/signature' +import { HttpStatus, INestApplication } 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 { TestPrismaService } from '../../../shared/module/persistence/service/test-prisma.service' +import { getTestRawAesKeyring } from '../../../shared/testing/encryption.testing' +import { Tenant } from '../../../shared/type/domain.type' +import { TenantService } from '../../../tenant/core/service/tenant.service' +import { TenantModule } from '../../../tenant/tenant.module' + +describe('Import', () => { + let app: INestApplication + let module: TestingModule + let testPrismaService: TestPrismaService + + const adminApiKey = 'test-admin-api-key' + const clientId = uuid() + + const PRIVATE_KEY = '0x7cfef3303797cbc7515d9ce22ffe849c701b0f2812f999b0847229c47951fca5' + // Engine key used to sign the approval request + const enginePrivateJwk = secp256k1PrivateKeyToJwk(PRIVATE_KEY) + // Engine public key registered w/ the Vault Tenant + // eslint-disable-next-line + const { d, ...tenantPublicJWK } = enginePrivateJwk + + const tenant: Tenant = { + clientId, + clientSecret: adminApiKey, + engineJwk: tenantPublicJWK, + createdAt: new Date(), + updatedAt: new Date() + } + + 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) + }) + .compile() + + app = module.createNestApplication({ logger: false }) + testPrismaService = module.get(TestPrismaService) + await testPrismaService.truncateAll() + + await app.init() + }) + + afterAll(async () => { + await testPrismaService.truncateAll() + await module.close() + await app.close() + }) + + describe('POST /encryption-key', () => { + it('has client secret guard', async () => { + const { status } = await request(app.getHttpServer()) + .post('/import/encryption-key') + // .set(REQUEST_HEADER_CLIENT_ID, clientId) NO CLIENT SECRET + .send({}) + + expect(status).toEqual(HttpStatus.UNAUTHORIZED) + }) + + it('generates an RSA keypair', async () => { + const { status, body } = await request(app.getHttpServer()) + .post('/import/encryption-key') + .set(REQUEST_HEADER_CLIENT_ID, clientId) + .set(REQUEST_HEADER_API_KEY, adminApiKey) + .send({}) + + expect(status).toEqual(HttpStatus.CREATED) + + expect(body).toEqual({ + publicKey: expect.objectContaining({ + kid: expect.any(String), + kty: 'RSA', + use: 'enc', + alg: 'RS256', + n: expect.any(String), + e: expect.any(String) + }) + }) + }) + }) + + describe('POST /private-key', () => { + it('imports an unencrypted private key', async () => { + const { status, body } = await request(app.getHttpServer()) + .post('/import/private-key') + .set(REQUEST_HEADER_CLIENT_ID, clientId) + .set(REQUEST_HEADER_API_KEY, adminApiKey) + .send({ + privateKey: PRIVATE_KEY + }) + + expect(status).toEqual(HttpStatus.CREATED) + expect(body).toEqual({ + id: 'eip155:eoa:0x2c4895215973cbbd778c32c456c074b99daf8bf1', + address: '0x2c4895215973CbBd778C32c456C074b99daF8Bf1' + }) + }) + + it('imports a JWE-encrypted private key', async () => { + const { body: keygenBody } = await request(app.getHttpServer()) + .post('/import/encryption-key') + .set(REQUEST_HEADER_CLIENT_ID, clientId) + .set(REQUEST_HEADER_API_KEY, adminApiKey) + .send({}) + const rsPublicKey: RsaPublicKey = rsaPublicKeySchema.parse(keygenBody.publicKey) + + const jwe = await rsaEncrypt(PRIVATE_KEY, rsPublicKey) + + const { status, body } = await request(app.getHttpServer()) + .post('/import/private-key') + .set(REQUEST_HEADER_CLIENT_ID, clientId) + .set(REQUEST_HEADER_API_KEY, adminApiKey) + .send({ + encryptedPrivateKey: jwe + }) + + expect(body).toEqual({ + id: 'eip155:eoa:0x2c4895215973cbbd778c32c456c074b99daf8bf1', + address: '0x2c4895215973CbBd778C32c456C074b99daF8Bf1' + }) + expect(status).toEqual(HttpStatus.CREATED) + }) + }) +}) diff --git a/apps/vault/src/vault/core/service/import.service.ts b/apps/vault/src/vault/core/service/import.service.ts index 653b93250..a2fede0e7 100644 --- a/apps/vault/src/vault/core/service/import.service.ts +++ b/apps/vault/src/vault/core/service/import.service.ts @@ -1,7 +1,10 @@ import { Hex } from '@narval/policy-engine-shared' -import { Alg, RsaPrivateKey, RsaPublicKey, generateJwk, rsaPrivateKeyToPublicKey } from '@narval/signature' -import { Injectable, Logger } from '@nestjs/common' +import { Alg, RsaPrivateKey, RsaPublicKey, generateJwk, rsaDecrypt, rsaPrivateKeyToPublicKey } from '@narval/signature' +import { HttpStatus, Injectable, Logger } from '@nestjs/common' +import { decodeProtectedHeader } from 'jose' +import { isHex } from 'viem' import { privateKeyToAddress } from 'viem/accounts' +import { ApplicationException } from '../../../shared/exception/application.exception' import { Wallet } from '../../../shared/type/domain.type' import { ImportRepository } from '../../persistence/repository/import.repository' import { WalletRepository } from '../../persistence/repository/wallet.repository' @@ -41,6 +44,45 @@ export class ImportService { return wallet } + async importEncryptedPrivateKey(clientId: string, encryptedPrivateKey: string, walletId?: string): Promise { + this.logger.log('Importing encrypted private key', { + clientId + }) + // Get the kid of the + const header = decodeProtectedHeader(encryptedPrivateKey) + const kid = header.kid + + if (!kid) { + throw new ApplicationException({ + message: 'Missing kid in JWE header', + suggestedHttpStatusCode: HttpStatus.BAD_REQUEST + }) + } + + const encryptionPrivateKey = await this.importRepository.findById(clientId, kid) + // TODO: do we want to enforce a time constraint on the createdAt time so you have to use a fresh key? + + if (!encryptionPrivateKey) { + throw new ApplicationException({ + message: 'Encryption Key Not Found', + suggestedHttpStatusCode: HttpStatus.BAD_REQUEST + }) + } + const privateKey = await rsaDecrypt(encryptedPrivateKey, encryptionPrivateKey.jwk) + if (!isHex(privateKey)) { + throw new ApplicationException({ + message: 'Invalid decrypted private key; must be hex string with 0x prefix', + suggestedHttpStatusCode: HttpStatus.BAD_REQUEST + }) + } + + this.logger.log('Decrypted private key', { + clientId + }) + + return this.importPrivateKey(clientId, privateKey as Hex, walletId) + } + generateWalletId(address: Hex): string { return `eip155:eoa:${address.toLowerCase()}` } diff --git a/apps/vault/src/vault/http/rest/controller/import.controller.ts b/apps/vault/src/vault/http/rest/controller/import.controller.ts index 9ec7bd7af..dcb0c1ce1 100644 --- a/apps/vault/src/vault/http/rest/controller/import.controller.ts +++ b/apps/vault/src/vault/http/rest/controller/import.controller.ts @@ -1,5 +1,6 @@ -import { Body, Controller, Post, UseGuards } from '@nestjs/common' +import { Body, Controller, HttpStatus, Post, UseGuards } from '@nestjs/common' import { ClientId } from '../../../../shared/decorator/client-id.decorator' +import { ApplicationException } from '../../../../shared/exception/application.exception' import { ClientSecretGuard } from '../../../../shared/guard/client-secret.guard' import { ImportService } from '../../../core/service/import.service' import { GenerateEncryptionKeyResponseDto } from '../dto/generate-encryption-key-response.dto' @@ -22,7 +23,21 @@ export class ImportController { @Post('/private-key') async create(@ClientId() clientId: string, @Body() body: ImportPrivateKeyDto) { - const importedKey = await this.importService.importPrivateKey(clientId, body.privateKey, body.walletId) + let importedKey + if (body.encryptedPrivateKey) { + importedKey = await this.importService.importEncryptedPrivateKey( + clientId, + body.encryptedPrivateKey, + body.walletId + ) + } else if (body.privateKey) { + importedKey = await this.importService.importPrivateKey(clientId, body.privateKey, body.walletId) + } else { + throw new ApplicationException({ + message: 'Missing privateKey or encryptedPrivateKey', + suggestedHttpStatusCode: HttpStatus.BAD_REQUEST + }) + } const response = new ImportPrivateKeyResponseDto(importedKey) 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 index ce6bbc50f..b58e7210d 100644 --- 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 @@ -4,8 +4,18 @@ import { IsOptional, IsString } from 'class-validator' export class ImportPrivateKeyDto { @IsHexString() - @ApiProperty() - privateKey: Hex + @IsOptional() + @ApiProperty({ + description: 'Wallet Private Key, unencrypted' + }) + privateKey?: Hex + + @IsString() + @IsOptional() + @ApiProperty({ + description: 'Wallet Private Key encrypted with JWE. Header MUST include `kid`' + }) + encryptedPrivateKey?: string @IsString() @IsOptional() diff --git a/packages/signature/src/index.ts b/packages/signature/src/index.ts index 17ae55436..8dcb4ee61 100644 --- a/packages/signature/src/index.ts +++ b/packages/signature/src/index.ts @@ -5,3 +5,4 @@ export * from './lib/sign' export * from './lib/types' export * from './lib/utils' export * from './lib/verify' +export * from './lib/encrypt' diff --git a/packages/signature/src/lib/__test__/unit/encrypt.spec.ts b/packages/signature/src/lib/__test__/unit/encrypt.spec.ts new file mode 100644 index 000000000..1d4292c59 --- /dev/null +++ b/packages/signature/src/lib/__test__/unit/encrypt.spec.ts @@ -0,0 +1,15 @@ +import { rsaDecrypt, rsaEncrypt } from '../../encrypt' +import { Alg, RsaPrivateKey } from '../../types' +import { generateJwk, rsaPrivateKeyToPublicKey } from '../../utils' + +describe('encrypt / decrypt', () => { + it('should encrypt & decrypt with RS256 key', async () => { + const rsaPrivate = await generateJwk(Alg.RS256, { use: 'enc' }) + const data = 'myTestDataString' + + const rsaPublic = rsaPrivateKeyToPublicKey(rsaPrivate) + const encrypted = await rsaEncrypt(data, rsaPublic) + const decrypted = await rsaDecrypt(encrypted, rsaPrivate) + expect(decrypted).toEqual(data) + }) +}) diff --git a/packages/signature/src/lib/encrypt.ts b/packages/signature/src/lib/encrypt.ts new file mode 100644 index 000000000..35ee61488 --- /dev/null +++ b/packages/signature/src/lib/encrypt.ts @@ -0,0 +1,20 @@ +import { CompactEncrypt, compactDecrypt, importJWK } from 'jose' +import { RsaPrivateKey, RsaPublicKey } from './types' + +export async function rsaEncrypt(data: string, rsaKey: RsaPrivateKey | RsaPublicKey): Promise { + const key = await importJWK(rsaKey) + const jwe = await new CompactEncrypt(new TextEncoder().encode(data)) + .setProtectedHeader({ + kid: rsaKey.kid, + alg: 'RSA-OAEP-256', + enc: 'A256GCM' + }) + .encrypt(key) + return jwe +} + +export async function rsaDecrypt(jwe: string, rsaKey: RsaPrivateKey): Promise { + const key = await importJWK(rsaKey) + const { plaintext } = await compactDecrypt(jwe, key) + return new TextDecoder().decode(plaintext) +} diff --git a/packages/signature/src/lib/schemas.ts b/packages/signature/src/lib/schemas.ts index eb23e5dcf..1eeeaeab0 100644 --- a/packages/signature/src/lib/schemas.ts +++ b/packages/signature/src/lib/schemas.ts @@ -59,7 +59,12 @@ export const p256PrivateKeySchema = p256PublicKeySchema.extend({ }) export const rsaPrivateKeySchema = rsaPublicKeySchema.extend({ - d: z.string() + d: z.string(), + p: z.string(), + q: z.string(), + dp: z.string(), + dq: z.string(), + qi: z.string() }) export const publicKeySchema = z.union([