Skip to content

Commit

Permalink
Adding encrypted privateKey import
Browse files Browse the repository at this point in the history
  • Loading branch information
mattschoch committed Mar 25, 2024
1 parent 599249c commit be56973
Show file tree
Hide file tree
Showing 8 changed files with 266 additions and 7 deletions.
151 changes: 151 additions & 0 deletions apps/vault/src/vault/__test__/e2e/import.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(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)
})
})
})
46 changes: 44 additions & 2 deletions apps/vault/src/vault/core/service/import.service.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -41,6 +44,45 @@ export class ImportService {
return wallet
}

async importEncryptedPrivateKey(clientId: string, encryptedPrivateKey: string, walletId?: string): Promise<Wallet> {
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()}`
}
Expand Down
19 changes: 17 additions & 2 deletions apps/vault/src/vault/http/rest/controller/import.controller.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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)

Expand Down
14 changes: 12 additions & 2 deletions apps/vault/src/vault/http/rest/dto/import-private-key-dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions packages/signature/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from './lib/sign'
export * from './lib/types'
export * from './lib/utils'
export * from './lib/verify'
export * from './lib/encrypt'
15 changes: 15 additions & 0 deletions packages/signature/src/lib/__test__/unit/encrypt.spec.ts
Original file line number Diff line number Diff line change
@@ -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<RsaPrivateKey>(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)
})
})
20 changes: 20 additions & 0 deletions packages/signature/src/lib/encrypt.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string> {
const key = await importJWK(rsaKey)
const { plaintext } = await compactDecrypt(jwe, key)
return new TextDecoder().decode(plaintext)
}
7 changes: 6 additions & 1 deletion packages/signature/src/lib/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down

0 comments on commit be56973

Please sign in to comment.