Skip to content

Commit

Permalink
eddsa round trip
Browse files Browse the repository at this point in the history
  • Loading branch information
Ptroger committed Dec 3, 2024
1 parent 92ed588 commit 50803ef
Show file tree
Hide file tree
Showing 10 changed files with 351 additions and 33 deletions.
14 changes: 13 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
"@nestjs/platform-express": "10.3.9",
"@nestjs/swagger": "7.4.0",
"@noble/curves": "1.6.0",
"@noble/ed25519": "1.7.1",
"@noble/hashes": "1.4.0",
"@open-policy-agent/opa-wasm": "1.9.0",
"@opentelemetry/api": "1.9.0",
Expand Down
29 changes: 25 additions & 4 deletions packages/signature/src/lib/__test__/unit/sign.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { secp256k1 } from '@noble/curves/secp256k1'
import { sha256 as sha256Hash } from '@noble/hashes/sha256'
import { exportJWK, importPKCS8 } from 'jose'
import { createPublicKey } from 'node:crypto'
import { toHex, verifyMessage } from 'viem'
import { hexToBytes, toHex, verifyMessage } from 'viem'
import { privateKeyToAccount, signMessage } from 'viem/accounts'
import { buildSignerEip191, buildSignerEs256k, signJwt, signSecp256k1, signatureToHex } from '../../sign'
import { buildSignerEdDSA, buildSignerEip191, buildSignerEs256k, signJwt, signSecp256k1, signatureToHex } from '../../sign'
import { Alg, Curves, Jwk, KeyTypes, Payload, PrivateKey, SigningAlg } from '../../types'
import {
base64UrlToBytes,
Expand All @@ -15,13 +15,18 @@ import {
hexToBase64Url,
privateKeyToHex,
secp256k1PrivateKeyToJwk,
secp256k1PublicKeyToJwk
secp256k1PublicKeyToJwk,
ed25519polyfilled as ed,
privateKeyToJwk,
publicKeyToHex,
} from '../../utils'
import { verifyJwt } from '../../verify'
import { HEADER_PART, PAYLOAD_PART, PRIVATE_KEY_PEM } from './mock'
import { toBytes } from '@noble/hashes/utils'

describe('sign', () => {
const UNSAFE_PRIVATE_KEY = '7cfef3303797cbc7515d9ce22ffe849c701b0f2812f999b0847229c47951fca5'
const ED25519_PRIVATE_KEY = '0xe6ad32d225c16074bd4a3b62e28c99dd26136ef341e6368ca05227d1e13822d9'

const payload: Payload = {
requestHash: '608abe908cffeab1fc33edde6b44586f9dacbc9c6fe6f0a13fa307237290ce5a',
Expand Down Expand Up @@ -55,7 +60,6 @@ describe('sign', () => {
const verified = await verifyJwt(jwt, maybeJwk)
expect(verified.payload).toEqual(payload)
})

it('should build & sign a EIP191 JWT', async () => {
const jwk = secp256k1PrivateKeyToJwk(`0x${UNSAFE_PRIVATE_KEY}`)
const signer = buildSignerEip191(UNSAFE_PRIVATE_KEY)
Expand Down Expand Up @@ -110,6 +114,23 @@ describe('sign', () => {
expect(signature).toBe('afu_-8eYXRpHAt_nTVRksRmiwVZpq7iC2rBVhGQT5YcJlViKV9wD3OIlRYAxa7JkNd1Yqzf_x2ohLzqGjmlb2hs')
})

it('should sign ED25519 correctly', async () => {
const signer = buildSignerEdDSA(ED25519_PRIVATE_KEY)
const jwk = privateKeyToJwk(ED25519_PRIVATE_KEY, Alg.EDDSA)
const publicHexKey = await publicKeyToHex(jwk)

const message = [HEADER_PART, PAYLOAD_PART].join('.')

const messageBytes = toBytes(message)

const signature = await signer(message)

const signatureWithout0x = base64UrlToHex(signature)

const isVerified = await ed.verify(signatureWithout0x.slice(2), messageBytes, publicHexKey.slice(2))
expect(isVerified).toBe(true)
})

it('should sign EIP191 the same as viem', async () => {
// Just double-check that we're building a signature & base64url encoding the same thing we'd get from Viem.
const message = [HEADER_PART, PAYLOAD_PART].join('.')
Expand Down
64 changes: 62 additions & 2 deletions packages/signature/src/lib/__test__/unit/util.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { toHex } from 'viem'
import { hash } from '../../hash'
import { p256PrivateKeySchema, rsaPrivateKeySchema, secp256k1PrivateKeySchema } from '../../schemas'
import { p256PrivateKeySchema, rsaPrivateKeySchema, secp256k1PrivateKeySchema, ed25519PrivateKeySchema } from '../../schemas'
import { buildSignerEip191, signJwt } from '../../sign'
import {
Alg,
Expand All @@ -19,7 +20,8 @@ import {
publicKeyToHex,
publicKeyToJwk,
requestWithoutWildcardFields,
rsaPrivateKeyToPublicKey
rsaPrivateKeyToPublicKey,
ed25519polyfilled
} from '../../utils'
import { validateJwk } from '../../validate'
import { verifyJwt } from '../../verify'
Expand Down Expand Up @@ -58,6 +60,15 @@ const k1Jwk: Jwk = {
d: 'ENTv9xjMG6RkHwamtOk3l0mlHOZN7herKFEmGPPKK04'
}

const eddsaKey: Jwk = {
kty: 'OKP',
crv: 'Ed25519',
alg: 'EDDSA',
kid: '0xaf8dcff8da6aae18c2170f59a0734c8c5c19ca726a1b75993857bd836db00a5f',
x: 'HrmLI5NH3cYp4-HluFGBOcYvARGti_oz0aZMXMzy8m4',
d: 'nq2eDJPp9NAqCdTT_dNerIJFJxegTKmFgDAsFkhbJIA'
}

const k1HexPublicKey =
'0x048cf0cf42e721a1ab479fb27fb5a4674ae6bf5a00357144a8552bb66eff875be3b045bb4ce358c2788941d8bf403840d4e0e0089196b5f8015900972aeed54b84'

Expand All @@ -67,6 +78,11 @@ const p256HexPublicKey =
'0x046efe944311e4955214040e3f0056eaa3d7db8443fce9764506ed556f8fa9c35408dfa6d8b07a68cc1b8668be732289e3da91956922ba9645f07eca9aa9531e2c'

const p256HexPrivateKey = '0xd9cf695be325ab8d849fc60a5eb92bf45c8b0c81527d5e3926a951f0732c4157'

const eddsaHexPublicKey = '0x1eb98b239347ddc629e3e1e5b8518139c62f0111ad8bfa33d1a64c5cccf2f26e'

const eddsaHexPrivateKey = '0x9ead9e0c93e9f4d02a09d4d3fdd35eac82452717a04ca98580302c16485b2480'

describe('isHeader', () => {
it('returns true for a valid header object', () => {
const validHeader = { alg: 'ES256', kid: 'test-kid', typ: 'JWT' }
Expand Down Expand Up @@ -113,6 +129,11 @@ describe('generateKeys', () => {
expect(secp256k1PrivateKeySchema.safeParse(key).success).toBe(true)
})

it('generates a valid ed25519 key pair and return it as a JWK', async () => {
const key = await generateJwk(Alg.EDDSA)
expect(ed25519PrivateKeySchema.safeParse(key).success).toBe(true)
})

it('can sign and verify with a generated secp256k1 key pair', async () => {
const key = await generateJwk(Alg.ES256K)
const message = 'test message'
Expand All @@ -131,6 +152,18 @@ describe('generateKeys', () => {
expect(isValid).not.toEqual(false)
})

it('can sign and verify with a generated ed25519 key pair', async () => {
const key = await generateJwk(Alg.EDDSA)

const message = hash('test message')
const payload = {
requestHash: message
}

const signature = await signJwt(payload, key)
const isValid = await verifyJwt(signature, key)
expect(isValid).not.toEqual(false)
})
describe('rsaPrivateKeyToPublicKey', () => {
it('converts private to public', async () => {
const privateKey = await generateJwk<RsaPrivateKey>(Alg.RS256, { use: 'enc' })
Expand Down Expand Up @@ -170,6 +203,23 @@ describe('publicKeyToJwk', () => {
const jwk2 = publicKeyToJwk(p256HexPublicKey, Alg.ES256, 'myKey2')
expect(jwk2.kid).toBe('myKey2')
})

it('converts a valid EDDSA hex public key to JWK', async () => {
const jwk = publicKeyToJwk(eddsaHexPublicKey, Alg.EDDSA)
const { d, ...eddsaPublicKey } = eddsaKey
expect(jwk).toEqual(eddsaPublicKey)
})

it('works', async () => {
const key = ed25519polyfilled.utils.randomPrivateKey()
const publicKey = ed25519polyfilled.sync.getPublicKey(key)
const asyncPublicKey = await ed25519polyfilled.getPublicKey(key)

const publicHexKey = (await publicKeyToHex(privateKeyToJwk(toHex(key), Alg.EDDSA)))

expect(publicHexKey).toEqual(toHex(publicKey))
expect(publicHexKey).toEqual(toHex(asyncPublicKey))
})
})

describe('publicKeyToHex', () => {
Expand All @@ -189,6 +239,11 @@ describe('publicKeyToHex', () => {
'0x30820122300d06092a864886f70d01010105000382010f003082010a0282010100c4d7538d62fd8466b86f3e2d2ca6e6159e328d0b10cd6df9f82312d249e8df8d378c4aa0e72d82b3ab0b5723c15f837685d2b91113f2b697f53eb4e11ea07e7843ba2ed116a44341805b8d21ff03447aefd2d9deedd2cdc5feecc756a870646b920224e16b3a22ce0eb77c219b7f968a19831a71e8a2013e10fc1592449cd8560f5d894dab886d0995c2f3f3142372ee5ebcb61a7e2d9dae47b1849e85885f2830fe9df49a8de5b174d52fff7c0e7920abf480a6f54765a3b8692141d08275f7373ea4bdf198bb078b1a88ffa2dfa81140887656d0aa6adf4ad01c96e81b339348ead63fffac03d33ff86418d3a2ea570819576d8b1d7bafd91bdc4f9984b2930203010001'
)
})

it('converts a valid eddsa JWK to hex string', async () => {
const hex = await publicKeyToHex(eddsaKey)
expect(hex).toBe(eddsaHexPublicKey)
})
})

describe('privateKeyToHex', () => {
Expand All @@ -202,6 +257,11 @@ describe('privateKeyToHex', () => {
expect(hex).toBe(p256HexPrivateKey)
})

it('converts a valid eddsa private JWK to hex string', async () => {
const hex = await privateKeyToHex(eddsaKey)
expect(hex).toBe(eddsaHexPrivateKey)
})

it('throws an error for invalid private key', async () => {
const jwk: Jwk = {
kty: 'EC',
Expand Down
26 changes: 24 additions & 2 deletions packages/signature/src/lib/__test__/unit/verify.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { signatureToHex, toBytes } from 'viem'
import { signatureToHex, toBytes, toHex } from 'viem'
import { JwtError } from '../../error'
import { hash } from '../../hash'
import { secp256k1PublicKeySchema } from '../../schemas'
import { signJwt, signSecp256k1 } from '../../sign'
import { Alg, Header, JwtVerifyOptions, Payload, Secp256k1PublicKey, SigningAlg } from '../../types'
import { generateJwk, nowSeconds, privateKeyToJwk, secp256k1PrivateKeyToJwk } from '../../utils'
import { generateJwk, nowSeconds, privateKeyToHex, privateKeyToJwk, secp256k1PrivateKeyToJwk, base64UrlToBytes, publicKeyToHex, publicKeyToJwk } from '../../utils';
import { validateJwk } from '../../validate'
import {
checkAccess,
Expand All @@ -17,11 +17,13 @@ import {
checkRequiredClaims,
checkSubject,
checkTokenExpiration,
verifyEd25519,
verifyJwsdHeader,
verifyJwt,
verifyJwtHeader,
verifySecp256k1
} from '../../verify'
import * as ed from '@noble/ed25519'

const ENGINE_PRIVATE_KEY = '7cfef3303797cbc7515d9ce22ffe849c701b0f2812f999b0847229c47951fca5'

Expand Down Expand Up @@ -203,6 +205,26 @@ describe('verifySecp256k1', () => {
})
})

describe('verifyEd215519', () => {
it('verifies raw ed25519 signatures', async () => {
const msg = toBytes('My ASCII message')
const key = ed.utils.randomPrivateKey()
const pubKey = await ed.getPublicKey(key)

const jwk = publicKeyToJwk(toHex(pubKey), Alg.EDDSA)
console.log({
jwk
})
const signature = await ed.sign(msg, key)

const isVerified = await ed.verify(signature, msg, pubKey)
const isVerifiedByUs = await verifyEd25519(signature, msg, jwk)

expect(isVerified).toEqual(true)
expect(isVerifiedByUs).toEqual(isVerified)
})
})

describe('verifyJwtHeader', () => {
it('returns true when all recognized crit parameters are present in the header', () => {
const header = {
Expand Down
25 changes: 22 additions & 3 deletions packages/signature/src/lib/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@ export const jwkEoaSchema = z.object({
addr: addressSchema
})


// EdDSA Base and PublicKey Schema
export const ed25519PublicKeySchema = jwkBaseSchema.extend({
kty: z.literal(KeyTypes.OKP),
crv: z.literal(Curves.ED25519),
alg: z.literal(Alg.EDDSA),
x: z.string() // Ed25519 public key, no y coordinate
})

// EdDSA Private Key Schema
export const ed25519PrivateKeySchema = ed25519PublicKeySchema.extend({
d: z.string(), // Ed25519 private key
x: z.string().optional()
})

// EC Base Schema
export const ecBaseSchema = jwkBaseSchema.extend({
kty: z.literal(KeyTypes.EC),
Expand Down Expand Up @@ -75,10 +90,14 @@ export const publicKeySchema = z.union([
secp256k1PublicKeySchema,
p256PublicKeySchema,
rsaPublicKeySchema,
jwkEoaSchema
jwkEoaSchema,
ed25519PublicKeySchema
])

export const privateKeySchema = z.union([secp256k1PrivateKeySchema, p256PrivateKeySchema, rsaPrivateKeySchema])

export const privateKeySchema = z.union([secp256k1PrivateKeySchema, p256PrivateKeySchema, rsaPrivateKeySchema, ed25519PrivateKeySchema])

export const ed25519KeySchema = z.union([ed25519PublicKeySchema, ed25519PrivateKeySchema])

export const secp256k1KeySchema = z.union([secp256k1PublicKeySchema, secp256k1PrivateKeySchema])

Expand Down Expand Up @@ -112,7 +131,7 @@ export const jwkSchema = dynamicKeySchema.extend({
export const Header = z.intersection(
z.record(z.string(), z.unknown()),
z.object({
alg: z.union([z.literal('ES256K'), z.literal('ES256'), z.literal('RS256'), z.literal('EIP191')]),
alg: z.union([z.literal('ES256K'), z.literal('ES256'), z.literal('RS256'), z.literal('EIP191'), z.literal('EDDSA')]),
kid: z.string().min(1).describe('The key ID to identify the signing key.'),
typ: z
.union([z.literal('JWT'), z.literal('gnap-binding-jwsd')])
Expand Down
25 changes: 22 additions & 3 deletions packages/signature/src/lib/sign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,17 @@ import { JwtError } from './error'
import { hash } from './hash'
import { canonicalize } from './json.util'
import { jwkBaseSchema, privateKeySchema } from './schemas'
import { Alg, EcdsaSignature, Header, Hex, Jwk, JwsdHeader, PartialJwk, Payload, PrivateKey, SigningAlg } from './types'
import { Alg, EcdsaSignature, Header, Hex, Jwk, JwsdHeader, PartialJwk, Payload, PrivateKey, PublicKey, publicKeySchema, SigningAlg } from './types'
import { hexToBase64Url, privateKeyToHex, stringToBase64Url } from './utils'
import { validateJwk } from './validate'
import { ed25519polyfilled as ed25519 } from './utils'

const SigningAlgToKey = {
[SigningAlg.EIP191]: Alg.ES256K,
[SigningAlg.ES256K]: Alg.ES256K,
[SigningAlg.ES256]: Alg.ES256,
[SigningAlg.RS256]: Alg.RS256
[SigningAlg.RS256]: Alg.RS256,
[SigningAlg.ED25519]: Alg.EDDSA
}

const buildHeader = (jwk: Jwk, alg?: SigningAlg): Header => {
Expand Down Expand Up @@ -114,7 +116,6 @@ export async function signJwt(
const encodedHeader = stringToBase64Url(canonicalize(header))
const encodedPayload = stringToBase64Url(canonicalize(payload))
const messageToSign = `${encodedHeader}.${encodedPayload}`

// Determine the signing logic based on the presence of a custom signer
let signature: string
if (actualSigner) {
Expand All @@ -141,6 +142,9 @@ export async function signJwt(
case SigningAlg.RS256:
signature = await buildSignerRs256(jwk)(messageToSign)
break
case SigningAlg.ED25519:
signature = await buildSignerEdDSA(privateKeyHex)(messageToSign)
break
default: {
throw new JwtError({
message: 'Unsupported signing algorithm',
Expand Down Expand Up @@ -219,6 +223,21 @@ export const buildSignerEip191 =
return hexToBase64Url(hexSignature)
}

export const signEd25519 = async (message: Uint8Array, privateKey: Hex | string): Promise<Uint8Array> => {
const pk = isHex(privateKey) ? privateKey.slice(2) : privateKey
const signature = await ed25519.sign(message, pk)

return signature
}

export const buildSignerEdDSA =
(privateKey: Hex | string) =>
async (messageToSign: string): Promise<string> => {

const signature = await signEd25519(toBytes(messageToSign), privateKey)
return hexToBase64Url(toHex(signature))
}

export const buildSignerEs256 =
(privateKey: Hex | string) =>
async (messageToSign: string): Promise<string> => {
Expand Down
Loading

0 comments on commit 50803ef

Please sign in to comment.