Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Encrypted PK wallet imports #183

Merged
merged 6 commits into from
Mar 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .lintstagedrc.js
Original file line number Diff line number Diff line change
@@ -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(' ')}`
]
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought we already had it 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not at the root; I had it per-project but it can also go at the root & it'll use the first one it finds, so this ensures it works on the whole monorepo

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)
})
})
})
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing'
import { Wallet } from '../../../../../shared/type/domain.type'
import { ImportRepository } from '../../../../persistence/repository/import.repository'
import { WalletRepository } from '../../../../persistence/repository/wallet.repository'
import { ImportService } from '../../import.service'

Expand All @@ -24,6 +25,10 @@ describe('ImportService', () => {
privateKey: PRIVATE_KEY
})
}
},
{
provide: ImportRepository,
useValue: {}
}
]
}).compile()
Expand Down
67 changes: 62 additions & 5 deletions apps/vault/src/vault/core/service/import.service.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,41 @@
import { Hex } from '@narval/policy-engine-shared'
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'

@Injectable()
export class ImportService {
private logger = new Logger(ImportService.name)

constructor(private walletRepository: WalletRepository) {}
constructor(
private walletRepository: WalletRepository,
private importRepository: ImportRepository
) {}

async importPrivateKey(tenantId: string, privateKey: Hex, walletId?: string): Promise<Wallet> {
async generateEncryptionKey(clientId: string): Promise<RsaPublicKey> {
const privateKey = await generateJwk<RsaPrivateKey>(Alg.RS256, { use: 'enc' })
const publicKey = rsaPrivateKeyToPublicKey(privateKey)

// Save the privateKey
await this.importRepository.save(clientId, privateKey)

return publicKey
}

async importPrivateKey(clientId: string, privateKey: Hex, walletId?: string): Promise<Wallet> {
this.logger.log('Importing private key', {
tenantId
clientId
})
const address = privateKeyToAddress(privateKey)
const id = walletId || this.generateWalletId(address)

const wallet = await this.walletRepository.save(tenantId, {
const wallet = await this.walletRepository.save(clientId, {
id,
privateKey,
address
Expand All @@ -26,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
29 changes: 27 additions & 2 deletions apps/vault/src/vault/http/rest/controller/import.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
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'
import { ImportPrivateKeyDto } from '../dto/import-private-key-dto'
import { ImportPrivateKeyResponseDto } from '../dto/import-private-key-response-dto'

Expand All @@ -10,9 +12,32 @@ import { ImportPrivateKeyResponseDto } from '../dto/import-private-key-response-
export class ImportController {
constructor(private importService: ImportService) {}

@Post('/encryption-key')
async generateEncryptionKey(@ClientId() clientId: string) {
const publicKey = await this.importService.generateEncryptionKey(clientId)

const response = new GenerateEncryptionKeyResponseDto(publicKey)

return response
}

@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
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { RsaPublicKeyDto } from '@narval/nestjs-shared'
import { RsaPublicKey } from '@narval/signature'
import { ApiProperty } from '@nestjs/swagger'

export class GenerateEncryptionKeyResponseDto {
constructor(publicKey: RsaPublicKey) {
this.publicKey = publicKey
}

@ApiProperty()
publicKey: RsaPublicKeyDto
}
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 @@ -5,8 +5,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
50 changes: 50 additions & 0 deletions apps/vault/src/vault/persistence/repository/import.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { RsaPrivateKey, rsaPrivateKeySchema } from '@narval/signature'
import { Injectable } from '@nestjs/common'
import { z } from 'zod'
import { EncryptKeyValueService } from '../../../shared/module/key-value/core/service/encrypt-key-value.service'

const importKeySchema = z.object({
jwk: rsaPrivateKeySchema,
createdAt: z.number() // epoch in seconds
})
export type ImportKey = z.infer<typeof importKeySchema>

@Injectable()
export class ImportRepository {
private KEY_PREFIX = 'import:'

constructor(private keyValueService: EncryptKeyValueService) {}

getKey(clientId: string, id: string): string {
return `${this.KEY_PREFIX}:${clientId}:${id}`
}

async findById(clientId: string, id: string): Promise<ImportKey | null> {
const value = await this.keyValueService.get(this.getKey(clientId, id))

if (value) {
return this.decode(value)
}

return null
}

async save(clientId: string, privateKey: RsaPrivateKey): Promise<ImportKey> {
const createdAt = Date.now() / 1000
const importKey: ImportKey = {
jwk: privateKey,
createdAt
}
await this.keyValueService.set(this.getKey(clientId, privateKey.kid), this.encode(importKey))

return importKey
}

private encode(importKey: ImportKey): string {
return JSON.stringify(importKey)
}

private decode(value: string): ImportKey {
return importKeySchema.parse(JSON.parse(value))
}
}
Loading
Loading