Skip to content

Commit

Permalink
refactor!: future proof Ed25519
Browse files Browse the repository at this point in the history
BREAKING CHANGE: specifying Ed448 curve for EdDSA is no longer
supported, EdDSA is now just an alias for the fully-specified Ed25519
JWS algorithm

BREAKING CHANGE: assertions signed with an Ed25519 CryptoKey will now
use the Ed25519 JWS alg value instead of EdDSA. This can be reverted
using the modifyAssertion symbol export
  • Loading branch information
panva committed Oct 7, 2024
1 parent 6ec5a2d commit ac0550d
Show file tree
Hide file tree
Showing 7 changed files with 140 additions and 172 deletions.
102 changes: 52 additions & 50 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,18 +104,18 @@ function CodedTypeError(message: string, code: codes, cause?: unknown) {
* for which Digital Signature validation is implemented.
*/
export type JWSAlgorithm =
// widely used
| 'PS256'
| 'ES256'
| 'RS256'
| 'EdDSA'
// less used
| 'Ed25519'
| 'ES384'
| 'PS384'
| 'RS384'
| 'ES512'
| 'PS512'
| 'RS512'
// Deprecated
| 'EdDSA'

export interface JWK {
readonly kty?: string
Expand Down Expand Up @@ -312,6 +312,35 @@ export const customFetch: unique symbol = Symbol()
* },
* })
* ```
*
* @example
*
* Changing the `alg: "Ed25519"` back to `alg: "EdDSA"`
*
* ```ts
* let as!: oauth.AuthorizationServer
* let client!: oauth.Client
* let parameters!: URLSearchParams
* let key!: oauth.CryptoKey | oauth.PrivateKey
* let keyPair!: oauth.CryptoKeyPair
*
* let remapEd25519: oauth.ModifyAssertionOptions = {
* [oauth.modifyAssertion]: (header) => {
* if (header.alg === 'Ed25519') {
* header.alg = 'EdDSA'
* }
* },
* }
*
* // For JAR
* oauth.issueRequestObject(as, client, parameters, key, remapEd25519)
*
* // For Private Key JWT
* oauth.PrivateKeyJwt(key, remapEd25519)
*
* // For DPoP
* oauth.DPoP(client, keyPair, remapEd25519)
* ```
*/
export const modifyAssertion: unique symbol = Symbol()

Expand Down Expand Up @@ -1490,8 +1519,8 @@ function keyToJws(key: CryptoKey) {
case 'ECDSA':
return esAlg(key)
case 'Ed25519': // Fall through
case 'Ed448':
return 'EdDSA'
case 'EdDSA':
return 'Ed25519'
default:
throw new UnsupportedOperationError('unsupported CryptoKey algorithm name', { cause: key })
}
Expand Down Expand Up @@ -2873,7 +2902,8 @@ async function getPublicSigKeyFromIssuerJwksUri(
case alg === 'ES256' && jwk.crv !== 'P-256': // Fall through
case alg === 'ES384' && jwk.crv !== 'P-384': // Fall through
case alg === 'ES512' && jwk.crv !== 'P-521': // Fall through
case alg === 'EdDSA' && !(jwk.crv === 'Ed25519' || jwk.crv === 'Ed448'):
case alg === 'Ed25519' && jwk.crv !== 'Ed25519': // Fall through
case alg === 'EdDSA' && jwk.crv !== 'Ed25519': // Fall through
return false
}

Expand Down Expand Up @@ -4516,6 +4546,7 @@ function supported(alg: string) {
case 'PS512':
case 'ES512':
case 'RS512':
case 'Ed25519':
case 'EdDSA':
return true
default:
Expand Down Expand Up @@ -4579,8 +4610,8 @@ function keyToSubtle(key: CryptoKey): AlgorithmIdentifier | RsaPssParams | Ecdsa
case 'RSASSA-PKCS1-v1_5':
checkRsaKeyAlgorithm(key)
return key.algorithm.name
case 'Ed448': // Fall through
case 'Ed25519':
case 'Ed25519': // Fall through
case 'EdDSA':
return key.algorithm.name
}
throw new UnsupportedOperationError('unsupported CryptoKey algorithm name', { cause: key })
Expand Down Expand Up @@ -4790,12 +4821,7 @@ export async function validateJwtAuthResponse(
return validateAuthResponse(as, client, result, expectedState)
}

async function idTokenHash(
data: string,
key: CryptoKey,
header: CompactJWSHeaderParameters,
claimName: string,
) {
async function idTokenHash(data: string, header: CompactJWSHeaderParameters, claimName: string) {
let algorithm: string
switch (header.alg) {
case 'RS256': // Fall through
Expand All @@ -4810,17 +4836,11 @@ async function idTokenHash(
break
case 'RS512': // Fall through
case 'PS512': // Fall through
case 'ES512':
case 'ES512': // Fall through
case 'Ed25519': // Fall through
case 'EdDSA':
algorithm = 'SHA-512'
break
case 'EdDSA':
if (key.algorithm.name === 'Ed25519') {
algorithm = 'SHA-512'
break
}
throw new UnsupportedOperationError(`unsupported EdDSA curve for ${claimName} calculation`, {
cause: key,
})
default:
throw new UnsupportedOperationError(
`unsupported JWS algorithm for ${claimName} calculation`,
Expand All @@ -4835,11 +4855,10 @@ async function idTokenHash(
async function idTokenHashMatches(
data: string,
actual: string,
key: CryptoKey,
header: CompactJWSHeaderParameters,
claimName: string,
) {
const expected = await idTokenHash(data, key, header, claimName)
const expected = await idTokenHash(data, header, claimName)
return actual === expected
}

Expand Down Expand Up @@ -5103,7 +5122,7 @@ async function validateHybridResponse(
const key = await getPublicSigKeyFromIssuerJwksUri(as, options, header)
await validateJwsSignature(protectedHeader, payload, key, signature)

if ((await idTokenHashMatches(code, claims.c_hash, key!, header, 'c_hash')) !== true) {
if ((await idTokenHashMatches(code, claims.c_hash, header, 'c_hash')) !== true) {
throw OPE('invalid ID Token "c_hash" (code hash) claim value', JWT_CLAIM_COMPARISON, {
code,
alg: header.alg,
Expand All @@ -5118,7 +5137,7 @@ async function validateHybridResponse(
})
assertString(state, '"state" response parameter', INVALID_RESPONSE, { parameters })

if ((await idTokenHashMatches(state, claims.s_hash, key!, header, 's_hash')) !== true) {
if ((await idTokenHashMatches(state, claims.s_hash, header, 's_hash')) !== true) {
throw OPE('invalid ID Token "s_hash" (state hash) claim value', JWT_CLAIM_COMPARISON, {
state,
alg: header.alg,
Expand Down Expand Up @@ -5325,10 +5344,7 @@ export function validateAuthResponse(
return brand(new URLSearchParams(parameters))
}

function algToSubtle(
alg: string,
crv?: string,
): RsaHashedImportParams | EcKeyImportParams | AlgorithmIdentifier {
function algToSubtle(alg: string): RsaHashedImportParams | EcKeyImportParams | AlgorithmIdentifier {
switch (alg) {
case 'PS256': // Fall through
case 'PS384': // Fall through
Expand All @@ -5343,23 +5359,17 @@ function algToSubtle(
return { name: 'ECDSA', namedCurve: `P-${alg.slice(-3)}` }
case 'ES512':
return { name: 'ECDSA', namedCurve: 'P-521' }
case 'EdDSA': {
switch (crv) {
case 'Ed25519': // Fall through
case 'Ed448':
return crv
default:
throw new UnsupportedOperationError('unsupported EdDSA curve', { cause: { alg, crv } })
}
}
case 'Ed25519':
case 'EdDSA':
return 'Ed25519'
default:
throw new UnsupportedOperationError('unsupported JWS algorithm', { cause: { alg } })
}
}

async function importJwk(alg: string, jwk: JWK) {
const { ext, key_ops, use, ...key } = jwk
return crypto.subtle.importKey('jwk', key, algToSubtle(alg, jwk.crv), true, ['verify'])
return crypto.subtle.importKey('jwk', key, algToSubtle(alg), true, ['verify'])
}

export interface DeviceAuthorizationRequestOptions
Expand Down Expand Up @@ -5608,11 +5618,6 @@ export interface GenerateKeyPairOptions {
* (RSA algorithms only) The length, in bits, of the RSA modulus. Default is `2048`.
*/
modulusLength?: number

/**
* (EdDSA algorithm only) The EdDSA sub-type. Default is `Ed25519`.
*/
crv?: 'Ed25519' | 'Ed448'
}

/**
Expand All @@ -5629,10 +5634,7 @@ export async function generateKeyPair(
): Promise<CryptoKeyPair> {
assertString(alg, '"alg"')

const algorithm: RsaHashedKeyGenParams | EcKeyGenParams | AlgorithmIdentifier = algToSubtle(
alg,
alg === 'EdDSA' ? (options?.crv ?? 'Ed25519') : undefined,
)
const algorithm: RsaHashedKeyGenParams | EcKeyGenParams | AlgorithmIdentifier = algToSubtle(alg)

if (alg.startsWith('PS') || alg.startsWith('RS')) {
Object.assign(algorithm, {
Expand Down
16 changes: 0 additions & 16 deletions tap/generate.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type QUnit from 'qunit'
import * as lib from '../src/index.js'
import { fails, keys } from './keys.js'
import * as env from './env.js'

function isRSA(alg: string) {
return alg.startsWith('RS') || alg.startsWith('PS')
Expand Down Expand Up @@ -33,21 +32,6 @@ export default (QUnit: QUnit) => {
})
}

if (env.isNode || env.isEdgeRuntime) {
test('EdDSA w/ Ed448', async (t) => {
const { publicKey, privateKey } = await lib.generateKeyPair('EdDSA', { crv: 'Ed448' })
testGeneratedCryptoKeyPair(t, privateKey, publicKey)

for (const key of [privateKey, publicKey]) {
t.equal(key.algorithm.name, 'Ed448')
}
})
} else {
test(`[not supported] EdDSA w/ Ed448 fails to generate`, async (t) => {
await t.rejects(lib.generateKeyPair('EdDSA', { crv: 'Ed448' }))
})
}

for (const [alg, kp] of Object.entries(keys)) {
test(`${alg} defaults`, async (t) => {
const { publicKey, privateKey } = await kp
Expand Down
2 changes: 1 addition & 1 deletion tap/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const fails: string[] = []
if (!env.isDeno) {
algs.push('ES512')
}
;(env.isBlink ? fails : algs).push('EdDSA')
;(env.isBlink ? fails : algs).push('EdDSA', 'Ed25519')

export const keys = algs.reduce(
(acc, alg) => {
Expand Down
73 changes: 43 additions & 30 deletions tap/request_object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,36 +10,49 @@ export default (QUnit: QUnit) => {
const { module, test } = QUnit
module('request_object.ts')

for (const [alg, kp] of Object.entries(keys)) {
test(`issueRequestObject() w/ ${alg}`, async (t) => {
const { privateKey, publicKey } = await kp
for (const parameters of [
new URLSearchParams({ response_type: 'code', resource: 'urn:example:resource' }),
{ response_type: 'code', resource: 'urn:example:resource' },
[
['response_type', 'code'],
['resource', 'urn:example:resource'],
],
]) {
const jwt = await lib.issueRequestObject(issuer, client, parameters, { key: privateKey })

const { payload, protectedHeader } = await jose.jwtVerify(jwt, publicKey)
t.propEqual(protectedHeader, { alg, typ: 'oauth-authz-req+jwt' })
const { exp, iat, nbf, jti, ...claims } = payload
t.equal(typeof exp, 'number')
t.equal(typeof nbf, 'number')
t.equal(typeof iat, 'number')
t.equal(typeof jti, 'string')
t.propEqual(claims, {
iss: client.client_id,
aud: issuer.issuer,
response_type: 'code',
resource: 'urn:example:resource',
client_id: client.client_id,
})
}
})
}
test(`issueRequestObject()`, async (t) => {
const { privateKey, publicKey } = await lib.generateKeyPair('ES256')
for (const parameters of [
new URLSearchParams({ response_type: 'code', resource: 'urn:example:resource' }),
{ response_type: 'code', resource: 'urn:example:resource' },
[
['response_type', 'code'],
['resource', 'urn:example:resource'],
],
]) {
const jwt = await lib.issueRequestObject(
issuer,
client,
parameters,
{ key: privateKey },
{
[lib.modifyAssertion]: (header) => {
if (header.alg === 'Ed25519') {
header.alg = 'EdDSA'
}
},
},
)

const { payload, protectedHeader } = await jose.jwtVerify(jwt, publicKey)
t.propEqual(protectedHeader, {
alg: 'ES256',
typ: 'oauth-authz-req+jwt',
})
const { exp, iat, nbf, jti, ...claims } = payload
t.equal(typeof exp, 'number')
t.equal(typeof nbf, 'number')
t.equal(typeof iat, 'number')
t.equal(typeof jti, 'string')
t.propEqual(claims, {
iss: client.client_id,
aud: issuer.issuer,
response_type: 'code',
resource: 'urn:example:resource',
client_id: client.client_id,
})
}
})

test('issueRequestObject() multiple resource parameters', async (t) => {
const kp = await keys.ES256
Expand Down
Loading

0 comments on commit ac0550d

Please sign in to comment.