Skip to content

Commit

Permalink
Adding eip191 sig recovery for jwt verification; updating devtool to …
Browse files Browse the repository at this point in the history
…generate jwt sigs
  • Loading branch information
mattschoch committed Mar 11, 2024
1 parent af961c4 commit ede46d2
Show file tree
Hide file tree
Showing 6 changed files with 154 additions and 22 deletions.
39 changes: 36 additions & 3 deletions apps/devtool/src/app/components/EditorComponent.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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: {
Expand Down
11 changes: 9 additions & 2 deletions apps/policy-engine/src/app/core/service/signing.service.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
48 changes: 48 additions & 0 deletions packages/signature/src/lib/__test__/unit/verify.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { hash } from '../../hash-request'
import { Payload } from '../../types'
import { privateKeyToJwk } from '../../utils'
import { verifyJwt } from '../../verify'
Expand Down Expand Up @@ -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)
})
})
6 changes: 4 additions & 2 deletions packages/signature/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 = {
Expand Down
13 changes: 12 additions & 1 deletion packages/signature/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -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 = (
Expand Down Expand Up @@ -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')
Expand Down
59 changes: 45 additions & 14 deletions packages/signature/src/lib/verify.ts
Original file line number Diff line number Diff line change
@@ -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 => {
Expand All @@ -14,23 +15,53 @@ const checkTokenExpiration = (payload: Payload): boolean => {
return true
}

export async function verifyJwt(jwt: string, jwk: JWK): Promise<Jwt> {
const { header, payload, signature } = decode(jwt)
const verifyEip191WithRecovery = async (sig: Hex, hash: Uint8Array, address: Hex): Promise<boolean> => {
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<boolean> => {
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<boolean> => {
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<Jwt> {
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)
Expand Down

0 comments on commit ede46d2

Please sign in to comment.