From 242e8b9c39b58a1027a96a109de25d5e4b5f3d17 Mon Sep 17 00:00:00 2001 From: Pierre Troger Date: Wed, 20 Mar 2024 14:00:32 -0400 Subject: [PATCH] precise sig fns by alg, change credential shape, add jwk schemas --- .../historical-transfer-feed.service.ts | 4 +- .../core/service/price-feed.service.ts | 4 +- apps/policy-engine/src/engine/app.service.ts | 12 +-- .../engine/core/service/signing.service.ts | 21 ++--- .../repository/entity.repository.ts | 3 - .../persistence/repository/mock_data.ts | 8 +- .../src/lib/dev.fixture.ts | 77 +++++++++++++------ .../src/lib/schema/entity.schema.ts | 9 +-- packages/signature/src/index.ts | 1 + .../src/lib/__test__/unit/sign.spec.ts | 24 +++--- .../src/lib/__test__/unit/verify.spec.ts | 4 +- packages/signature/src/lib/address.schema.ts | 22 ++++++ packages/signature/src/lib/evm.util.ts | 47 +++++++++++ packages/signature/src/lib/schemas.ts | 76 ++++++++++++++++++ packages/signature/src/lib/sign.ts | 4 +- packages/signature/src/lib/typeguards.ts | 11 ++- packages/signature/src/lib/types.ts | 36 +++++---- packages/signature/src/lib/utils.ts | 31 ++------ packages/signature/src/lib/verify.ts | 56 +++++++++----- 19 files changed, 313 insertions(+), 137 deletions(-) create mode 100644 packages/signature/src/lib/address.schema.ts create mode 100644 packages/signature/src/lib/evm.util.ts create mode 100644 packages/signature/src/lib/schemas.ts diff --git a/apps/armory/src/data-feed/core/service/historical-transfer-feed.service.ts b/apps/armory/src/data-feed/core/service/historical-transfer-feed.service.ts index 3d3813e1b..56e05a17d 100644 --- a/apps/armory/src/data-feed/core/service/historical-transfer-feed.service.ts +++ b/apps/armory/src/data-feed/core/service/historical-transfer-feed.service.ts @@ -1,5 +1,5 @@ import { Feed, HistoricalTransfer, JwtString } from '@narval/policy-engine-shared' -import { Payload, SigningAlg, hash, hexToBase64Url, privateKeyToJwk, signJwt } from '@narval/signature' +import { Payload, SigningAlg, hash, hexToBase64Url, secp256k1PrivateKeyToJwk, signJwt } from '@narval/signature' import { Injectable } from '@nestjs/common' import { ConfigService } from '@nestjs/config' import { mapValues, omit } from 'lodash/fp' @@ -37,7 +37,7 @@ export class HistoricalTransferFeedService implements DataFeed { } const now = Math.floor(Date.now() / 1000) - const jwk = privateKeyToJwk(this.getPrivateKey()) + const jwk = secp256k1PrivateKeyToJwk(this.getPrivateKey()) const payload: Payload = { data: hash(data), sub: account.address, diff --git a/apps/policy-engine/src/engine/app.service.ts b/apps/policy-engine/src/engine/app.service.ts index 575dbf98b..218dae936 100644 --- a/apps/policy-engine/src/engine/app.service.ts +++ b/apps/policy-engine/src/engine/app.service.ts @@ -5,11 +5,10 @@ import { EvaluationRequest, EvaluationResponse, HistoricalTransfer, - JsonWebKey, JwtString, Request } from '@narval/policy-engine-shared' -import { Payload, SigningAlg, decode, hash, privateKeyToJwk, publicKeyToJwk, verifyJwt } from '@narval/signature' +import { Payload, SigningAlg, decode, hash, secp256k1PrivateKeyToJwk, verifyJwt } from '@narval/signature' import { safeDecode } from '@narval/transaction-request-intent' import { BadRequestException, @@ -20,7 +19,6 @@ import { } from '@nestjs/common' import { InputType } from 'packages/transaction-request-intent/src/lib/domain' import { Intent } from 'packages/transaction-request-intent/src/lib/intent.types' -import { Hex } from 'viem' import { OpaResult, RegoInput } from '../shared/type/domain.type' import { SigningService } from './core/service/signing.service' import { OpaService } from './opa/opa.service' @@ -83,9 +81,7 @@ export class AppService { throw new NotFoundException('Credential not found') } - const jwk = publicKeyToJwk(credential.pubKey as Hex) - - const validJwt = await verifyJwt(requestSignature, jwk) + const validJwt = await verifyJwt(requestSignature, credential.key) // Check the data is the same if (validJwt.payload.requestHash !== verificationMessage) { throw new BadRequestException('Invalid signature') @@ -205,9 +201,9 @@ export class AppService { // If we are allowing, then the ENGINE signs the verification too if (finalDecision.decision === Decision.PERMIT) { - const tenantSigningKey: JsonWebKey = privateKeyToJwk(ENGINE_PRIVATE_KEY) + const tenantSigningKey = secp256k1PrivateKeyToJwk(ENGINE_PRIVATE_KEY) - const clientJwk = publicKeyToJwk(principalCredential.pubKey as Hex) + const { key: clientJwk } = principalCredential const jwtPayload: Payload = { requestHash: verificationMessage, diff --git a/apps/policy-engine/src/engine/core/service/signing.service.ts b/apps/policy-engine/src/engine/core/service/signing.service.ts index 8728ac5c5..518de268a 100644 --- a/apps/policy-engine/src/engine/core/service/signing.service.ts +++ b/apps/policy-engine/src/engine/core/service/signing.service.ts @@ -1,11 +1,13 @@ -import { JsonWebKey, toHex } from '@narval/policy-engine-shared' +import { toHex } from '@narval/policy-engine-shared' import { Alg, Payload, + PrivateKey, + PublicKey, SigningAlg, buildSignerEip191, buildSignerEs256k, - privateKeyToJwk, + secp256k1PrivateKeyToJwk, signJwt } from '@narval/signature' import { Injectable } from '@nestjs/common' @@ -17,8 +19,8 @@ type KeyGenerationOptions = { } type KeyGenerationResponse = { - publicKey: JsonWebKey - privateKey?: JsonWebKey + publicKey: PublicKey + privateKey?: PrivateKey } type SignOptions = { @@ -32,7 +34,7 @@ export class SigningService { async generateSigningKey(alg: Alg, options?: KeyGenerationOptions): Promise { if (alg === Alg.ES256K) { const privateKey = toHex(secp256k1.utils.randomPrivateKey()) - const privateJwk = privateKeyToJwk(privateKey, options?.keyId) + const privateJwk = secp256k1PrivateKeyToJwk(privateKey, options?.keyId) // Remove the privateKey from the public jwk const publicJwk = { @@ -49,21 +51,14 @@ export class SigningService { throw new Error('Unsupported algorithm') } - async sign(payload: Payload, jwk: JsonWebKey, opts: SignOptions = {}): Promise { + async sign(payload: Payload, jwk: PrivateKey, opts: SignOptions = {}): Promise { const alg: SigningAlg = opts.alg || jwk.alg if (alg === SigningAlg.ES256K) { - if (!jwk.d) { - throw new Error('Missing private key') - } const pk = jwk.d - const jwt = await signJwt(payload, jwk, opts, buildSignerEs256k(pk)) return jwt } else if (alg === SigningAlg.EIP191) { - if (!jwk.d) { - throw new Error('Missing private key') - } const pk = jwk.d const jwt = await signJwt(payload, jwk, opts, buildSignerEip191(pk)) diff --git a/apps/policy-engine/src/engine/persistence/repository/entity.repository.ts b/apps/policy-engine/src/engine/persistence/repository/entity.repository.ts index cd132f3a4..5da6945c9 100644 --- a/apps/policy-engine/src/engine/persistence/repository/entity.repository.ts +++ b/apps/policy-engine/src/engine/persistence/repository/entity.repository.ts @@ -14,9 +14,6 @@ export class EntityRepository { return FIXTURE.ENTITIES } - getCredentialForPubKey(pubKey: string): CredentialEntity | null { - return FIXTURE.ENTITIES.credentials.find((cred) => cred.pubKey === pubKey) || null - } getCredential(id: string): CredentialEntity | null { return FIXTURE.ENTITIES.credentials.find((cred) => cred.id === id) || null } diff --git a/apps/policy-engine/src/engine/persistence/repository/mock_data.ts b/apps/policy-engine/src/engine/persistence/repository/mock_data.ts index b8b2aecce..74a260d7e 100644 --- a/apps/policy-engine/src/engine/persistence/repository/mock_data.ts +++ b/apps/policy-engine/src/engine/persistence/repository/mock_data.ts @@ -1,5 +1,5 @@ import { Action, EvaluationRequest, FIXTURE, Request, TransactionRequest } from '@narval/policy-engine-shared' -import { Payload, SigningAlg, buildSignerEip191, hash, privateKeyToJwk, signJwt } from '@narval/signature' +import { Payload, SigningAlg, buildSignerEip191, hash, secp256k1PrivateKeyToJwk, signJwt } from '@narval/signature' import { UNSAFE_PRIVATE_KEY } from 'packages/policy-engine-shared/src/lib/dev.fixture' import { toHex } from 'viem' @@ -31,19 +31,19 @@ export const generateInboundRequest = async (): Promise => { // const aliceSignature = await FIXTURE.ACCOUNT.Alice.signMessage({ message }) const aliceSignature = await signJwt( payload, - privateKeyToJwk(UNSAFE_PRIVATE_KEY.Alice), + secp256k1PrivateKeyToJwk(UNSAFE_PRIVATE_KEY.Alice), { alg: SigningAlg.EIP191 }, buildSignerEip191(UNSAFE_PRIVATE_KEY.Alice) ) const bobSignature = await signJwt( payload, - privateKeyToJwk(UNSAFE_PRIVATE_KEY.Bob), + secp256k1PrivateKeyToJwk(UNSAFE_PRIVATE_KEY.Bob), { alg: SigningAlg.EIP191 }, buildSignerEip191(UNSAFE_PRIVATE_KEY.Bob) ) const carolSignature = await signJwt( payload, - privateKeyToJwk(UNSAFE_PRIVATE_KEY.Carol), + secp256k1PrivateKeyToJwk(UNSAFE_PRIVATE_KEY.Carol), { alg: SigningAlg.EIP191 }, buildSignerEip191(UNSAFE_PRIVATE_KEY.Carol) ) diff --git a/packages/policy-engine-shared/src/lib/dev.fixture.ts b/packages/policy-engine-shared/src/lib/dev.fixture.ts index 327122656..fa7e98b12 100644 --- a/packages/policy-engine-shared/src/lib/dev.fixture.ts +++ b/packages/policy-engine-shared/src/lib/dev.fixture.ts @@ -1,4 +1,4 @@ -import { Alg, addressToKid } from '@narval/signature' +import { Alg, Curves, KeyTypes, Use } from '@narval/signature' import { PrivateKeyAccount } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { Action } from './type/action.type' @@ -81,39 +81,66 @@ export const USER: Record = { export const CREDENTIAL: Record = { Root: { - id: addressToKid(ACCOUNT.Root.address), - pubKey: ACCOUNT.Root.publicKey, - address: ACCOUNT.Root.address, - alg: Alg.ES256K, - userId: USER.Root.id + id: '0x20FB9603DC2C011aBFdFbf270bD627e94065cBb9', + userId: USER.Root.id, + key: { + kty: KeyTypes.EC, + use: Use.ENC, + crv: Curves.SECP256K1, + alg: Alg.ES256K, + kid: '0x20FB9603DC2C011aBFdFbf270bD627e94065cBb9', + x: 'crqZ2XkCBgl1XwxjlQ02PKm_JJ4wJAkANJ6DidZRzTw', + y: 'GyAbgM5_HOaPmAHNatWanWmhLgaznyNHUIw5YUe_yyw' + } }, Alice: { - id: addressToKid(ACCOUNT.Alice.address), - pubKey: ACCOUNT.Alice.publicKey, - address: ACCOUNT.Alice.address, - alg: Alg.ES256K, - userId: USER.Alice.id + id: '0xcdE93dc1C6D8AF279c33069233aEE5542F308594', + userId: USER.Alice.id, + key: { + kty: KeyTypes.EC, + use: Use.SIG, + crv: Curves.SECP256K1, + alg: Alg.ES256K, + kid: '0xcdE93dc1C6D8AF279c33069233aEE5542F308594', + x: 'vjNVzbnLxdazY0M-2BDnX54JexB8Pa9n_fucDJli6Bo', + y: 'jOAwUCXcLz7nhvW2mSwPBCZwv856ybAGK7LS6hvfdFQ' + } }, Bob: { - id: addressToKid(ACCOUNT.Bob.address), - pubKey: ACCOUNT.Bob.publicKey, - address: ACCOUNT.Bob.address, - alg: Alg.ES256K, + id: '0x9A5Bd18C902887DCc2D881a352010C15eea229d', + key: { + kty: KeyTypes.EC, + crv: Curves.SECP256K1, + alg: Alg.ES256K, + kid: '0xc7916Ee805440bB386a88d09AED8688eFb99CB0F', + x: 'MjsuvdMuxs1AoQ12BuARzzTyilJNh2jQmErMZwR2M-E', + y: 'axLms3pGEX0Xujho5welzcn9mx_oV0Bs3uVeG9-eCqU' + }, userId: USER.Bob.id }, Carol: { - id: addressToKid(ACCOUNT.Carol.address), - pubKey: ACCOUNT.Carol.publicKey, - address: ACCOUNT.Carol.address, - alg: Alg.ES256K, - userId: USER.Carol.id + id: '0xe99c6FBb2eE939682AB8A216a893cBD21CC2f982', + userId: USER.Carol.id, + key: { + kty: KeyTypes.EC, + crv: Curves.SECP256K1, + alg: Alg.ES256K, + kid: '0x9AA5Bd18C902887DCc2D881a352010C15eea229d', + x: '4n3yf5qUBU0sDH9yGjdfiVRFEnQndbd5yGEupSdG6R4', + y: 'FESQhctMSQOF2E79YbCE8q1JIQWltMbvoCVwSsO19ck' + } }, Dave: { - id: addressToKid(ACCOUNT.Dave.address), - pubKey: ACCOUNT.Dave.publicKey, - address: ACCOUNT.Dave.address, - alg: Alg.ES256K, - userId: USER.Dave.id + id: '0xddd26a02e7c54e8dc373b9d2dcb309ecdeca815d', + userId: USER.Dave.id, + key: { + kty: KeyTypes.EC, + crv: Curves.SECP256K1, + alg: Alg.ES256K, + kid: '0xe99c6FBb2eE939682AB8A216a893cBD21CC2f982', + x: 'sdb8VZcfcI6t5i7BD3BTPoZPyYCxaVpw7H1BIUyPZ5M', + y: 'cIcYdzuWF7KqFKJrdQSmdjPpQzrk9_uzNycqtvtH1QI' + } } } diff --git a/packages/policy-engine-shared/src/lib/schema/entity.schema.ts b/packages/policy-engine-shared/src/lib/schema/entity.schema.ts index 1981f384f..144261185 100644 --- a/packages/policy-engine-shared/src/lib/schema/entity.schema.ts +++ b/packages/policy-engine-shared/src/lib/schema/entity.schema.ts @@ -1,4 +1,4 @@ -import { Alg } from '@narval/signature' +import { publicKeySchema } from '@narval/signature' import { z } from 'zod' import { addressSchema } from './address.schema' @@ -23,10 +23,9 @@ export const accountClassificationSchema = z.nativeEnum({ export const credentialEntitySchema = z.object({ id: z.string(), - pubKey: z.string(), - address: z.string().optional(), - alg: z.nativeEnum(Alg), - userId: z.string() + userId: z.string(), + key: publicKeySchema + // TODO @ptroger: Should we be allowing a private key to be passed in entity data ? }) export const organizationEntitySchema = z.object({ diff --git a/packages/signature/src/index.ts b/packages/signature/src/index.ts index a8dcd0d7b..17ae55436 100644 --- a/packages/signature/src/index.ts +++ b/packages/signature/src/index.ts @@ -1,5 +1,6 @@ export * from './lib/decode' export * from './lib/hash-request' +export * from './lib/schemas' export * from './lib/sign' export * from './lib/types' export * from './lib/utils' diff --git a/packages/signature/src/lib/__test__/unit/sign.spec.ts b/packages/signature/src/lib/__test__/unit/sign.spec.ts index aa5010cdb..f27ec0a32 100644 --- a/packages/signature/src/lib/__test__/unit/sign.spec.ts +++ b/packages/signature/src/lib/__test__/unit/sign.spec.ts @@ -5,14 +5,14 @@ import { createPublicKey } from 'node:crypto' import { toHex, verifyMessage } from 'viem' import { privateKeyToAccount, signMessage } from 'viem/accounts' import { buildSignerEip191, buildSignerEs256k, signJwt } from '../../sign' -import { Alg, JWK, Payload, SigningAlg } from '../../types' +import { Alg, Payload, PrivateKey, SigningAlg } from '../../types' import { base64UrlToBytes, base64UrlToHex, - jwkToPrivateKey, - jwkToPublicKey, - privateKeyToJwk, - publicKeyToJwk + secp256k1PrivateKeyToHex, + secp256k1PrivateKeyToJwk, + secp256k1PublicKeyToHex, + secp256k1PublicKeyToJwk } from '../../utils' import { verifyJwt } from '../../verify' import { HEADER_PART, PAYLOAD_PART, PRIVATE_KEY_PEM } from './mock' @@ -38,14 +38,14 @@ describe('sign', () => { it('should sign build & sign es256 JWT correctly with a PEM', async () => { const key = await importPKCS8(PRIVATE_KEY_PEM, Alg.ES256) const jwk = await exportJWK(key) - const jwt = await signJwt(payload, { ...jwk, alg: Alg.ES256 } as JWK) + const jwt = await signJwt(payload, { ...jwk, alg: Alg.ES256 } as PrivateKey) const verified = await jwtVerify(jwt, key) expect(verified.payload).toEqual(payload) }) it('should build & sign a EIP191 JWT', async () => { - const jwk = privateKeyToJwk(`0x${ENGINE_PRIVATE_KEY}`) + const jwk = secp256k1PrivateKeyToJwk(`0x${ENGINE_PRIVATE_KEY}`) const signer = buildSignerEip191(ENGINE_PRIVATE_KEY) const jwt = await signJwt(payload, jwk, { alg: SigningAlg.EIP191 }, signer) @@ -146,7 +146,7 @@ describe('sign', () => { const viemPubKey = privateKeyToAccount(`0x${ENGINE_PRIVATE_KEY}`).publicKey expect(toHex(publicKey)).toBe(viemPubKey) // Confirm that our key is in fact the same as what viem would give. - const jwk = privateKeyToJwk(`0x${ENGINE_PRIVATE_KEY}`) + const jwk = secp256k1PrivateKeyToJwk(`0x${ENGINE_PRIVATE_KEY}`) const k = await createPublicKey({ format: 'jwk', @@ -157,15 +157,15 @@ describe('sign', () => { }) it('should convert to and from jwk', async () => { - const jwk = privateKeyToJwk(`0x${ENGINE_PRIVATE_KEY}`) - const pk = jwkToPrivateKey(jwk) + const jwk = secp256k1PrivateKeyToJwk(`0x${ENGINE_PRIVATE_KEY}`) + const pk = secp256k1PrivateKeyToHex(jwk) expect(pk).toBe(`0x${ENGINE_PRIVATE_KEY}`) }) it('should convert to and from public jwk', async () => { const publicKey = secp256k1.getPublicKey(ENGINE_PRIVATE_KEY, false) - const jwk = publicKeyToJwk(toHex(publicKey)) - const pk = jwkToPublicKey(jwk) + const jwk = secp256k1PublicKeyToJwk(toHex(publicKey)) + const pk = secp256k1PublicKeyToHex(jwk) expect(pk).toBe(toHex(publicKey)) }) }) diff --git a/packages/signature/src/lib/__test__/unit/verify.spec.ts b/packages/signature/src/lib/__test__/unit/verify.spec.ts index b1502a345..b08f0e881 100644 --- a/packages/signature/src/lib/__test__/unit/verify.spec.ts +++ b/packages/signature/src/lib/__test__/unit/verify.spec.ts @@ -1,13 +1,13 @@ import { hash } from '../../hash-request' import { Payload } from '../../types' -import { privateKeyToJwk } from '../../utils' +import { secp256k1PrivateKeyToJwk } from '../../utils' import { verifyJwt } from '../../verify' describe('verify', () => { const ENGINE_PRIVATE_KEY = '7cfef3303797cbc7515d9ce22ffe849c701b0f2812f999b0847229c47951fca5' it('should verify a EIP191-signed JWT', async () => { - const jwk = privateKeyToJwk(`0x${ENGINE_PRIVATE_KEY}`) + const jwk = secp256k1PrivateKeyToJwk(`0x${ENGINE_PRIVATE_KEY}`) const header = { kid: '0x2c4895215973CbBd778C32c456C074b99daF8Bf1', diff --git a/packages/signature/src/lib/address.schema.ts b/packages/signature/src/lib/address.schema.ts new file mode 100644 index 000000000..57c0d165d --- /dev/null +++ b/packages/signature/src/lib/address.schema.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' +import { isAddress } from './evm.util' + +/** + * Schema backward compatible with viem's Address type. + * + * @see https://viem.sh/docs/glossary/types#address + */ +export const addressSchema = z.custom<`0x${string}`>( + (value) => { + const parse = z.string().safeParse(value) + + if (parse.success) { + return isAddress(parse.data) + } + + return false + }, + { + message: 'value is an invalid Ethereum address' + } +) diff --git a/packages/signature/src/lib/evm.util.ts b/packages/signature/src/lib/evm.util.ts new file mode 100644 index 000000000..58dad15ce --- /dev/null +++ b/packages/signature/src/lib/evm.util.ts @@ -0,0 +1,47 @@ +// eslint-disable-next-line no-restricted-imports +import { InvalidAddressError, getAddress as viemGetAddress, isAddress as viemIsAddress } from 'viem' + +type Address = `0x${string}` + +/** + * Checks if a string is a valid Ethereum address without regard of its format. + * + * @param address - The string to be checked. + * @returns Returns true if the string is a valid Ethereum address, otherwise + * returns false. + */ +export const isAddress = (address: string): boolean => { + if (!/^(0x)?[0-9a-fA-F]{40}$/.test(address)) { + return false + } else if (/^(0x)?[0-9a-f]{40}$/.test(address) || /^(0x)?[0-9A-F]{40}$/.test(address)) { + return true + } else { + return viemIsAddress(address) + } +} + +/** + * Retrieves the Ethereum address from a given string representation without + * regard of its format. + * + * @param address - The string representation of the Ethereum address. + * @param options - Optional parameters for address retrieval. + * @param options.checksum - Specifies whether the retrieved address should be + * checksummed. + * @param options.chainId - The chain ID to be used for address retrieval. + * @returns The Ethereum address. + * @throws {InvalidAddressError} if the provided address is invalid. + */ +export const getAddress = (address: string, options?: { checksum?: boolean; chainId?: number }): Address => { + if (isAddress(address)) { + const validAddress = address as Address + + if (options?.checksum || options?.chainId) { + return viemGetAddress(validAddress, options.chainId) + } + + return validAddress + } + + throw new InvalidAddressError({ address }) +} diff --git a/packages/signature/src/lib/schemas.ts b/packages/signature/src/lib/schemas.ts new file mode 100644 index 000000000..d34d7e20d --- /dev/null +++ b/packages/signature/src/lib/schemas.ts @@ -0,0 +1,76 @@ +import { z } from 'zod' +import { addressSchema } from './address.schema' +import { Alg, Curves, KeyTypes, Use } from './types' + +// Base JWK Schema +export const jwkBaseSchema = z.object({ + kty: z.nativeEnum(KeyTypes), + alg: z.nativeEnum(Alg), + use: z.nativeEnum(Use).optional(), + kid: z.string(), + addr: z.string().optional() +}) + +export const jwkEoaSchema = z.object({ + kty: z.literal(KeyTypes.EC), + crv: z.enum([Curves.SECP256K1]), + alg: z.literal(Alg.ES256K), + use: z.nativeEnum(Use).optional(), + kid: z.string(), + addr: addressSchema +}) + +// EC Base Schema +export const ecBaseSchema = jwkBaseSchema.extend({ + kty: z.literal(KeyTypes.EC), + crv: z.enum([Curves.SECP256K1, Curves.P256]), + x: z.string(), + y: z.string() +}) + +// RSA Base Schema +export const rsaBaseSchema = jwkBaseSchema.extend({ + kty: z.literal(KeyTypes.RSA), + alg: z.literal(Alg.RS256), + n: z.string(), + e: z.string() +}) + +// Specific Schemas for Public Keys +export const secp256k1PublicKeySchema = ecBaseSchema.extend({ + crv: z.literal(Curves.SECP256K1), + alg: z.literal(Alg.ES256K) +}) + +export const p256PublicKeySchema = ecBaseSchema.extend({ + crv: z.literal(Curves.P256), + alg: z.literal(Alg.ES256) +}) + +export const rsaPublicKeySchema = rsaBaseSchema + +// Specific Schemas for Private Keys +export const secp256k1PrivateKeySchema = secp256k1PublicKeySchema.extend({ + d: z.string() +}) + +export const p256PrivateKeySchema = p256PublicKeySchema.extend({ + d: z.string() +}) + +export const rsaPrivateKeySchema = rsaPublicKeySchema.extend({ + d: z.string() +}) + +export const publicKeySchema = z.union([ + secp256k1PublicKeySchema, + p256PublicKeySchema, + rsaPublicKeySchema, + jwkEoaSchema +]) + +export const privateKeySchema = z.union([secp256k1PrivateKeySchema, p256PrivateKeySchema, rsaPrivateKeySchema]) + +export const secp256k1KeySchema = z.union([secp256k1PublicKeySchema, secp256k1PrivateKeySchema]) + +export const jwkSchema = z.union([publicKeySchema, privateKeySchema]) diff --git a/packages/signature/src/lib/sign.ts b/packages/signature/src/lib/sign.ts index d9bb8e149..906f3a51c 100644 --- a/packages/signature/src/lib/sign.ts +++ b/packages/signature/src/lib/sign.ts @@ -3,13 +3,13 @@ import { sha256 as sha256Hash } from '@noble/hashes/sha256' import { keccak_256 as keccak256 } from '@noble/hashes/sha3' import { SignJWT, base64url, importJWK } from 'jose' import { isHex, signatureToHex, toBytes, toHex } from 'viem' -import { EcdsaSignature, Header, Hex, JWK, Payload, SigningAlg } from './types' +import { EcdsaSignature, Header, Hex, Payload, PrivateKey, SigningAlg } from './types' import { hexToBase64Url } from './utils' // WIP to replace `sign` export async function signJwt( payload: Payload, - jwk: JWK, + jwk: PrivateKey, opts: { alg?: SigningAlg } = {}, signer?: (payload: string) => Promise ): Promise { diff --git a/packages/signature/src/lib/typeguards.ts b/packages/signature/src/lib/typeguards.ts index a64ba40a9..fe344902e 100644 --- a/packages/signature/src/lib/typeguards.ts +++ b/packages/signature/src/lib/typeguards.ts @@ -1,4 +1,5 @@ -import { Header, Payload, SigningAlg } from './types' +import { jwkEoaSchema, jwkSchema, secp256k1PublicKeySchema } from './schemas' +import { EoaPublicKey, Header, Jwk, Payload, Secp256k1PublicKey, SigningAlg } from './types' function isAlg(alg: unknown): alg is SigningAlg { return typeof alg === 'string' && Object.values(SigningAlg).includes(alg as SigningAlg) @@ -8,6 +9,14 @@ function isStringNonNull(kid: unknown): kid is string { return typeof kid === 'string' && kid.length > 0 } +export function isJwk(jwk: unknown): jwk is Jwk { + return jwkSchema.safeParse(jwk).success +} + +export const isSepc256k1PublicKeyJwk = (jwk: Jwk): jwk is Secp256k1PublicKey => + secp256k1PublicKeySchema.safeParse(jwk).success +export const isEoaPublicKeyJwk = (jwk: Jwk): jwk is EoaPublicKey => jwkEoaSchema.safeParse(jwk).success + export function isHeader(header: unknown): header is Header { return ( typeof header === 'object' && diff --git a/packages/signature/src/lib/types.ts b/packages/signature/src/lib/types.ts index 2e7c9fbc8..d5ac704e2 100644 --- a/packages/signature/src/lib/types.ts +++ b/packages/signature/src/lib/types.ts @@ -1,3 +1,14 @@ +import { z } from 'zod' +import { + jwkEoaSchema, + jwkSchema, + privateKeySchema, + publicKeySchema, + secp256k1KeySchema, + secp256k1PrivateKeySchema, + secp256k1PublicKeySchema +} from './schemas' + export const KeyTypes = { EC: 'EC', RSA: 'RSA' @@ -36,19 +47,14 @@ export const Use = { export type Use = (typeof Use)[keyof typeof Use] -export type JWK = { - kty: 'EC' | 'RSA' - kid: string - alg: 'ES256K' | 'ES256' | 'RS256' - crv?: 'P-256' | 'secp256k1' | undefined - use?: 'sig' | 'enc' | undefined - n?: string | undefined - e?: string | undefined - x?: string | undefined - y?: string | undefined - d?: string | undefined - addr?: Hex | undefined -} +export type Secp256k1PrivateKey = z.infer +export type EoaPublicKey = z.infer +export type Secp256k1PublicKey = z.infer +export type Secp256k1KeySchema = z.infer +export type PublicKey = z.infer +export type PrivateKey = z.infer +export type Jwk = z.infer + export type Hex = `0x${string}` // DOMAIN /** @@ -78,7 +84,7 @@ export type Header = { * @param {string} sub - The subject of the JWT. * @param {string} [aud] - The audience of the JWT. * @param {string} [jti] - The JWT ID. - * @param {JWK} cnf - The client-bound key. + * @param {Jwk} cnf - The client-bound key. * */ export type Payload = { @@ -88,7 +94,7 @@ export type Payload = { iss?: string aud?: string jti?: string - cnf?: JWK // The client-bound key + cnf?: Jwk // The client-bound key requestHash?: string data?: string // hash of any data } diff --git a/packages/signature/src/lib/utils.ts b/packages/signature/src/lib/utils.ts index b5955e023..78dde8d87 100644 --- a/packages/signature/src/lib/utils.ts +++ b/packages/signature/src/lib/utils.ts @@ -1,8 +1,8 @@ import { secp256k1 } from '@noble/curves/secp256k1' import { sha256 as sha256Hash } from '@noble/hashes/sha256' import { toHex } from 'viem' -import { getAddress, publicKeyToAddress } from 'viem/utils' -import { Alg, Curves, Hex, JWK, KeyTypes } from './types' +import { publicKeyToAddress } from 'viem/utils' +import { Alg, Curves, Hex, KeyTypes, Secp256k1KeySchema, Secp256k1PrivateKey, Secp256k1PublicKey } from './types' export const algToJwk = ( alg: Alg @@ -39,7 +39,7 @@ export const addressToKid = (address: string): string => { } // ES256k -export const publicKeyToJwk = (publicKey: Hex, keyId?: string): JWK => { +export const secp256k1PublicKeyToJwk = (publicKey: Hex, keyId?: string): Secp256k1PublicKey => { // remove the 0x04 prefix -- 04 means it's an uncompressed ECDSA key, 02 or 03 means compressed -- these need to be removed in a JWK! const hexPubKey = publicKey.slice(4) const x = hexPubKey.slice(0, 64) @@ -56,39 +56,22 @@ export const publicKeyToJwk = (publicKey: Hex, keyId?: string): JWK => { } // ES256k -export const privateKeyToJwk = (privateKey: Hex, keyId?: string): JWK => { +export const secp256k1PrivateKeyToJwk = (privateKey: Hex, keyId?: string): Secp256k1PrivateKey => { const publicKey = toHex(secp256k1.getPublicKey(privateKey.slice(2), false)) - const publicJwk = publicKeyToJwk(publicKey, keyId) + const publicJwk = secp256k1PublicKeyToJwk(publicKey, keyId) return { ...publicJwk, d: hexToBase64Url(privateKey) } } -// Eth EOA -export const addressToJwk = (address: string, keyId?: string): JWK => { - return { - kty: KeyTypes.EC, - crv: Curves.SECP256K1, - alg: Alg.ES256K, - kid: keyId || addressToKid(address), - addr: getAddress(address) - } -} - -export const jwkToPublicKey = (jwk: JWK): Hex => { - if (!jwk.x || !jwk.y) { - throw new Error('Invalid JWK; missing x or y') - } +export const secp256k1PublicKeyToHex = (jwk: Secp256k1KeySchema): Hex => { const x = base64UrlToHex(jwk.x) const y = base64UrlToHex(jwk.y) return `0x04${x.slice(2)}${y.slice(2)}` } -export const jwkToPrivateKey = (jwk: JWK): Hex => { - if (!jwk.d) { - throw new Error('Invalid JWK; missing d') - } +export const secp256k1PrivateKeyToHex = (jwk: Secp256k1PrivateKey): Hex => { return base64UrlToHex(jwk.d) } diff --git a/packages/signature/src/lib/verify.ts b/packages/signature/src/lib/verify.ts index c7a0a4abe..47c18cf26 100644 --- a/packages/signature/src/lib/verify.ts +++ b/packages/signature/src/lib/verify.ts @@ -4,8 +4,9 @@ import { isAddressEqual, recoverAddress } from 'viem' import { decode } from './decode' import { JwtError } from './error' import { eip191Hash } from './sign' -import { Hex, JWK, Jwt, Payload, SigningAlg } from './types' -import { base64UrlToHex, jwkToPublicKey } from './utils' +import { isSepc256k1PublicKeyJwk } from './typeguards' +import { Alg, EoaPublicKey, Hex, Jwk, Jwt, Payload, PublicKey, Secp256k1PublicKey, SigningAlg } from './types' +import { base64UrlToHex, secp256k1PublicKeyToHex } from './utils' const checkTokenExpiration = (payload: Payload): boolean => { const now = Math.floor(Date.now() / 1000) @@ -26,38 +27,55 @@ const verifyEip191WithRecovery = async (sig: Hex, hash: Uint8Array, address: Hex return true } -const verifyEip191WithPublicKey = async (sig: Hex, hash: Uint8Array, jwk: JWK): Promise => { - const pub = jwkToPublicKey(jwk) +const verifyEip191WithPublicKey = async (sig: Hex, hash: Uint8Array, jwk: PublicKey): Promise => { + if (isSepc256k1PublicKeyJwk(jwk)) { + const pub = secp256k1PublicKeyToHex(jwk) + // A eth sig has a `v` value of 27 or 28, so we need to remove that to get the signature + // And we remove the 0x prefix. So that means we slice the first and last 2 bytes, leaving the 128 character signature + const isValid = secp256k1.verify(sig.slice(2, 130), hash, pub.slice(2)) === true + if (!isValid) { + throw new Error('Invalid JWT signature') + } + return isValid + } + throw new JwtError({ + message: 'Validation error: unsupported algorithm', + context: { jwk } + }) +} - // A eth sig has a `v` value of 27 or 28, so we need to remove that to get the signature - // And we remove the 0x prefix. So that means we slice the first and last 2 bytes, leaving the 128 character signature - const isValid = secp256k1.verify(sig.slice(2, 130), hash, pub.slice(2)) === true - if (!isValid) { - throw new Error('Invalid JWT signature') +const verifySepc256k1 = async ( + sig: Hex, + hash: Uint8Array, + jwk: Secp256k1PublicKey | EoaPublicKey +): Promise => { + if (isSepc256k1PublicKeyJwk(jwk)) { + await verifyEip191WithPublicKey(sig, hash, jwk) + } else { + await verifyEip191WithRecovery(sig, hash, jwk.addr) } - return isValid + return true } -export const verifyEip191 = async (jwt: string, jwk: JWK): Promise => { +export const verifyEip191 = async (jwt: string, jwk: PublicKey): Promise => { const [headerStr, payloadStr, jwtSig] = jwt.split('.') const verificationMsg = [headerStr, payloadStr].join('.') const msg = eip191Hash(verificationMsg) const sig = base64UrlToHex(jwtSig) - // If we have an Address but no x & y, recover the address from the signature to verify - // Otherwise, verify directly against the public key from the x&y. - if (jwk.x && jwk.y) { - await verifyEip191WithPublicKey(sig, msg, jwk) - } else if (jwk.addr) { - await verifyEip191WithRecovery(sig, msg, jwk.addr) + if (jwk.alg === Alg.ES256K) { + await verifySepc256k1(sig, msg, jwk) } else { - throw new Error('Invalid JWK, no x & y or address') + throw new JwtError({ + message: 'Validation error: unsupported algorithm', + context: { jwk } + }) } return true } -export async function verifyJwt(jwt: string, jwk: JWK): Promise { +export async function verifyJwt(jwt: string, jwk: Jwk): Promise { const { header, payload, signature } = decode(jwt) if (header.alg === SigningAlg.EIP191) {