diff --git a/apps/devtool/src/app/components/EditorComponent.tsx b/apps/devtool/src/app/components/EditorComponent.tsx index c6d2d1cc5..44b737d31 100644 --- a/apps/devtool/src/app/components/EditorComponent.tsx +++ b/apps/devtool/src/app/components/EditorComponent.tsx @@ -1,7 +1,8 @@ 'use client' import Editor from '@monaco-editor/react' -import { signMessage } from '@wagmi/core' +import { JWK, Payload, SigningAlg, hash, hexToBase64Url, signJwt } from '@narval/signature' +import { getAccount, signMessage } from '@wagmi/core' import axios from 'axios' import Image from 'next/image' import { useEffect, useRef, useState } from 'react' @@ -36,8 +37,40 @@ const EditorComponent = () => { const { entity, policy } = JSON.parse(data) - const entitySig = await signMessage(config, { message: JSON.stringify(entity) }) - const policySig = await signMessage(config, { message: JSON.stringify(policy) }) + const jwtSigner = async (msg: string) => { + const jwtSig = await signMessage(config, { message: msg }) + + return hexToBase64Url(jwtSig) + } + + const address = getAccount(config).address + if (!address) throw new Error('No address connected') + + // Need real JWK + const jwk: JWK = { + kty: 'EC', + crv: 'secp256k1', + alg: SigningAlg.ES256K, + kid: address + } + + const now = Math.floor(Date.now() / 1000) + + const entityPayload: Payload = { + data: hash(entity), + sub: address, + iss: 'https://devtool.narval.xyz', + iat: now + } + + const policyPayload: Payload = { + data: hash(policy), + sub: address, + iss: 'https://devtool.narval.xyz', + iat: now + } + const entitySig = await signJwt(entityPayload, jwk, { alg: SigningAlg.EIP191 }, jwtSigner) + const policySig = await signJwt(policyPayload, jwk, { alg: SigningAlg.EIP191 }, jwtSigner) await axios.post('/api/data-store', { entity: { diff --git a/apps/policy-engine/src/app/core/service/signing.service.ts b/apps/policy-engine/src/app/core/service/signing.service.ts index 10fc74dc8..8728ac5c5 100644 --- a/apps/policy-engine/src/app/core/service/signing.service.ts +++ b/apps/policy-engine/src/app/core/service/signing.service.ts @@ -1,8 +1,15 @@ import { JsonWebKey, toHex } from '@narval/policy-engine-shared' -import { Alg, Payload, SigningAlg, privateKeyToJwk } from '@narval/signature' +import { + Alg, + Payload, + SigningAlg, + buildSignerEip191, + buildSignerEs256k, + privateKeyToJwk, + signJwt +} from '@narval/signature' import { Injectable } from '@nestjs/common' import { secp256k1 } from '@noble/curves/secp256k1' -import { buildSignerEip191, buildSignerEs256k, signJwt } from 'packages/signature/src/lib/sign' // Optional additional configs, such as for MPC-based DKG. type KeyGenerationOptions = { diff --git a/packages/signature/src/lib/__test__/unit/verify.spec.ts b/packages/signature/src/lib/__test__/unit/verify.spec.ts index 389208d5d..b1502a345 100644 --- a/packages/signature/src/lib/__test__/unit/verify.spec.ts +++ b/packages/signature/src/lib/__test__/unit/verify.spec.ts @@ -1,3 +1,4 @@ +import { hash } from '../../hash-request' import { Payload } from '../../types' import { privateKeyToJwk } from '../../utils' import { verifyJwt } from '../../verify' @@ -40,4 +41,51 @@ describe('verify', () => { signature: 'gFDywYsxY2-uT6H6hyxk51CtJhAZpI8WtcvoXHltiWsoBVOot1zMo3nHAhkWlYRmD3RuLtmOYzi6TwTUM8mFyBs' }) }) + + it('verifies a JWT signed by wagmi on client', async () => { + // Example data from devtool ui + const policy = [ + { + id: 'a68e8d20-0419-475c-8fcc-b17d4de8c955', + name: 'Authorized any admin to transfer ERC721 or ERC1155 tokens', + when: [ + { + criterion: 'checkResourceIntegrity', + args: null + }, + { + criterion: 'checkPrincipalRole', + args: ['admin'] + }, + { + criterion: 'checkAction', + args: ['signTransaction'] + }, + { + criterion: 'checkIntentType', + args: ['transferErc721', 'transferErc1155'] + } + ], + then: 'permit' + } + ] + + // JWT signed w/ real metamask, narval dev-wallet 0x04B12F0863b83c7162429f0Ebb0DfdA20E1aA97B + const jwt = + 'eyJraWQiOiIweDA0QjEyRjA4NjNiODNjNzE2MjQyOWYwRWJiMERmZEEyMEUxYUE5N0IiLCJhbGciOiJFSVAxOTEiLCJ0eXAiOiJKV1QifQ.eyJkYXRhIjoiYzg2YWNkNzk3ODFmYTRjODRkZTEyNjk1YTYxODVkZWRiZDVlNTczN2UwYjlhMWEzOGYxYzliZDI4ZGE5MWJiNCIsInN1YiI6IjB4MDRCMTJGMDg2M2I4M2M3MTYyNDI5ZjBFYmIwRGZkQTIwRTFhQTk3QiIsImlzcyI6Imh0dHBzOi8vZGV2dG9vbC5uYXJ2YWwueHl6IiwiaWF0IjoxNzEwMTgyMDgxfQ.Q0p7sJxqDMhmyrCuJqH48y0sgbWUzs9zuANV0rYdyyphXMlxdBN5Jme37QNZ_NWtH-O2RNZe9nVY0iJuvDurexw' + + // We do NOT have a publicKey, only the address. So we need to be able to verify with that only. + const res = await verifyJwt(jwt, { + kty: 'EC', + crv: 'secp256k1', + alg: 'ES256K', + use: 'sig', + kid: 'made-up-kid-that-matches-nothing', + addr: '0x04B12F0863b83c7162429f0Ebb0DfdA20E1aA97B' + }) + const policyHash = hash(policy) + + expect(res).toBeDefined() + expect(res.payload.data).toEqual(policyHash) + }) }) diff --git a/packages/signature/src/lib/types.ts b/packages/signature/src/lib/types.ts index ea7996b12..fe9f92be7 100644 --- a/packages/signature/src/lib/types.ts +++ b/packages/signature/src/lib/types.ts @@ -47,6 +47,7 @@ export type JWK = { x?: string | undefined y?: string | undefined d?: string | undefined + addr?: Hex | undefined } export type Hex = `0x${string}` // DOMAIN @@ -83,8 +84,9 @@ export type Payload = { iss: string aud?: string jti?: string - cnf: JWK // The client-bound key - requestHash: string + cnf?: JWK // The client-bound key + requestHash?: string + data?: string // hash of any data } export type Jwt = { diff --git a/packages/signature/src/lib/utils.ts b/packages/signature/src/lib/utils.ts index f0090cd23..4887ae715 100644 --- a/packages/signature/src/lib/utils.ts +++ b/packages/signature/src/lib/utils.ts @@ -1,6 +1,6 @@ import { secp256k1 } from '@noble/curves/secp256k1' import { toHex } from 'viem' -import { publicKeyToAddress } from 'viem/utils' +import { getAddress, publicKeyToAddress } from 'viem/utils' import { Alg, Curves, Hex, JWK, KeyTypes } from './types' export const algToJwk = ( @@ -60,6 +60,17 @@ export const privateKeyToJwk = (privateKey: Hex, keyId?: string): JWK => { } } +// Eth EOA +export const addressToJwk = (address: string, keyId?: string): JWK => { + return { + kty: KeyTypes.EC, + crv: Curves.SECP256K1, + alg: Alg.ES256K, + kid: keyId || getAddress(address), + addr: getAddress(address) + } +} + export const jwkToPublicKey = (jwk: JWK): Hex => { if (!jwk.x || !jwk.y) { throw new Error('Invalid JWK; missing x or y') diff --git a/packages/signature/src/lib/verify.ts b/packages/signature/src/lib/verify.ts index c85731ee1..c7a0a4abe 100644 --- a/packages/signature/src/lib/verify.ts +++ b/packages/signature/src/lib/verify.ts @@ -1,9 +1,10 @@ import { secp256k1 } from '@noble/curves/secp256k1' import { importJWK, jwtVerify } from 'jose' +import { isAddressEqual, recoverAddress } from 'viem' import { decode } from './decode' import { JwtError } from './error' import { eip191Hash } from './sign' -import { JWK, Jwt, Payload, SigningAlg } from './types' +import { Hex, JWK, Jwt, Payload, SigningAlg } from './types' import { base64UrlToHex, jwkToPublicKey } from './utils' const checkTokenExpiration = (payload: Payload): boolean => { @@ -14,23 +15,53 @@ const checkTokenExpiration = (payload: Payload): boolean => { return true } -export async function verifyJwt(jwt: string, jwk: JWK): Promise { - const { header, payload, signature } = decode(jwt) +const verifyEip191WithRecovery = async (sig: Hex, hash: Uint8Array, address: Hex): Promise => { + const recoveredAddress = await recoverAddress({ + hash, + signature: sig + }) + if (!isAddressEqual(recoveredAddress, address)) { + throw new Error('Invalid JWT signature') + } + return true +} + +const verifyEip191WithPublicKey = async (sig: Hex, hash: Uint8Array, jwk: JWK): Promise => { + const pub = jwkToPublicKey(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 +} +export const verifyEip191 = async (jwt: string, jwk: JWK): 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) + } else { + throw new Error('Invalid JWK, no x & y or address') + } + + return true +} + +export async function verifyJwt(jwt: string, jwk: JWK): Promise { + const { header, payload, signature } = decode(jwt) if (header.alg === SigningAlg.EIP191) { - const verificationMsg = [headerStr, payloadStr].join('.') - const msg = eip191Hash(verificationMsg) - const sig = base64UrlToHex(jwtSig) - const pub = jwkToPublicKey(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), msg, pub.slice(2)) === true - if (!isValid) { - throw new Error('Invalid JWT signature') - } + await verifyEip191(jwt, jwk) } else { // TODO: Implement other algs individually without jose const joseJwk = await importJWK(jwk)