diff --git a/.lintstagedrc.js b/.lintstagedrc.js new file mode 100644 index 000000000..3cc2540b7 --- /dev/null +++ b/.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/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/__test__/unit/import.service.spec.ts b/apps/vault/src/vault/core/service/__test__/unit/import.service.spec.ts index 95e22f26d..388086f23 100644 --- 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 @@ -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' @@ -24,6 +25,10 @@ describe('ImportService', () => { privateKey: PRIVATE_KEY }) } + }, + { + provide: ImportRepository, + useValue: {} } ] }).compile() diff --git a/apps/vault/src/vault/core/service/import.service.ts b/apps/vault/src/vault/core/service/import.service.ts index 19e818e55..a2fede0e7 100644 --- a/apps/vault/src/vault/core/service/import.service.ts +++ b/apps/vault/src/vault/core/service/import.service.ts @@ -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 { + async generateEncryptionKey(clientId: string): Promise { + const privateKey = await generateJwk(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 { 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 @@ -26,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 47daf4a9a..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,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' @@ -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) diff --git a/apps/vault/src/vault/http/rest/dto/generate-encryption-key-response.dto.ts b/apps/vault/src/vault/http/rest/dto/generate-encryption-key-response.dto.ts new file mode 100644 index 000000000..9a9d8037f --- /dev/null +++ b/apps/vault/src/vault/http/rest/dto/generate-encryption-key-response.dto.ts @@ -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 +} 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 22511fef1..963e1db88 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 @@ -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() diff --git a/apps/vault/src/vault/persistence/repository/import.repository.ts b/apps/vault/src/vault/persistence/repository/import.repository.ts new file mode 100644 index 000000000..28a27bb92 --- /dev/null +++ b/apps/vault/src/vault/persistence/repository/import.repository.ts @@ -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 + +@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 { + 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 { + 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)) + } +} diff --git a/apps/vault/src/vault/vault.module.ts b/apps/vault/src/vault/vault.module.ts index 36b653950..8e7ca729a 100644 --- a/apps/vault/src/vault/vault.module.ts +++ b/apps/vault/src/vault/vault.module.ts @@ -17,6 +17,7 @@ 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 { ImportRepository } from './persistence/repository/import.repository' import { WalletRepository } from './persistence/repository/wallet.repository' import { VaultController } from './vault.controller' import { VaultService } from './vault.service' @@ -46,6 +47,7 @@ import { VaultService } from './vault.service' ProvisionService, SigningService, WalletRepository, + ImportRepository, { provide: APP_PIPE, useFactory: () => diff --git a/packages/nestjs-shared/src/lib/dto/index.ts b/packages/nestjs-shared/src/lib/dto/index.ts index 6e69c84b0..e290a648a 100644 --- a/packages/nestjs-shared/src/lib/dto/index.ts +++ b/packages/nestjs-shared/src/lib/dto/index.ts @@ -1,5 +1,6 @@ export * from './base-action-request.dto' export * from './base-action.dto' +export * from './rsa-public-key.dto' export * from './sign-message-request-data-dto' export * from './sign-transaction-request-data.dto' export * from './signature.dto' diff --git a/packages/nestjs-shared/src/lib/dto/rsa-public-key.dto.ts b/packages/nestjs-shared/src/lib/dto/rsa-public-key.dto.ts new file mode 100644 index 000000000..d06342ed6 --- /dev/null +++ b/packages/nestjs-shared/src/lib/dto/rsa-public-key.dto.ts @@ -0,0 +1,46 @@ +import { ApiProperty } from '@nestjs/swagger' +import { IsDefined, IsIn, IsOptional, IsString } from 'class-validator' + +export class RsaPublicKeyDto { + @IsString() + @IsDefined() + @ApiProperty() + kid: string + + @IsString() + @IsDefined() + @ApiProperty({ + enum: ['RSA'], + default: 'RSA' + }) + kty: 'RSA' + + @IsString() + @IsDefined() + @ApiProperty({ + enum: ['RS256'], + default: 'RS256' + }) + alg: 'RS256' + + @IsString() + @IsDefined() + @ApiProperty({ + description: 'A base64Url-encoded value' + }) + n: string + + @IsString() + @IsDefined() + @ApiProperty({ + description: 'A base64Url-encoded value' + }) + e: string + + @IsIn(['enc', 'sig']) + @IsOptional() + @ApiProperty({ + enum: ['enc', 'sig'] + }) + use?: 'enc' | 'sig' | undefined +} diff --git a/packages/signature/src/index.ts b/packages/signature/src/index.ts index 17ae55436..bc5640ef4 100644 --- a/packages/signature/src/index.ts +++ b/packages/signature/src/index.ts @@ -1,4 +1,5 @@ export * from './lib/decode' +export * from './lib/encrypt' export * from './lib/hash-request' export * from './lib/schemas' export * from './lib/sign' 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/__test__/unit/util.spec.ts b/packages/signature/src/lib/__test__/unit/util.spec.ts index e4ad7b2b8..b48fe56d3 100644 --- a/packages/signature/src/lib/__test__/unit/util.spec.ts +++ b/packages/signature/src/lib/__test__/unit/util.spec.ts @@ -1,8 +1,8 @@ import { p256PrivateKeySchema, rsaPrivateKeySchema, secp256k1PrivateKeySchema } from '../../schemas' import { buildSignerEip191, signJwt } from '../../sign' import { isHeader, isPayload } from '../../typeguards' -import { Alg, Secp256k1PrivateKey, SigningAlg } from '../../types' -import { generateJwk, secp256k1PrivateKeyToHex } from '../../utils' +import { Alg, RsaPrivateKey, Secp256k1PrivateKey, SigningAlg } from '../../types' +import { generateJwk, rsaPrivateKeyToPublicKey, secp256k1PrivateKeyToHex } from '../../utils' import { validate } from '../../validate' import { verifyJwt } from '../../verify' @@ -99,4 +99,19 @@ describe('generateKeys', () => { const isValid = await verifyJwt(signature, key) expect(isValid).not.toEqual(false) }) + + describe('rsaPrivateKeyToPublicKey', () => { + it('converts private to public', async () => { + const privateKey = await generateJwk(Alg.RS256, { use: 'enc' }) + const publicKey = rsaPrivateKeyToPublicKey(privateKey) + expect(publicKey).toEqual({ + alg: Alg.RS256, + e: expect.any(String), + kid: expect.any(String), + kty: 'RSA', + n: expect.any(String), + use: 'enc' + }) + }) + }) }) 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([ diff --git a/packages/signature/src/lib/types.ts b/packages/signature/src/lib/types.ts index b86f47cbb..24ff7a2ff 100644 --- a/packages/signature/src/lib/types.ts +++ b/packages/signature/src/lib/types.ts @@ -7,6 +7,7 @@ import { privateKeySchema, publicKeySchema, rsaPrivateKeySchema, + rsaPublicKeySchema, secp256k1KeySchema, secp256k1PrivateKeySchema, secp256k1PublicKeySchema @@ -54,6 +55,7 @@ export type Secp256k1PrivateKey = z.infer export type P256PrivateKey = z.infer export type P256PublicKey = z.infer export type RsaPrivateKey = z.infer +export type RsaPublicKey = z.infer export type EoaPublicKey = z.infer export type Secp256k1PublicKey = z.infer export type Secp256k1KeySchema = z.infer diff --git a/packages/signature/src/lib/utils.ts b/packages/signature/src/lib/utils.ts index a0a91be62..907e1800f 100644 --- a/packages/signature/src/lib/utils.ts +++ b/packages/signature/src/lib/utils.ts @@ -5,7 +5,7 @@ import { exportJWK, generateKeyPair } from 'jose' import { toHex } from 'viem' import { publicKeyToAddress } from 'viem/utils' import { JwtError } from './error' -import { rsaPrivateKeySchema } from './schemas' +import { rsaPrivateKeySchema, rsaPublicKeySchema } from './schemas' import { Alg, Curves, @@ -15,6 +15,7 @@ import { P256PrivateKey, P256PublicKey, RsaPrivateKey, + RsaPublicKey, Secp256k1KeySchema, Secp256k1PrivateKey, Secp256k1PublicKey, @@ -152,7 +153,7 @@ const rsaKeyToKid = (jwk: Jwk) => { return toHex(hash) } -const generateRsaKeyPair = async ( +const generateRsaPrivateKey = async ( opts: { keyId?: string modulusLength?: number @@ -167,6 +168,7 @@ const generateRsaKeyPair = async ( }) const partialJwk = await exportJWK(privateKey) + if (!partialJwk.n) { throw new JwtError({ message: 'Invalid JWK; missing n', context: { partialJwk } }) } @@ -183,26 +185,31 @@ const generateRsaKeyPair = async ( return pk } -export const generateJwk = async ( +export const rsaPrivateKeyToPublicKey = (jwk: RsaPrivateKey) => { + const publicKey: RsaPublicKey = rsaPublicKeySchema.parse(jwk) + return publicKey +} + +export const generateJwk = async ( alg: Alg, opts?: { keyId?: string modulusLength?: number use?: Use } -): Promise => { +): Promise => { switch (alg) { case Alg.ES256K: { const privateKeyK1 = toHex(secp256k1.utils.randomPrivateKey()) - return secp256k1PrivateKeyToJwk(privateKeyK1, opts?.keyId) + return secp256k1PrivateKeyToJwk(privateKeyK1, opts?.keyId) as T } case Alg.ES256: { const privateKeyP256 = toHex(p256.utils.randomPrivateKey()) - return p256PrivateKeyToJwk(privateKeyP256, opts?.keyId) + return p256PrivateKeyToJwk(privateKeyP256, opts?.keyId) as T } case Alg.RS256: { - const jwk = await generateRsaKeyPair(opts) - return jwk + const jwk = await generateRsaPrivateKey(opts) + return jwk as T } default: throw new Error(`Unsupported algorithm: ${alg}`)