Skip to content

Commit

Permalink
Encrypted PK wallet imports (#183)
Browse files Browse the repository at this point in the history
* rsa private-to-public util

* POST /import/encryption-key generating RSA keypair for imports

* Adding encrypted privateKey import

* Adding lintstaged to root

* fix import from refactor/rebase

* Fixing test
  • Loading branch information
mattschoch authored Mar 26, 2024
1 parent 8578436 commit ada6ac7
Show file tree
Hide file tree
Showing 18 changed files with 450 additions and 20 deletions.
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(' ')}`
]
}
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

0 comments on commit ada6ac7

Please sign in to comment.