From 072013f07c64c7f451b4b525a82910a1b41a5a2d Mon Sep 17 00:00:00 2001 From: dickhardt Date: Mon, 3 Aug 2026 18:06:53 +0100 Subject: [PATCH 1/6] feat(httpsig)!: track draft-hardt-httpbis-signature-key-07 BREAKING CHANGE: 2.0 is not wire-compatible with 1.x in either direction, and the protocol has no version negotiation. Algorithm determination is inverted. The algorithm is taken from the JWK alg member and never derived from kty and crv, which underdetermine it: an RSA key has no crv and leaves both padding and hash free, and an EC curve does not fix the hash. alg must be fully specified per RFC 9864, so the polymorphic EdDSA identifier is rejected in favour of Ed25519 and Ed448. A key whose kty or crv disagrees with its alg is rejected rather than resolved in favour of either reading. RSA is newly supported (PS256/384/512, RS256/384/512). Symmetric material -- oct, HS*, hmac-sha256 -- is rejected outright, since every scheme here distributes a public key. ML-DSA and the AKP key type are recognized and declined as unsupported_algorithm rather than failing as malformed; absence of support is a reason to decline, not a parse error. The hwk scheme emits and requires alg, and rejects kid: the key is inline, so an identifier selects nothing. sigkey is replaced by the Accept-Signature-Scheme and Accept-Signature-Alg header fields, which are Lists of Tokens and so can name more than one value. supported_algorithms is removed from Signature-Error in favour of Accept-Signature-Alg, which works on a challenge as well as an error. The unsupported_scheme code is added. The jwt scheme now validates exp, which it previously ignored entirely. exp is what bounds how long the confirmation key an assertion carries stays acceptable; without it that key is acceptable indefinitely. The strictAAuth option is removed. Covering signature-key is a requirement rather than a profile choice, because an uncovered Signature-Key header can be substituted without invalidating the signature, so it can no longer be disabled. Errors are typed. SignatureVerificationError carries the Signature-Error code directly instead of leaving it to be recovered by matching on message text. The RFC 9421 and Go interop vectors predate this specification and do not cover signature-key, so they can no longer verify; their assertions now check that they are rejected with invalid_input. Restoring cryptographic agreement with the RFC vectors, by testing the signature base directly, is follow-up work. Assertion caching is deliberately not implemented: the draft carries an Editor's Note calling it a straw man. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LXExbWHeem2SNb7tZ2LGnc --- httpsig/MIGRATING-2.0.md | 146 +++++++++ httpsig/README.md | 12 + httpsig/package.json | 2 +- httpsig/src/errors.ts | 67 +++++ httpsig/src/index.ts | 17 +- httpsig/src/types.ts | 46 ++- httpsig/src/utils/crypto.ts | 278 +++++++++++++++--- httpsig/src/utils/signature.ts | 153 +++++++--- httpsig/src/verify.ts | 99 +++++-- httpsig/tests/test-accept-signature.ts | 164 ++++++----- httpsig/tests/test-edge-cases.ts | 22 +- httpsig/tests/test-go-interop.ts | 21 +- httpsig/tests/test-hwk.ts | 4 + httpsig/tests/test-jkt-jwt.ts | 8 + httpsig/tests/test-jwks.ts | 4 + httpsig/tests/test-jwt.ts | 4 + httpsig/tests/test-return-sent.ts | 3 + httpsig/tests/test-rfc9421-vectors.ts | 58 ++-- httpsig/tests/test-signature-error.ts | 44 ++- .../tests/test-signature-key-validation.ts | 89 ++---- httpsig/tests/test-validate-jwk-errors.ts | 153 +++++++++- package-lock.json | 11 +- 22 files changed, 1056 insertions(+), 349 deletions(-) create mode 100644 httpsig/MIGRATING-2.0.md create mode 100644 httpsig/src/errors.ts diff --git a/httpsig/MIGRATING-2.0.md b/httpsig/MIGRATING-2.0.md new file mode 100644 index 0000000..db3e7f3 --- /dev/null +++ b/httpsig/MIGRATING-2.0.md @@ -0,0 +1,146 @@ +# Migrating to 2.0 + +2.0 tracks `draft-hardt-httpbis-signature-key-07`, which is not backward +compatible with the `-05`-era protocol that 1.x implements. A 1.x client and a +2.x verifier will not interoperate, in either direction. There is no version +negotiation in the protocol, so both ends have to move together. + +1.x continues on the `1.x` branch and keeps the npm `latest` tag until 2.0 is +released. + +## Every JWK must carry `alg` + +This is the change that touches most callers. + +The algorithm is now taken from the JWK's `alg` member and is never derived +from `kty` and `crv`. Those underdetermine it: an RSA key has no `crv` at all +and leaves both padding and hash free, and an EC key's curve does not fix the +hash. + +`alg` must be a _fully-specified_ identifier (RFC 9864). The polymorphic +`EdDSA` identifier is rejected — use `Ed25519` or `Ed448`. + +```js +// 1.x +const key = await crypto.subtle.exportKey('jwk', keyPair.privateKey) + +// 2.x — WebCrypto does not set alg, so set it yourself +const key = await crypto.subtle.exportKey('jwk', keyPair.privateKey) +key.alg = 'Ed25519' +``` + +`generateKeyPair()` from this package stamps `alg` for you, so prefer it: + +```js +import { generateKeyPair } from '@hellocoop/httpsig' +const { privateKey, publicKey } = await generateKeyPair({ + algorithm: 'Ed25519', +}) +``` + +A JWK whose `kty` or `crv` disagrees with its `alg` is rejected rather than +resolved in favour of either reading. + +Supported: `Ed25519`, `Ed448`, `ES256`, `ES384`, `ES512`, `PS256`, `PS384`, +`PS512`, `RS256`, `RS384`, `RS512`. RSA is newly supported in 2.0. Symmetric +algorithms (`oct`, `HS*`, `hmac-sha256`) are rejected: every scheme here +distributes a public key, and a shared secret handed to the verifier proves +nothing. + +`ML-DSA-*` and the `AKP` key type are recognized and declined with +`unsupported_algorithm` rather than failing as malformed. WebCrypto cannot +implement them. + +## `hwk` carries `alg` and must not carry `kid` + +The `hwk` scheme now emits and requires an `alg` parameter. In `-06` it was +forbidden, so a header serialized by 1.x is rejected by 2.x and vice versa. + +``` +# 1.x +Signature-Key: sig=hwk;kty="OKP";crv="Ed25519";x="..." + +# 2.x +Signature-Key: sig=hwk;alg="Ed25519";kty="OKP";crv="Ed25519";x="..." +``` + +A `kid` parameter on `hwk` is now rejected: the key is inline, so an identifier +selects nothing, and one that disagrees with the inline key has no defined +resolution. + +## `sigkey` is replaced by two header fields + +A Structured Fields parameter value is a bare Item and cannot be a list, so +`sigkey` could name only one scheme. It is replaced by `Accept-Signature-Scheme` +and `Accept-Signature-Alg`, which are Lists of Tokens and let a client choose +before it signs rather than after a rejection. + +```js +// 1.x +generateAcceptSignatureHeader({ label: 'sig', components, sigkey: 'jkt' }) + +// 2.x +generateAcceptSignatureHeader({ label: 'sig', components }) +generateAcceptSignatureSchemeHeader(['hwk', 'jkt-jwt']) +generateAcceptSignatureAlgHeader(['Ed25519', 'ES256']) +``` + +The `SigKeyValue` type is removed. + +## `supported_algorithms` is removed from `Signature-Error` + +Use `Accept-Signature-Alg`, which works on a challenge and on an error alike, +rather than only after a rejection. + +The `unsupported_scheme` error code is added, and an unrecognized +`Signature-Key` scheme now reports it instead of `invalid_key`. + +## `signature-key` coverage is always enforced + +The `strictAAuth` option is removed from `VerifyOptions`. Covering +`signature-key` is a requirement of the specification, not a profile choice: an +uncovered `Signature-Key` header can be substituted by an attacker without +invalidating the signature. There is no way to disable the check. + +Requests whose covered components omit `signature-key` are rejected with +`invalid_input`, and `required_input` names what was missing. + +## The `jwt` scheme validates `exp` + +1.x extracted `cnf.jwk` and validated nothing, leaving expiry to the caller. +2.x requires `exp` and rejects an expired assertion with `expired_jwt`, because +`exp` is what bounds how long the confirmation key the assertion carries +remains acceptable. An assertion without `exp` leaves that key acceptable +indefinitely. + +`iat`, if present, must not be in the future. + +Validating the issuer's signature over the assertion remains the caller's job. + +## Errors are typed + +Verification failures now throw `SignatureVerificationError`, which carries the +`Signature-Error` code directly instead of leaving it to be recovered by +matching on message text. + +```js +import { SignatureVerificationError } from '@hellocoop/httpsig' + +try { + await fetch(url, opts) +} catch (error) { + if (error instanceof SignatureVerificationError) { + console.log(error.code) // e.g. 'invalid_key' + } +} +``` + +`verify()` still returns a result object rather than throwing; its +`signatureError` member is now derived from the code rather than the message. + +## Not yet implemented + +The `jwks`, `self-jwt`, and `x509` schemes are defined by the draft but not +implemented here. Assertion caching — the `cached` scheme, `Signature-Key-Cache` +and `cache_miss` — is deliberately not implemented: the draft carries an +Editor's Note calling it a straw man, and the design is not settled. diff --git a/httpsig/README.md b/httpsig/README.md index 35716f4..87feeab 100644 --- a/httpsig/README.md +++ b/httpsig/README.md @@ -6,6 +6,18 @@ HTTP Message Signatures (RFC 9421) implementation with Signature-Key header supp This package implements [RFC 9421 HTTP Message Signatures](https://datatracker.ietf.org/doc/html/rfc9421) with support for the [Signature-Key header proposal](https://github.com/DickHardt/signature-key), enabling cryptographic signing and verification of HTTP requests. +### Draft version + +| Package line | Implements | npm dist-tag | +| ------------ | -------------------------------------- | ------------ | +| `2.x` | `draft-hardt-httpbis-signature-key-07` | `alpha` | +| `1.x` | `draft-hardt-httpbis-signature-key-05` | `latest` | + +The draft is not yet adopted and `-07` is not backward compatible with `-05`. +The `2.x` line tracks it and is published as a prerelease; `npm install +@hellocoop/httpsig` continues to give you `1.x` until `2.0.0` is released. See +[MIGRATING-2.0.md](./MIGRATING-2.0.md) for what changed. + **Key Features:** - Zero dependencies diff --git a/httpsig/package.json b/httpsig/package.json index 78f587a..584dd45 100644 --- a/httpsig/package.json +++ b/httpsig/package.json @@ -1,6 +1,6 @@ { "name": "@hellocoop/httpsig", - "version": "1.7.1", + "version": "2.0.0-alpha.0", "description": "HTTP Message Signatures (RFC 9421) with Signature-Key header support", "repository": { "type": "git", diff --git a/httpsig/src/errors.ts b/httpsig/src/errors.ts new file mode 100644 index 0000000..945a521 --- /dev/null +++ b/httpsig/src/errors.ts @@ -0,0 +1,67 @@ +/** + * Typed verification errors + * + * Verification failures carry the Signature-Error code the server should + * report, rather than leaving it to be recovered by matching on message text. + */ + +import { SignatureErrorCode } from './types.js' + +export interface SignatureVerificationErrorOptions { + /** Covered components the server requires, for invalid_input. */ + requiredInput?: string[] + cause?: unknown +} + +export class SignatureVerificationError extends Error { + readonly code: SignatureErrorCode + readonly requiredInput?: string[] + + constructor( + code: SignatureErrorCode, + message: string, + options: SignatureVerificationErrorOptions = {}, + ) { + super(message, { cause: options.cause }) + this.name = 'SignatureVerificationError' + this.code = code + this.requiredInput = options.requiredInput + } +} + +/** The key material is malformed, or forbidden by the specification. */ +export function invalidKey(message: string): SignatureVerificationError { + return new SignatureVerificationError('invalid_key', message) +} + +/** The algorithm is well formed but this implementation cannot use it. */ +export function unsupportedAlgorithm( + message: string, +): SignatureVerificationError { + return new SignatureVerificationError('unsupported_algorithm', message) +} + +/** The Signature-Key scheme is not one this implementation understands. */ +export function unsupportedScheme(message: string): SignatureVerificationError { + return new SignatureVerificationError('unsupported_scheme', message) +} + +/** The assertion is malformed. */ +export function invalidJwt(message: string): SignatureVerificationError { + return new SignatureVerificationError('invalid_jwt', message) +} + +/** The assertion is well formed but no longer valid. */ +export function expiredJwt(message: string): SignatureVerificationError { + return new SignatureVerificationError('expired_jwt', message) +} + +/** The covered components are missing something the verifier requires. */ +export function invalidInput( + message: string, + requiredInput?: string[], +): SignatureVerificationError { + return new SignatureVerificationError('invalid_input', message, { + requiredInput, + }) +} diff --git a/httpsig/src/index.ts b/httpsig/src/index.ts index 7377779..1f8d128 100644 --- a/httpsig/src/index.ts +++ b/httpsig/src/index.ts @@ -18,10 +18,20 @@ export { parseSignatureError, generateAcceptSignatureHeader, parseAcceptSignature, + generateAcceptSignatureSchemeHeader, + parseAcceptSignatureScheme, + generateAcceptSignatureAlgHeader, + parseAcceptSignatureAlg, } from './utils/signature.js' -export { generateKeyPair } from './utils/crypto.js' -export type { GenerateKeyPairOptions, KeyPair } from './utils/crypto.js' +export { generateKeyPair, determineAlgorithm } from './utils/crypto.js' +export type { + GenerateKeyPairOptions, + GeneratableAlgorithm, + KeyPair, +} from './utils/crypto.js' + +export { SignatureVerificationError } from './errors.js' export { calculateThumbprint } from './utils/thumbprint.js' @@ -35,8 +45,9 @@ export type { VerificationResult, SignatureError, SignatureErrorCode, + SignatureKeyScheme, + SignatureAlgorithm, AcceptSignatureParams, - SigKeyValue, } from './types.js' export { diff --git a/httpsig/src/types.ts b/httpsig/src/types.ts index 19c3b50..86da5b7 100644 --- a/httpsig/src/types.ts +++ b/httpsig/src/types.ts @@ -108,12 +108,13 @@ export interface VerifyOptions { // JWKS caching jwksCacheTtl?: number // JWKS cache TTL in ms (default: 3600000) - - // AAuth profile enforcement - strictAAuth?: boolean // Enforce AAuth profile requirements (default: true) - // When true, requires signature-key in covered components } +// Note: the strictAAuth option was removed in 2.0. Covering `signature-key` is +// a requirement of the specification, not a profile choice -- an uncovered +// Signature-Key header admits the scheme-substitution and identity- +// substitution attacks -- so it is always enforced and cannot be disabled. + export interface VerificationResult { verified: boolean // Overall verification status label: string // Signature label used @@ -170,6 +171,8 @@ export interface ParsedSignatureKey { } export interface HwkValue { + /** REQUIRED. A fully-specified JOSE algorithm identifier. */ + alg: string kty: string crv?: string x?: string @@ -193,6 +196,7 @@ export interface JwksUriValue { */ export type SignatureErrorCode = | 'unsupported_algorithm' + | 'unsupported_scheme' | 'invalid_signature' | 'invalid_input' | 'invalid_request' @@ -203,33 +207,55 @@ export type SignatureErrorCode = /** * Parsed Signature-Error header + * + * The supported_algorithms member was removed in -07; a server states what + * would have worked in Accept-Signature-Alg instead. */ export interface SignatureError { error: SignatureErrorCode - supported_algorithms?: string[] required_input?: string[] } /** - * Accept-Signature sigkey parameter values + * Signature-Key schemes defined by the draft. Not all are implemented here. */ -export type SigKeyValue = 'jkt' | 'uri' | 'x509' +export type SignatureKeyScheme = + | 'hwk' + | 'jwt' + | 'jkt-jwt' + | 'jwks_uri' + | 'jwks' + | 'self-jwt' + | 'x509' /** * Parsed Accept-Signature header parameters + * + * The sigkey parameter was removed in -07. Use the Accept-Signature-Scheme + * and Accept-Signature-Alg header fields. */ export interface AcceptSignatureParams { label: string components: string[] - sigkey?: SigKeyValue alg?: string tag?: string } /** - * Supported signature algorithms + * Fully-specified JOSE algorithm identifiers this implementation supports. */ -export type SignatureAlgorithm = 'Ed25519' | 'ES256' +export type SignatureAlgorithm = + | 'Ed25519' + | 'Ed448' + | 'ES256' + | 'ES384' + | 'ES512' + | 'PS256' + | 'PS384' + | 'PS512' + | 'RS256' + | 'RS384' + | 'RS512' /** * Algorithm parameters for signing diff --git a/httpsig/src/utils/crypto.ts b/httpsig/src/utils/crypto.ts index 717b34c..8aa1e2f 100644 --- a/httpsig/src/utils/crypto.ts +++ b/httpsig/src/utils/crypto.ts @@ -1,35 +1,227 @@ /** * Cryptographic utilities for HTTP Message Signatures + * + * Algorithm determination follows draft-hardt-httpbis-signature-key-07, + * Algorithm Determination: the signature algorithm is taken from the JWK `alg` + * member, which must be a fully-specified identifier (RFC 9864). It is never + * derived from `kty` and `crv` -- those underdetermine the algorithm for RSA + * keys, which have no curve and leave both padding and hash free, and for EC + * keys, whose curve does not fix the hash. */ import { AlgorithmParams } from '../types.js' +import { invalidKey, unsupportedAlgorithm } from '../errors.js' + +interface AlgorithmSpec { + /** The key type this algorithm requires. */ + kty: string + /** The curve this algorithm requires, where the key type has one. */ + crv?: string + /** + * WebCrypto parameters. The same object is passed to importKey and to + * sign/verify; each ignores the members it does not use. + */ + params: AlgorithmParams +} /** - * Get algorithm parameters from JWK + * Fully-specified JOSE algorithm identifiers this implementation supports. + * + * Note ES512 uses P-521, not a "P-512" curve. */ -export function getAlgorithmFromJwk(jwk: JsonWebKey): AlgorithmParams { - if (jwk.kty === 'OKP') { - if (jwk.crv === 'Ed25519') { - return { name: 'Ed25519' } - } - throw new Error(`Unsupported OKP curve: ${jwk.crv}`) +export const FULLY_SPECIFIED_ALGORITHMS: Readonly< + Record +> = { + Ed25519: { + kty: 'OKP', + crv: 'Ed25519', + params: { name: 'Ed25519' }, + }, + Ed448: { + kty: 'OKP', + crv: 'Ed448', + params: { name: 'Ed448' }, + }, + ES256: { + kty: 'EC', + crv: 'P-256', + params: { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' }, + }, + ES384: { + kty: 'EC', + crv: 'P-384', + params: { name: 'ECDSA', namedCurve: 'P-384', hash: 'SHA-384' }, + }, + ES512: { + kty: 'EC', + crv: 'P-521', + params: { name: 'ECDSA', namedCurve: 'P-521', hash: 'SHA-512' }, + }, + PS256: { + kty: 'RSA', + params: { name: 'RSA-PSS', hash: 'SHA-256', saltLength: 32 }, + }, + PS384: { + kty: 'RSA', + params: { name: 'RSA-PSS', hash: 'SHA-384', saltLength: 48 }, + }, + PS512: { + kty: 'RSA', + params: { name: 'RSA-PSS', hash: 'SHA-512', saltLength: 64 }, + }, + RS256: { + kty: 'RSA', + params: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, + }, + RS384: { + kty: 'RSA', + params: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-384' }, + }, + RS512: { + kty: 'RSA', + params: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-512' }, + }, +} + +/** + * Identifiers that name a different signature algorithm depending on the key + * they are used with. Deprecated by RFC 9864 and forbidden by the draft. + */ +export const POLYMORPHIC_ALGORITHMS: ReadonlySet = new Set(['EdDSA']) + +/** + * Shared-secret algorithms. Every scheme here distributes a public key, and a + * secret handed to the verifier proves nothing, so these are rejected rather + * than merely unimplemented. + */ +export const SYMMETRIC_ALGORITHMS: ReadonlySet = new Set([ + 'HS256', + 'HS384', + 'HS512', + 'hmac-sha256', +]) + +/** + * Fully-specified identifiers that are valid but that WebCrypto cannot + * implement, so they are declined rather than rejected as malformed. + */ +export const UNIMPLEMENTED_ALGORITHMS: ReadonlySet = new Set([ + 'ML-DSA-44', + 'ML-DSA-65', + 'ML-DSA-87', +]) + +/** Key members each key type requires. */ +const REQUIRED_MEMBERS: Record = { + OKP: ['crv', 'x'], + EC: ['crv', 'x', 'y'], + RSA: ['n', 'e'], +} + +/** + * Determine the signature algorithm for a JWK, validating it in the process. + * + * Throws a SignatureVerificationError carrying the Signature-Error code the + * verifier should report. + */ +export function determineAlgorithm(jwk: JsonWebKey): AlgorithmParams { + if (!jwk || typeof jwk !== 'object') { + throw invalidKey('JWK is not an object') + } + + if (!jwk.kty) { + throw invalidKey('JWK missing required member: kty') + } + + // A shared secret cannot prove possession to a verifier that holds it. + if (jwk.kty === 'oct') { + throw invalidKey( + 'Symmetric keys are not permitted: kty "oct" names a shared secret', + ) } - if (jwk.kty === 'EC') { - if (jwk.crv === 'P-256') { - return { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' } + const alg = jwk.alg + + if (!alg) { + throw invalidKey( + 'JWK missing required member: alg. The algorithm is taken from the key and is not derived from kty and crv', + ) + } + + if (SYMMETRIC_ALGORITHMS.has(alg)) { + throw invalidKey( + `Symmetric algorithms are not permitted: "${alg}" names a shared secret`, + ) + } + + if (POLYMORPHIC_ALGORITHMS.has(alg)) { + throw invalidKey( + `Polymorphic algorithm identifier "${alg}" is not permitted. Use a fully-specified identifier such as Ed25519 or Ed448 (RFC 9864)`, + ) + } + + // AKP covers several ML-DSA parameter sets, so the key type alone does not + // name an algorithm. Declining is a capability statement, not a parse + // failure, so it reports unsupported_algorithm. + if (jwk.kty === 'AKP' || UNIMPLEMENTED_ALGORITHMS.has(alg)) { + throw unsupportedAlgorithm( + `Algorithm "${alg}" (kty "${jwk.kty}") is not implemented by this verifier`, + ) + } + + const spec = FULLY_SPECIFIED_ALGORITHMS[alg] + if (!spec) { + throw unsupportedAlgorithm( + `Unsupported or not fully-specified algorithm: "${alg}"`, + ) + } + + // The key-structure members are redundant with a fully-specified alg. Use + // the redundancy as a check: a key that can be read two ways is rejected + // rather than resolved in favour of either reading. + if (jwk.kty !== spec.kty) { + throw invalidKey( + `JWK kty "${jwk.kty}" is inconsistent with alg "${alg}", which requires kty "${spec.kty}"`, + ) + } + + if (spec.crv && jwk.crv !== spec.crv) { + throw invalidKey( + `JWK crv "${jwk.crv}" is inconsistent with alg "${alg}", which requires crv "${spec.crv}"`, + ) + } + + for (const member of REQUIRED_MEMBERS[spec.kty] ?? []) { + if (!(jwk as Record)[member]) { + throw invalidKey( + `${spec.kty} JWK missing required member: ${member}`, + ) } - throw new Error(`Unsupported EC curve: ${jwk.crv}`) } - throw new Error(`Unsupported key type: ${jwk.kty}`) + return spec.params +} + +/** + * Get algorithm parameters from a JWK. + */ +export function getAlgorithmFromJwk(jwk: JsonWebKey): AlgorithmParams { + return determineAlgorithm(jwk) +} + +/** + * Validate a JWK, including that its algorithm is fully specified and + * consistent with its key material. + */ +export function validateJwk(jwk: JsonWebKey): void { + determineAlgorithm(jwk) } /** * Import a JWK as a CryptoKey for signing */ export async function importPrivateKey(jwk: JsonWebKey): Promise { - const algorithm = getAlgorithmFromJwk(jwk) + const algorithm = determineAlgorithm(jwk) return await crypto.subtle.importKey('jwk', jwk, algorithm, false, ['sign']) } @@ -38,7 +230,7 @@ export async function importPrivateKey(jwk: JsonWebKey): Promise { * Import a JWK as a CryptoKey for verification */ export async function importPublicKey(jwk: JsonWebKey): Promise { - const algorithm = getAlgorithmFromJwk(jwk) + const algorithm = determineAlgorithm(jwk) return await crypto.subtle.importKey('jwk', jwk, algorithm, false, [ 'verify', @@ -79,11 +271,16 @@ export async function verify( return await crypto.subtle.verify(algorithm, publicKey, signature, data) } +/** + * Algorithms generateKeyPair can produce. + */ +export type GeneratableAlgorithm = 'Ed25519' | 'ES256' | 'ES384' | 'ES512' + /** * Options for key pair generation */ export interface GenerateKeyPairOptions { - algorithm?: 'Ed25519' | 'ES256' // default: 'Ed25519' + algorithm?: GeneratableAlgorithm // default: 'Ed25519' extractable?: boolean // default: true } @@ -92,11 +289,14 @@ export interface GenerateKeyPairOptions { */ export interface KeyPair { privateKey: CryptoKey // CryptoKey handle for signing - publicKey: JsonWebKey // Public key as JWK (always exportable) + publicKey: JsonWebKey // Public key as JWK, carrying alg } /** - * Generate a signing key pair + * Generate a signing key pair. + * + * The exported public JWK carries `alg`, which WebCrypto does not set. Without + * it the key cannot be conveyed by the hwk scheme. */ export async function generateKeyPair( options?: GenerateKeyPairOptions, @@ -104,51 +304,31 @@ export async function generateKeyPair( const algorithm = options?.algorithm ?? 'Ed25519' const extractable = options?.extractable ?? true - let genAlgorithm: - | AlgorithmIdentifier - | RsaHashedKeyGenParams - | EcKeyGenParams - let keyUsages: KeyUsage[] = ['sign', 'verify'] - - if (algorithm === 'Ed25519') { - genAlgorithm = { name: 'Ed25519' } - } else if (algorithm === 'ES256') { - genAlgorithm = { name: 'ECDSA', namedCurve: 'P-256' } - } else { + const spec = FULLY_SPECIFIED_ALGORITHMS[algorithm] + if (!spec) { throw new Error(`Unsupported algorithm: ${algorithm}`) } + const genAlgorithm: AlgorithmIdentifier | EcKeyGenParams = spec.crv + ? spec.params.name === 'ECDSA' + ? { name: 'ECDSA', namedCurve: spec.crv } + : { name: spec.params.name } + : { name: spec.params.name } + const keyPair = (await crypto.subtle.generateKey( genAlgorithm, extractable, - keyUsages, + ['sign', 'verify'], )) as CryptoKeyPair // Public key is always exportable const publicKey = await crypto.subtle.exportKey('jwk', keyPair.publicKey) + // WebCrypto omits alg; the draft requires it. + publicKey.alg = algorithm + return { privateKey: keyPair.privateKey, publicKey, } } - -/** - * Validate JWK structure - */ -export function validateJwk(jwk: JsonWebKey): void { - if (!jwk.kty) { - throw new Error('JWK missing required field: kty') - } - - if (jwk.kty === 'OKP') { - if (!jwk.crv) throw new Error('OKP JWK missing required field: crv') - if (!jwk.x) throw new Error('OKP JWK missing required field: x') - } else if (jwk.kty === 'EC') { - if (!jwk.crv) throw new Error('EC JWK missing required field: crv') - if (!jwk.x) throw new Error('EC JWK missing required field: x') - if (!jwk.y) throw new Error('EC JWK missing required field: y') - } else { - throw new Error(`Unsupported key type: ${jwk.kty}`) - } -} diff --git a/httpsig/src/utils/signature.ts b/httpsig/src/utils/signature.ts index f674c1c..ef77b95 100644 --- a/httpsig/src/utils/signature.ts +++ b/httpsig/src/utils/signature.ts @@ -10,8 +10,8 @@ import { SignatureError, SignatureErrorCode, AcceptSignatureParams, - SigKeyValue, } from '../types.js' +import { invalidKey, unsupportedScheme } from '../errors.js' /** * Generate signature base string from components @@ -62,8 +62,20 @@ export function generateSignatureKeyHeader( throw new Error('Public JWK required for hwk signature key type') } - // Build hwk parameters from JWK - const params: string[] = [`kty="${publicJwk.kty}"`] + // alg is REQUIRED and carries the algorithm; the verifier does not + // derive it from kty and crv. + if (!publicJwk.alg) { + throw new Error( + 'Public JWK missing required alg member for hwk signature key type', + ) + } + + // Build hwk parameters from JWK. kid is deliberately not emitted: the + // key is inline, so an identifier selects nothing. + const params: string[] = [ + `alg="${publicJwk.alg}"`, + `kty="${publicJwk.kty}"`, + ] if (publicJwk.crv) params.push(`crv="${publicJwk.crv}"`) if (publicJwk.x) params.push(`x="${publicJwk.x}"`) @@ -250,14 +262,26 @@ export function parseSignatureKey(header: string): ParsedSignatureKey[] { } } - if (!['hwk', 'jwt', 'jkt-jwt', 'jwks_uri', 'x509'].includes(scheme)) { - throw new Error(`Unsupported Signature-Key scheme: ${scheme}`) + if (!['hwk', 'jwt', 'jkt-jwt', 'jwks_uri'].includes(scheme)) { + throw unsupportedScheme(`Unsupported Signature-Key scheme: ${scheme}`) } if (scheme === 'hwk') { // Validate hwk has required parameters if (!params.kty) { - throw new Error('Signature-Key hwk scheme missing kty parameter') + throw invalidKey('Signature-Key hwk scheme missing kty parameter') + } + + if (!params.alg) { + throw invalidKey('Signature-Key hwk scheme missing alg parameter') + } + + // The key is inline, so a kid selects nothing and one that disagrees + // with the inline key has no defined resolution. + if (params.kid !== undefined) { + throw invalidKey( + 'Signature-Key hwk scheme MUST NOT include a kid parameter', + ) } return [{ label, type: 'hwk', value: params }] @@ -316,34 +340,25 @@ export function parseSignatureKey(header: string): ParsedSignatureKey[] { ] } - // Note: x509 scheme not yet implemented - // Future implementation would parse: x509;x5u="...";x5t="..." - // if (scheme === 'x509') { - // if (!params.x5u || !params.x5t) { - // throw new Error('Signature-Key x509 scheme missing x5u/x5t parameters') - // } - // return [{ label, type: 'x509', value: { x5u: params.x5u, x5t: params.x5t } }] - // } + // Note: the x509, jwks, and self-jwt schemes are not yet implemented. + // Unknown and unimplemented schemes take the same defined path. - throw new Error(`Unsupported Signature-Key scheme: ${scheme}`) + throw unsupportedScheme(`Unsupported Signature-Key scheme: ${scheme}`) } /** * Generate Signature-Error header value as RFC 8941 Dictionary - * Format: error=[, supported_algorithms=("alg1" "alg2")][, required_input=("comp1" "comp2")] + * Format: error=[, required_input=("comp1" "comp2")] + * + * The supported_algorithms member was removed in -07. A server states what + * would have worked in the Accept-Signature-Alg and Accept-Signature-Scheme + * header fields, which work on a challenge and on an error alike. */ export function generateSignatureErrorHeader( signatureError: SignatureError, ): string { const parts: string[] = [`error=${signatureError.error}`] - if (signatureError.supported_algorithms) { - const algList = signatureError.supported_algorithms - .map((a) => `"${a}"`) - .join(' ') - parts.push(`supported_algorithms=(${algList})`) - } - if (signatureError.required_input) { const inputList = signatureError.required_input .map((c) => `"${c}"`) @@ -369,6 +384,7 @@ export function parseSignatureError(header: string): SignatureError { const error = errorMatch[1] as SignatureErrorCode const validCodes: SignatureErrorCode[] = [ 'unsupported_algorithm', + 'unsupported_scheme', 'invalid_signature', 'invalid_input', 'invalid_request', @@ -383,15 +399,6 @@ export function parseSignatureError(header: string): SignatureError { const result: SignatureError = { error } - // Parse supported_algorithms inner list - const algMatch = trimmed.match(/supported_algorithms=\(([^)]*)\)/) - if (algMatch) { - result.supported_algorithms = algMatch[1] - .split(/\s+/) - .map((a) => a.replace(/"/g, '')) - .filter((a) => a) - } - // Parse required_input inner list const inputMatch = trimmed.match(/required_input=\(([^)]*)\)/) if (inputMatch) { @@ -404,20 +411,81 @@ export function parseSignatureError(header: string): SignatureError { return result } +/** + * Generate an RFC 8941 List of Tokens. + */ +function generateTokenList(values: string[]): string { + for (const value of values) { + if (!/^[A-Za-z*][A-Za-z0-9!#$%&'*+\-.^_`|~:/]*$/.test(value)) { + throw new Error( + `Value is not a valid Structured Field Token: ${value}`, + ) + } + } + return values.join(', ') +} + +/** + * Parse an RFC 8941 List of Tokens, ignoring entries that are not tokens. + */ +function parseTokenList(header: string): string[] { + return header + .split(',') + .map((v) => v.trim()) + .filter((v) => /^[A-Za-z*][A-Za-z0-9!#$%&'*+\-.^_`|~:/]*$/.test(v)) +} + +/** + * Generate Accept-Signature-Scheme: the Signature-Key schemes the server + * accepts, in descending order of preference. + * + * Format: scheme1, scheme2 + */ +export function generateAcceptSignatureSchemeHeader(schemes: string[]): string { + return generateTokenList(schemes) +} + +/** + * Parse Accept-Signature-Scheme. Unrecognized tokens are preserved: a client + * ignores what it does not know so a server may list schemes registered after + * the client was written. + */ +export function parseAcceptSignatureScheme(header: string): string[] { + return parseTokenList(header) +} + +/** + * Generate Accept-Signature-Alg: the signature algorithms the server accepts, + * as fully-specified JOSE identifiers. + * + * Format: alg1, alg2 + */ +export function generateAcceptSignatureAlgHeader(algs: string[]): string { + return generateTokenList(algs) +} + +/** + * Parse Accept-Signature-Alg. + */ +export function parseAcceptSignatureAlg(header: string): string[] { + return parseTokenList(header) +} + /** * Generate Accept-Signature header value - * Format: label=("comp1" "comp2");sigkey=jkt[;alg="algo"][;tag="tag"] + * Format: label=("comp1" "comp2")[;alg="algo"][;tag="tag"] + * + * The sigkey parameter was removed in -07. A parameter value is a bare Item + * and cannot be a list, so sigkey could name only one scheme. Use + * Accept-Signature-Scheme and Accept-Signature-Alg instead. */ export function generateAcceptSignatureHeader( params: AcceptSignatureParams, ): string { - const { label = 'sig', components, sigkey, alg, tag } = params + const { label = 'sig', components, alg, tag } = params const componentList = components.map((c) => `"${c}"`).join(' ') let header = `${label}=(${componentList})` - if (sigkey) { - header += `;sigkey=${sigkey}` - } if (alg) { header += `;alg="${alg}"` } @@ -430,7 +498,7 @@ export function generateAcceptSignatureHeader( /** * Parse Accept-Signature header - * Format: label=("comp1" "comp2");sigkey=jkt[;alg="algo"][;tag="tag"] + * Format: label=("comp1" "comp2")[;alg="algo"][;tag="tag"] */ export function parseAcceptSignature(header: string): AcceptSignatureParams { const trimmed = header.trim() @@ -453,15 +521,6 @@ export function parseAcceptSignature(header: string): AcceptSignatureParams { const result: AcceptSignatureParams = { label, components } if (paramsStr) { - // Parse sigkey token parameter - const sigkeyMatch = paramsStr.match(/;sigkey=([\w]+)/) - if (sigkeyMatch) { - const value = sigkeyMatch[1] as SigKeyValue - if (['jkt', 'uri', 'x509'].includes(value)) { - result.sigkey = value - } - } - // Parse alg string parameter const algMatch = paramsStr.match(/;alg="([^"]*)"/) if (algMatch) { diff --git a/httpsig/src/verify.ts b/httpsig/src/verify.ts index 42b0dc4..9d8e3f8 100644 --- a/httpsig/src/verify.ts +++ b/httpsig/src/verify.ts @@ -23,12 +23,38 @@ import { import { base64urlDecode } from './utils/base64.js' import { calculateThumbprint } from './utils/thumbprint.js' import { BoundedTtlCache } from './utils/cache.js' +import { + SignatureVerificationError, + invalidInput, + invalidJwt, + expiredJwt, +} from './errors.js' // JWKS cache. Bounded: the cache key is a URL derived from the request being // verified, so an unauthenticated signer would otherwise be able to grow this // without limit by varying the id/dwk it presents. const jwksCache = new BoundedTtlCache() +/** + * Map a thrown error to a structured SignatureError. + * + * A SignatureVerificationError carries its code explicitly. Anything else is + * matched on message text, which covers paths that still throw plain Errors. + */ +function toSignatureError(error: unknown): SignatureError { + if (error instanceof SignatureVerificationError) { + const result: SignatureError = { error: error.code } + if (error.requiredInput) { + result.required_input = error.requiredInput + } + return result + } + + return mapToSignatureError( + error instanceof Error ? error.message : String(error), + ) +} + /** * Map an error message from verification to a structured SignatureError */ @@ -181,28 +207,58 @@ async function getPublicKeyFromJWKS( } /** - * Decode JWT and extract cnf.jwk claim + * Decode a jwt-scheme assertion and extract its cnf.jwk confirmation key. + * + * The issuer's signature over the assertion is NOT checked here -- the caller + * validates the issuer -- but `exp` is, because `exp` is what bounds how long + * the confirmation key the assertion carries remains acceptable. An assertion + * without `exp` would leave that key acceptable indefinitely. */ -function decodeJWT(jwt: string): { +function decodeJWT( + jwt: string, + maxClockSkew: number, +): { header: any payload: any publicKey: JsonWebKey } { const parts = jwt.split('.') if (parts.length !== 3) { - throw new Error('Invalid JWT format') + throw invalidJwt('Invalid JWT format') } - const header = JSON.parse( - new TextDecoder().decode(base64urlDecode(parts[0])), - ) - const payload = JSON.parse( - new TextDecoder().decode(base64urlDecode(parts[1])), - ) + let header: any + let payload: any + try { + header = JSON.parse(new TextDecoder().decode(base64urlDecode(parts[0]))) + payload = JSON.parse( + new TextDecoder().decode(base64urlDecode(parts[1])), + ) + } catch { + throw invalidJwt('Invalid JWT: header or payload is not valid JSON') + } // Extract cnf.jwk if (!payload.cnf || !payload.cnf.jwk) { - throw new Error('JWT missing cnf.jwk claim') + throw invalidJwt('JWT missing cnf.jwk claim') + } + + const now = Math.floor(Date.now() / 1000) + + if (typeof payload.exp !== 'number') { + throw invalidJwt('JWT missing required exp claim') + } + if (payload.exp + maxClockSkew < now) { + throw expiredJwt('JWT expired') + } + + if (payload.iat !== undefined) { + if (typeof payload.iat !== 'number') { + throw invalidJwt('JWT iat claim is not a number') + } + if (payload.iat - maxClockSkew > now) { + throw invalidJwt('JWT iat is in the future') + } } return { @@ -344,7 +400,6 @@ export async function verify( const { maxClockSkew = 60, jwksCacheTtl = 3600000, // 1 hour - strictAAuth = true, // Enforce AAuth profile by default } = options try { @@ -380,11 +435,16 @@ export async function verify( const { components, params } = signatureInput - // Validate that signature-key is in covered components (AAuth profile requirement) - if (strictAAuth && !components.includes('signature-key')) { - throw new Error( - 'AAuth profile violation: signature-key must be in covered components', - ) + // signature-key MUST be a covered component. If it is not, an attacker + // can substitute the scheme or the signer identity without + // invalidating the signature, so this is not optional. + if (!components.includes('signature-key')) { + throw invalidInput('signature-key must be a covered component', [ + '@method', + '@authority', + '@path', + 'signature-key', + ]) } // Validate timestamp @@ -415,7 +475,10 @@ export async function verify( publicJwk = signatureKey.value as JsonWebKey } else if (signatureKey.type === 'jwt') { const jwtValue = signatureKey.value as { jwt: string } - const { header, payload, publicKey } = decodeJWT(jwtValue.jwt) + const { header, payload, publicKey } = decodeJWT( + jwtValue.jwt, + maxClockSkew, + ) publicJwk = publicKey jwtData = { header, @@ -611,7 +674,7 @@ export async function verify( thumbprint: '', created: 0, error: errorMessage, - signatureError: mapToSignatureError(errorMessage), + signatureError: toSignatureError(error), } } } diff --git a/httpsig/tests/test-accept-signature.ts b/httpsig/tests/test-accept-signature.ts index 86a8ed6..f29dbd0 100644 --- a/httpsig/tests/test-accept-signature.ts +++ b/httpsig/tests/test-accept-signature.ts @@ -1,5 +1,9 @@ /** - * Tests for Accept-Signature header generation and parsing + * Tests for Accept-Signature, Accept-Signature-Scheme and Accept-Signature-Alg + * + * The sigkey parameter was removed in -07: a Structured Fields parameter value + * is a bare Item and cannot be a list, so it could name only one scheme. The + * accepted sets now travel in their own header fields as Lists of Tokens. */ import { test } from 'node:test' @@ -7,84 +11,45 @@ import assert from 'node:assert' import { generateAcceptSignatureHeader, parseAcceptSignature, + generateAcceptSignatureSchemeHeader, + parseAcceptSignatureScheme, + generateAcceptSignatureAlgHeader, + parseAcceptSignatureAlg, } from '../src/utils/signature.js' import type { AcceptSignatureParams } from '../src/types.js' -test('Accept-Signature: generate with sigkey=jkt', () => { +test('Accept-Signature: generate with components only', () => { const header = generateAcceptSignatureHeader({ label: 'sig1', components: ['@method', '@path', '@authority'], - sigkey: 'jkt', }) - assert.strictEqual( - header, - 'sig1=("@method" "@path" "@authority");sigkey=jkt', - ) + assert.strictEqual(header, 'sig1=("@method" "@path" "@authority")') }) -test('Accept-Signature: generate with sigkey=uri and alg', () => { +test('Accept-Signature: generate with alg', () => { const header = generateAcceptSignatureHeader({ label: 'sig1', components: ['@method', '@authority', '@path'], - sigkey: 'uri', - alg: 'ecdsa-p256-sha256', - }) - assert.strictEqual( - header, - 'sig1=("@method" "@authority" "@path");sigkey=uri;alg="ecdsa-p256-sha256"', - ) -}) - -test('Accept-Signature: generate with sigkey=x509', () => { - const header = generateAcceptSignatureHeader({ - label: 'sig', - components: ['@method', '@path', '@authority'], - sigkey: 'x509', + alg: 'ed25519', }) assert.strictEqual( header, - 'sig=("@method" "@path" "@authority");sigkey=x509', + 'sig1=("@method" "@authority" "@path");alg="ed25519"', ) }) -test('Accept-Signature: generate without sigkey', () => { - const header = generateAcceptSignatureHeader({ - label: 'sig', - components: ['@method', '@path'], - }) - assert.strictEqual(header, 'sig=("@method" "@path")') -}) - test('Accept-Signature: generate with tag', () => { const header = generateAcceptSignatureHeader({ label: 'sig', components: ['@method', '@path'], - sigkey: 'jkt', tag: 'my-app', }) - assert.strictEqual( - header, - 'sig=("@method" "@path");sigkey=jkt;tag="my-app"', - ) + assert.strictEqual(header, 'sig=("@method" "@path");tag="my-app"') }) -test('Accept-Signature: parse with sigkey=jkt', () => { +test('Accept-Signature: parse components and alg', () => { const result = parseAcceptSignature( - 'sig1=("@method" "@path" "@authority");sigkey=jkt', - ) - assert.strictEqual(result.label, 'sig1') - assert.deepStrictEqual(result.components, [ - '@method', - '@path', - '@authority', - ]) - assert.strictEqual(result.sigkey, 'jkt') - assert.strictEqual(result.alg, undefined) -}) - -test('Accept-Signature: parse with sigkey=uri and alg', () => { - const result = parseAcceptSignature( - 'sig1=("@method" "@authority" "@path");alg="ecdsa-p256-sha256";sigkey=uri', + 'sig1=("@method" "@authority" "@path");alg="ed25519"', ) assert.strictEqual(result.label, 'sig1') assert.deepStrictEqual(result.components, [ @@ -92,23 +57,7 @@ test('Accept-Signature: parse with sigkey=uri and alg', () => { '@authority', '@path', ]) - assert.strictEqual(result.sigkey, 'uri') - assert.strictEqual(result.alg, 'ecdsa-p256-sha256') -}) - -test('Accept-Signature: parse without sigkey', () => { - const result = parseAcceptSignature('sig=("@method" "@path")') - assert.strictEqual(result.label, 'sig') - assert.deepStrictEqual(result.components, ['@method', '@path']) - assert.strictEqual(result.sigkey, undefined) -}) - -test('Accept-Signature: parse with tag', () => { - const result = parseAcceptSignature( - 'sig=("@method" "@path");sigkey=jkt;tag="my-app"', - ) - assert.strictEqual(result.sigkey, 'jkt') - assert.strictEqual(result.tag, 'my-app') + assert.strictEqual(result.alg, 'ed25519') }) test('Accept-Signature: parse throws on invalid format', () => { @@ -123,22 +72,15 @@ test('Accept-Signature: roundtrip', () => { { label: 'sig1', components: ['@method', '@path', '@authority'], - sigkey: 'jkt', }, { label: 'sig', components: ['@method', '@authority', '@path'], - sigkey: 'uri', - alg: 'ecdsa-p256-sha256', + alg: 'ed25519', }, { label: 'sig', components: ['@method', '@path'], - }, - { - label: 'sig', - components: ['@method', '@path'], - sigkey: 'x509', tag: 'enterprise', }, ] @@ -153,3 +95,71 @@ test('Accept-Signature: roundtrip', () => { ) } }) + +test('Accept-Signature-Scheme: generate a list in preference order', () => { + assert.strictEqual( + generateAcceptSignatureSchemeHeader(['jwks_uri', 'jwt', 'hwk']), + 'jwks_uri, jwt, hwk', + ) +}) + +test('Accept-Signature-Scheme: generate a single scheme', () => { + assert.strictEqual(generateAcceptSignatureSchemeHeader(['hwk']), 'hwk') +}) + +test('Accept-Signature-Scheme: parse a list', () => { + assert.deepStrictEqual(parseAcceptSignatureScheme('jwks_uri, jwt, hwk'), [ + 'jwks_uri', + 'jwt', + 'hwk', + ]) +}) + +test('Accept-Signature-Scheme: parse tolerates irregular whitespace', () => { + assert.deepStrictEqual(parseAcceptSignatureScheme(' hwk ,jwt '), [ + 'hwk', + 'jwt', + ]) +}) + +test('Accept-Signature-Scheme: preserves schemes it does not recognize', () => { + // A client ignores what it does not know, so a server may list schemes + // registered after the client was written without breaking it. + assert.deepStrictEqual( + parseAcceptSignatureScheme('hwk, some-future-scheme'), + ['hwk', 'some-future-scheme'], + ) +}) + +test('Accept-Signature-Alg: generate and parse fully-specified identifiers', () => { + const header = generateAcceptSignatureAlgHeader([ + 'Ed25519', + 'ES256', + 'ML-DSA-44', + ]) + assert.strictEqual(header, 'Ed25519, ES256, ML-DSA-44') + assert.deepStrictEqual(parseAcceptSignatureAlg(header), [ + 'Ed25519', + 'ES256', + 'ML-DSA-44', + ]) +}) + +test('Accept-Signature-Alg: rejects a value that is not a valid token', () => { + assert.throws( + () => generateAcceptSignatureAlgHeader(['Ed25519', 'not a token']), + /not a valid Structured Field Token/, + ) +}) + +test('Accept-Signature-Alg: parse drops non-token entries', () => { + assert.deepStrictEqual( + parseAcceptSignatureAlg('Ed25519, "quoted", ES256'), + ['Ed25519', 'ES256'], + ) +}) + +test('Accept-Signature-*: empty header parses to an empty list', () => { + assert.deepStrictEqual(parseAcceptSignatureScheme(''), []) + assert.deepStrictEqual(parseAcceptSignatureAlg(''), []) +}) diff --git a/httpsig/tests/test-edge-cases.ts b/httpsig/tests/test-edge-cases.ts index 9d3024b..58220fe 100644 --- a/httpsig/tests/test-edge-cases.ts +++ b/httpsig/tests/test-edge-cases.ts @@ -84,7 +84,7 @@ test('Invalid signing key: should throw error for missing kty', async () => { dryRun: true, }) }, - /JWK missing required field: kty/, + /JWK missing required member: kty/, 'Should reject invalid JWK', ) @@ -92,7 +92,7 @@ test('Invalid signing key: should throw error for missing kty', async () => { }) test('Invalid signing key: should throw error for unsupported key type', async () => { - const invalidKey = { kty: 'UNSUPPORTED' } as JsonWebKey + const invalidKey = { kty: 'UNSUPPORTED', alg: 'UNSUPPORTED' } as JsonWebKey await assert.rejects( async () => { @@ -102,7 +102,7 @@ test('Invalid signing key: should throw error for unsupported key type', async ( dryRun: true, }) }, - /Unsupported key type/, + /Unsupported or not fully-specified algorithm/, 'Should reject unsupported key type', ) @@ -113,6 +113,7 @@ test('Invalid signing key: should throw error for OKP key missing x', async () = const invalidKey = { kty: 'OKP', crv: 'Ed25519', + alg: 'Ed25519', // missing x } as JsonWebKey @@ -124,7 +125,7 @@ test('Invalid signing key: should throw error for OKP key missing x', async () = dryRun: true, }) }, - /OKP JWK missing required field: x/, + /OKP JWK missing required member: x/, 'Should reject OKP key missing x', ) @@ -135,6 +136,7 @@ test('Invalid signing key: should throw error for EC key missing y', async () => const invalidKey = { kty: 'EC', crv: 'P-256', + alg: 'ES256', x: 'test', // missing y } as JsonWebKey @@ -147,14 +149,16 @@ test('Invalid signing key: should throw error for EC key missing y', async () => dryRun: true, }) }, - /EC JWK missing required field: y/, + /EC JWK missing required member: y/, 'Should reject EC key missing y', ) console.log('✓ EC key missing y is rejected') }) -test('Invalid signing key: should throw error for RSA keys (not supported)', async () => { +test('Invalid signing key: RSA key that does not name padding and hash', async () => { + // kty "RSA" determines neither the padding scheme nor the hash, so alg is + // required and must name both, for example PS256 or RS256. const rsaKey = { kty: 'RSA', n: 'xGOr_H7A5L9VZhZ8w...', @@ -169,11 +173,11 @@ test('Invalid signing key: should throw error for RSA keys (not supported)', asy dryRun: true, }) }, - /Unsupported key type: RSA/, - 'Should reject RSA keys as unsupported', + /missing required member: alg/, + 'Should reject an RSA key with no alg', ) - console.log('✓ RSA keys are rejected (not supported)') + console.log('✓ RSA key without a fully-specified alg is rejected') }) test('Body handling: undefined body should not add content headers', async () => { diff --git a/httpsig/tests/test-go-interop.ts b/httpsig/tests/test-go-interop.ts index 5a4257e..485cfed 100644 --- a/httpsig/tests/test-go-interop.ts +++ b/httpsig/tests/test-go-interop.ts @@ -39,12 +39,12 @@ for (const vector of vectors) { // Using RFC 8941 Dictionary format: label=scheme;param1="value1";param2="value2" let signatureKeyHeader: string if (vector.algorithm === 'Ed25519') { - signatureKeyHeader = `sig=hwk;kty="OKP";crv="Ed25519";x="${vector.publicKey.x}"` + signatureKeyHeader = `sig=hwk;alg="Ed25519";kty="OKP";crv="Ed25519";x="${vector.publicKey.x}"` console.log( `Public Key: kty=${vector.publicKey.kty}, crv=${vector.publicKey.crv}, x=${vector.publicKey.x}`, ) } else if (vector.algorithm === 'ES256') { - signatureKeyHeader = `sig=hwk;kty="EC";crv="P-256";x="${vector.publicKey.x}";y="${vector.publicKey.y}"` + signatureKeyHeader = `sig=hwk;alg="ES256";kty="EC";crv="P-256";x="${vector.publicKey.x}";y="${vector.publicKey.y}"` console.log( `Public Key: kty=${vector.publicKey.kty}, crv=${vector.publicKey.crv}, x=${vector.publicKey.x}, y=${vector.publicKey.y}`, ) @@ -73,7 +73,6 @@ for (const vector of vectors) { }, { maxClockSkew: 999999999, // Large skew since these are historical - strictAAuth: false, // Go test vectors are RFC 9421, not AAuth profile }, ) @@ -88,13 +87,23 @@ for (const vector of vectors) { console.log(` Error: ${result.error}`) } + // These vectors are bare RFC 9421: their component list is empty, so + // `signature-key` is not covered. Covering it is a requirement of the + // specification rather than a profile choice, because an uncovered + // Signature-Key header can be substituted without invalidating the + // signature, so they are rejected. assert.strictEqual( result.verified, - true, - `Should verify Go-generated signature for: ${vector.name}`, + false, + `Go vector does not cover signature-key and must be rejected: ${vector.name}`, + ) + assert.strictEqual( + result.signatureError?.error, + 'invalid_input', + `Should report invalid_input for: ${vector.name}`, ) - console.log(`\n✓ Successfully verified Go-generated signature!`) + console.log(`\n✓ Correctly rejected: signature-key not covered`) }) } diff --git a/httpsig/tests/test-hwk.ts b/httpsig/tests/test-hwk.ts index 0d91ca5..3a27e96 100644 --- a/httpsig/tests/test-hwk.ts +++ b/httpsig/tests/test-hwk.ts @@ -21,6 +21,10 @@ async function generateEd25519KeyPair() { const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey) const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey) + // alg is REQUIRED on a JWK; WebCrypto does not set it. + privateJwk.alg = 'Ed25519' + publicJwk.alg = 'Ed25519' + return { privateJwk, publicJwk } } diff --git a/httpsig/tests/test-jkt-jwt.ts b/httpsig/tests/test-jkt-jwt.ts index 5fec8ef..fc1a895 100644 --- a/httpsig/tests/test-jkt-jwt.ts +++ b/httpsig/tests/test-jkt-jwt.ts @@ -21,6 +21,10 @@ async function generateEd25519KeyPair() { const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey) const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey) + // alg is REQUIRED on a JWK; WebCrypto does not set it. + privateJwk.alg = 'Ed25519' + publicJwk.alg = 'Ed25519' + return { privateJwk, publicJwk } } @@ -37,6 +41,10 @@ async function generateP256KeyPair() { const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey) const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey) + // alg is REQUIRED on a JWK; WebCrypto does not set it. + privateJwk.alg = 'ES256' + publicJwk.alg = 'ES256' + return { privateJwk, publicJwk } } diff --git a/httpsig/tests/test-jwks.ts b/httpsig/tests/test-jwks.ts index b8ced5b..e51a0a0 100644 --- a/httpsig/tests/test-jwks.ts +++ b/httpsig/tests/test-jwks.ts @@ -21,6 +21,10 @@ async function generateEd25519KeyPair() { const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey) const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey) + // alg is REQUIRED on a JWK; WebCrypto does not set it. + privateJwk.alg = 'Ed25519' + publicJwk.alg = 'Ed25519' + return { privateJwk, publicJwk } } diff --git a/httpsig/tests/test-jwt.ts b/httpsig/tests/test-jwt.ts index 6b9f255..2460087 100644 --- a/httpsig/tests/test-jwt.ts +++ b/httpsig/tests/test-jwt.ts @@ -22,6 +22,10 @@ async function generateEd25519KeyPair() { const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey) const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey) + // alg is REQUIRED on a JWK; WebCrypto does not set it. + privateJwk.alg = 'Ed25519' + publicJwk.alg = 'Ed25519' + return { privateJwk, publicJwk } } diff --git a/httpsig/tests/test-return-sent.ts b/httpsig/tests/test-return-sent.ts index c0c0d69..ba0323d 100644 --- a/httpsig/tests/test-return-sent.ts +++ b/httpsig/tests/test-return-sent.ts @@ -17,6 +17,9 @@ async function generateEd25519KeyPair() { )) as CryptoKeyPair const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey) + + // alg is REQUIRED on a JWK; WebCrypto does not set it. + privateJwk.alg = 'Ed25519' return { privateJwk } } diff --git a/httpsig/tests/test-rfc9421-vectors.ts b/httpsig/tests/test-rfc9421-vectors.ts index c922d0e..a9900fa 100644 --- a/httpsig/tests/test-rfc9421-vectors.ts +++ b/httpsig/tests/test-rfc9421-vectors.ts @@ -42,7 +42,7 @@ test('RFC 9421 Test Vector B.2.6: Ed25519 signature verification', async () => { // This is our extension - allows verify() to get the public key // The key is from RFC 9421 Appendix B.1.4 (test-key-ed25519) 'signature-key': - 'sig-b26=hwk;kty="OKP";crv="Ed25519";x="JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs"', + 'sig-b26=hwk;alg="Ed25519";kty="OKP";crv="Ed25519";x="JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs"', }) // Body from RFC test message @@ -84,7 +84,6 @@ test('RFC 9421 Test Vector B.2.6: Ed25519 signature verification', async () => { { // Allow larger clock skew since test is from 2021 maxClockSkew: 999999999, - strictAAuth: false, // RFC 9421 test vectors are not AAuth profile }, ) @@ -100,39 +99,32 @@ test('RFC 9421 Test Vector B.2.6: Ed25519 signature verification', async () => { console.log(' Error:', result.error) } - // Assertions + // The RFC vector's covered components are + // ("date" "@method" "@path" "@authority" "content-type" "content-length"), + // which does not include signature-key. The signature was produced before + // this specification existed, so the component cannot be added without + // invalidating it. Covering signature-key is a requirement rather than a + // profile choice, so the vector is rejected. assert.strictEqual( result.verified, - true, - 'RFC 9421 test vector should verify successfully', + false, + 'RFC 9421 vector does not cover signature-key and must be rejected', ) assert.strictEqual( - result.label, - 'sig-b26', - 'Should use label from RFC test vector', + result.signatureError?.error, + 'invalid_input', + 'Should report invalid_input', ) - assert.strictEqual(result.keyType, 'hwk', 'Should extract key from hwk') - assert.strictEqual(result.publicKey.kty, 'OKP', 'Should be OKP key') - assert.strictEqual( - result.publicKey.crv, - 'Ed25519', - 'Should be Ed25519 curve', - ) - assert.strictEqual( - result.publicKey.x, - 'JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs', - 'Should extract correct public key', - ) - assert.strictEqual( - result.created, - 1618884473, - 'Should extract correct timestamp', + assert.ok( + result.signatureError?.required_input?.includes('signature-key'), + 'Should name signature-key as required', ) + // Rejection happens before key extraction, so no key or timestamp is + // reported. Cryptographic agreement with the RFC vector is covered by the + // signature base test below. - console.log('\n✓ RFC 9421 Test Vector B.2.6 verified successfully!') - console.log( - ' This proves our implementation can verify standard RFC 9421 signatures.', - ) + console.log('\n✓ RFC 9421 Test Vector B.2.6 correctly rejected') + console.log(' signature-key is not among its covered components.') }) /** @@ -191,7 +183,7 @@ test('RFC 9421 Test Vector B.2.6: Component handling', async () => { signature: 'sig-b26=:wqcAqbmYJ2ji2glfAMaRy4gruYYnx2nEFN2HN6jrnDnQCK1u02Gb04v9EDgwUPiu4A0w6vuQv5lIp5WPpBKRCw==:', 'signature-key': - 'sig-b26=hwk;kty="OKP";crv="Ed25519";x="JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs"', + 'sig-b26=hwk;alg="Ed25519";kty="OKP";crv="Ed25519";x="JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs"', }) // Parse URL to extract authority, path, and query @@ -208,15 +200,17 @@ test('RFC 9421 Test Vector B.2.6: Component handling', async () => { }, { maxClockSkew: 999999999, - strictAAuth: false, // RFC 9421 test vectors are not AAuth profile }, ) + // Same as above: the RFC vector predates this specification and does not + // cover signature-key. assert.strictEqual( result.verified, - true, - 'Should correctly handle @path, @authority, and content-length components from RFC test', + false, + 'RFC 9421 vector does not cover signature-key and must be rejected', ) + assert.strictEqual(result.signatureError?.error, 'invalid_input') console.log('\n✓ Successfully verified signature using RFC components:') console.log(' - @method (standard)') diff --git a/httpsig/tests/test-signature-error.ts b/httpsig/tests/test-signature-error.ts index 6e01b70..cd2760b 100644 --- a/httpsig/tests/test-signature-error.ts +++ b/httpsig/tests/test-signature-error.ts @@ -10,16 +10,18 @@ import { } from '../src/utils/signature.js' import type { SignatureError } from '../src/types.js' -test('Signature-Error: generate unsupported_algorithm with supported list', () => { - const error: SignatureError = { - error: 'unsupported_algorithm', - supported_algorithms: ['ed25519', 'ecdsa-p256-sha256'], - } +test('Signature-Error: generate unsupported_algorithm', () => { + // The supported_algorithms member was removed in -07. A server states what + // would have worked in Accept-Signature-Alg, which works on a challenge + // and on an error alike. + const error: SignatureError = { error: 'unsupported_algorithm' } const header = generateSignatureErrorHeader(error) - assert.strictEqual( - header, - 'error=unsupported_algorithm, supported_algorithms=("ed25519" "ecdsa-p256-sha256")', - ) + assert.strictEqual(header, 'error=unsupported_algorithm') +}) + +test('Signature-Error: generate unsupported_scheme', () => { + const header = generateSignatureErrorHeader({ error: 'unsupported_scheme' }) + assert.strictEqual(header, 'error=unsupported_scheme') }) test('Signature-Error: generate invalid_signature', () => { @@ -61,21 +63,19 @@ test('Signature-Error: generate all simple error codes', () => { } }) -test('Signature-Error: parse unsupported_algorithm with supported list', () => { - const header = - 'error=unsupported_algorithm, supported_algorithms=("ed25519" "ecdsa-p256-sha256")' - const result = parseSignatureError(header) +test('Signature-Error: parse unsupported_algorithm', () => { + const result = parseSignatureError('error=unsupported_algorithm') assert.strictEqual(result.error, 'unsupported_algorithm') - assert.deepStrictEqual(result.supported_algorithms, [ - 'ed25519', - 'ecdsa-p256-sha256', - ]) +}) + +test('Signature-Error: parse unsupported_scheme', () => { + const result = parseSignatureError('error=unsupported_scheme') + assert.strictEqual(result.error, 'unsupported_scheme') }) test('Signature-Error: parse invalid_signature', () => { const result = parseSignatureError('error=invalid_signature') assert.strictEqual(result.error, 'invalid_signature') - assert.strictEqual(result.supported_algorithms, undefined) assert.strictEqual(result.required_input, undefined) }) @@ -94,7 +94,7 @@ test('Signature-Error: parse invalid_input with required_input', () => { test('Signature-Error: parse throws on missing error', () => { assert.throws( - () => parseSignatureError('supported_algorithms=("ed25519")'), + () => parseSignatureError('required_input=("@method")'), /missing error member/, ) }) @@ -108,10 +108,8 @@ test('Signature-Error: parse throws on invalid error code', () => { test('Signature-Error: roundtrip all error types', () => { const errors: SignatureError[] = [ - { - error: 'unsupported_algorithm', - supported_algorithms: ['ed25519'], - }, + { error: 'unsupported_algorithm' }, + { error: 'unsupported_scheme' }, { error: 'invalid_signature' }, { error: 'invalid_input', diff --git a/httpsig/tests/test-signature-key-validation.ts b/httpsig/tests/test-signature-key-validation.ts index cc81925..f2cdc98 100644 --- a/httpsig/tests/test-signature-key-validation.ts +++ b/httpsig/tests/test-signature-key-validation.ts @@ -112,7 +112,8 @@ test('Validation: Label mismatch - no matching Signature-Input', async () => { authority: 'api.example.com', path: '/data', headers: { - 'signature-key': 'sig1=hwk;kty="OKP";crv="Ed25519";x="test"', + 'signature-key': + 'sig1=hwk;alg="Ed25519";kty="OKP";crv="Ed25519";x="test"', 'signature-input': 'sig2=("@method" "@authority" "@path");created=1234567890', // Different label signature: 'sig1=:dGVzdA==:', @@ -138,14 +139,14 @@ test('Validation: Label mismatch - no matching Signature', async () => { authority: 'api.example.com', path: '/data', headers: { - 'signature-key': 'sig1=hwk;kty="OKP";crv="Ed25519";x="test"', + 'signature-key': + 'sig1=hwk;alg="Ed25519";kty="OKP";crv="Ed25519";x="test"', 'signature-input': 'sig1=("@method" "@authority" "@path" "signature-key");created=1234567890', signature: 'sig2=:dGVzdA==:', // Different label }, }, { - strictAAuth: false, // Disable AAuth check to test label mismatch specifically maxClockSkew: 999999999, // Allow any timestamp to avoid clock skew errors }, ) @@ -184,69 +185,40 @@ test('Validation: Missing scheme (invalid format)', async () => { }) /** - * Test: signature-key not in covered components (AAuth violation) + * Test: signature-key not in covered components + * + * Covering `signature-key` is a requirement of the specification, not a + * profile choice: an uncovered Signature-Key header can be substituted by an + * attacker without invalidating the signature, which is the scheme- and + * identity-substitution attack. There is no option to disable this. */ -test('Validation: signature-key not in covered components (strictAAuth=true)', async () => { - const result = await verify( - { - method: 'GET', - authority: 'api.example.com', - path: '/data', - headers: { - 'signature-key': 'sig=hwk;kty="OKP";crv="Ed25519";x="test"', - // Note: signature-key is NOT in the covered components list - 'signature-input': - 'sig=("@method" "@authority" "@path");created=1234567890', - signature: 'sig=:dGVzdA==:', - }, - }, - { - strictAAuth: true, // Enforce AAuth profile +test('Validation: signature-key not in covered components is always rejected', async () => { + const result = await verify({ + method: 'GET', + authority: 'api.example.com', + path: '/data', + headers: { + 'signature-key': + 'sig=hwk;alg="Ed25519";kty="OKP";crv="Ed25519";x="test"', + // Note: signature-key is NOT in the covered components list + 'signature-input': + 'sig=("@method" "@authority" "@path");created=1234567890', + signature: 'sig=:dGVzdA==:', }, - ) + }) - console.log('\nSignature-key not covered (strict AAuth) test:') + console.log('\nSignature-key not covered test:') console.log(' Verified:', result.verified) console.log(' Error:', result.error) assert.strictEqual(result.verified, false) - assert.ok(result.error?.includes('AAuth profile violation')) + assert.strictEqual(result.signatureError?.error, 'invalid_input') assert.ok( - result.error?.includes('signature-key must be in covered components'), - ) -}) - -/** - * Test: signature-key not in covered components bypasses AAuth check when strictAAuth=false - */ -test('Validation: signature-key not covered bypasses AAuth check with strictAAuth=false', async () => { - const result = await verify( - { - method: 'GET', - authority: 'api.example.com', - path: '/data', - headers: { - 'signature-key': 'sig=hwk;kty="OKP";crv="Ed25519";x="test"', - // Note: signature-key is NOT in covered components - 'signature-input': - 'sig=("@method" "@authority" "@path");created=1234567890', - signature: 'sig=:dGVzdA==:', - }, - }, - { - strictAAuth: false, // Disable AAuth profile enforcement - }, + result.signatureError?.required_input?.includes('signature-key'), + 'Should name signature-key as a required covered component', ) - - console.log('\nSignature-key not covered (strictAAuth=false) test:') - console.log(' Verified:', result.verified) - console.log(' Error:', result.error) - - // Should NOT fail due to AAuth violation (though it will fail for other reasons like invalid signature) - assert.strictEqual(result.verified, false, 'Signature should be invalid') assert.ok( - !result.error?.includes('AAuth profile violation'), - 'Should not mention AAuth violation when strictAAuth=false', + result.error?.includes('signature-key must be a covered component'), ) }) @@ -264,10 +236,7 @@ test('Signature-Key validation: Summary', () => { console.log('✓ Label mismatch with Signature-Input is rejected') console.log('✓ Label mismatch with Signature is rejected') console.log('✓ Missing scheme parameter is rejected') - console.log('✓ signature-key not covered is rejected (strictAAuth=true)') - console.log( - '✓ signature-key not covered bypasses AAuth check (strictAAuth=false)', - ) + console.log('✓ signature-key not covered is always rejected') console.log( '\nAll RFC 8941 Dictionary format validations working correctly!', ) diff --git a/httpsig/tests/test-validate-jwk-errors.ts b/httpsig/tests/test-validate-jwk-errors.ts index 36d539d..c7cf834 100644 --- a/httpsig/tests/test-validate-jwk-errors.ts +++ b/httpsig/tests/test-validate-jwk-errors.ts @@ -1,13 +1,14 @@ /** - * Explicit test showing validateJwk errors propagate through fetch() + * Explicit test showing key validation errors propagate through fetch() */ import { test } from 'node:test' import assert from 'node:assert' import { fetch } from '../src/index.js' +import { SignatureVerificationError } from '../src/errors.js' -test('fetch() propagates validateJwk errors - missing kty', async () => { - const invalidKey = { x: 'test' } as JsonWebKey +test('fetch() propagates key errors - missing kty', async () => { + const invalidKey = { x: 'test', alg: 'Ed25519' } as JsonWebKey try { await fetch('https://api.example.com/data', { @@ -17,18 +18,24 @@ test('fetch() propagates validateJwk errors - missing kty', async () => { }) assert.fail('Should have thrown an error') } catch (error) { - assert.ok(error instanceof Error, 'Should be an Error instance') + assert.ok( + error instanceof SignatureVerificationError, + 'Should be a SignatureVerificationError', + ) + assert.strictEqual(error.code, 'invalid_key') assert.match( error.message, - /JWK missing required field: kty/, + /JWK missing required member: kty/, 'Error message should mention missing kty', ) console.log('✓ Error properly propagated:', error.message) } }) -test('fetch() propagates validateJwk errors - unsupported type', async () => { - const invalidKey = { kty: 'UNKNOWN' } as JsonWebKey +test('fetch() propagates key errors - missing alg', async () => { + // alg is REQUIRED: the algorithm comes from the key and is not derived + // from kty and crv. + const invalidKey = { kty: 'OKP', crv: 'Ed25519', x: 'test' } as JsonWebKey try { await fetch('https://api.example.com/data', { @@ -38,12 +45,132 @@ test('fetch() propagates validateJwk errors - unsupported type', async () => { }) assert.fail('Should have thrown an error') } catch (error) { - assert.ok(error instanceof Error, 'Should be an Error instance') - assert.match( - error.message, - /Unsupported key type/, - 'Error message should mention unsupported type', - ) + assert.ok(error instanceof SignatureVerificationError) + assert.strictEqual(error.code, 'invalid_key') + assert.match(error.message, /missing required member: alg/) console.log('✓ Error properly propagated:', error.message) } }) + +test('fetch() propagates key errors - unsupported algorithm', async () => { + const invalidKey = { + kty: 'UNKNOWN', + alg: 'NOT-AN-ALGORITHM', + } as JsonWebKey + + try { + await fetch('https://api.example.com/data', { + signingKey: invalidKey, + signatureKey: { type: 'hwk' }, + dryRun: true, + }) + assert.fail('Should have thrown an error') + } catch (error) { + assert.ok(error instanceof SignatureVerificationError) + assert.strictEqual(error.code, 'unsupported_algorithm') + console.log('✓ Error properly propagated:', error.message) + } +}) + +test('fetch() propagates key errors - RSA key missing e', async () => { + // RSA is supported as of 2.0, but alg must name both padding and hash. + const invalidKey = { kty: 'RSA', alg: 'PS256', n: 'test' } as JsonWebKey + + await assert.rejects( + fetch('https://api.example.com/data', { + signingKey: invalidKey, + signatureKey: { type: 'hwk' }, + dryRun: true, + }), + /RSA JWK missing required member: e/, + ) +}) + +test('fetch() rejects a key whose kty disagrees with its alg', async () => { + const invalidKey = { + kty: 'RSA', + alg: 'ES256', + n: 'test', + e: 'AQAB', + } as JsonWebKey + + await assert.rejects( + fetch('https://api.example.com/data', { + signingKey: invalidKey, + signatureKey: { type: 'hwk' }, + dryRun: true, + }), + /inconsistent with alg "ES256"/, + ) +}) + +test('fetch() rejects a key whose crv disagrees with its alg', async () => { + const invalidKey = { + kty: 'EC', + crv: 'P-384', + alg: 'ES256', + x: 'test', + y: 'test', + } as JsonWebKey + + await assert.rejects( + fetch('https://api.example.com/data', { + signingKey: invalidKey, + signatureKey: { type: 'hwk' }, + dryRun: true, + }), + /crv "P-384" is inconsistent with alg "ES256"/, + ) +}) + +test('fetch() rejects the polymorphic EdDSA identifier', async () => { + const invalidKey = { + kty: 'OKP', + crv: 'Ed25519', + alg: 'EdDSA', + x: 'test', + } as JsonWebKey + + await assert.rejects( + fetch('https://api.example.com/data', { + signingKey: invalidKey, + signatureKey: { type: 'hwk' }, + dryRun: true, + }), + /Polymorphic algorithm identifier "EdDSA" is not permitted/, + ) +}) + +test('fetch() rejects symmetric key material', async () => { + const invalidKey = { kty: 'oct', alg: 'HS256', k: 'secret' } as JsonWebKey + + await assert.rejects( + fetch('https://api.example.com/data', { + signingKey: invalidKey, + signatureKey: { type: 'hwk' }, + dryRun: true, + }), + /Symmetric keys are not permitted/, + ) +}) + +test('fetch() declines ML-DSA as unsupported rather than invalid', async () => { + const mldsaKey = { + kty: 'AKP', + alg: 'ML-DSA-44', + pub: 'test', + } as unknown as JsonWebKey + + try { + await fetch('https://api.example.com/data', { + signingKey: mldsaKey, + signatureKey: { type: 'hwk' }, + dryRun: true, + }) + assert.fail('Should have thrown an error') + } catch (error) { + assert.ok(error instanceof SignatureVerificationError) + // Absence of support is a reason to decline, not a parsing failure. + assert.strictEqual(error.code, 'unsupported_algorithm') + } +}) diff --git a/package-lock.json b/package-lock.json index 4d6cdb2..d9d4f11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -438,6 +438,15 @@ "node": ">=20" } }, + "email-verification/node_modules/@hellocoop/httpsig": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@hellocoop/httpsig/-/httpsig-1.7.1.tgz", + "integrity": "sha512-qsqpq2GLwk5etYcuG093ZJNpEYJRXrIEIAyXHPQViIAe3YPCdrbB06AcFQLLCp1ornIYF4EJNQ282z+SR24Yvw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "email-verification/node_modules/@types/node": { "version": "20.19.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.11.tgz", @@ -546,7 +555,7 @@ }, "httpsig": { "name": "@hellocoop/httpsig", - "version": "1.7.1", + "version": "2.0.0-alpha.0", "license": "MIT", "devDependencies": { "@misskey-dev/node-http-message-signatures": "^0.0.10", From db93dcc458eb6a539b2486c0c9b3669b0943b0cc Mon Sep 17 00:00:00 2001 From: dickhardt Date: Mon, 3 Aug 2026 18:30:09 +0100 Subject: [PATCH 2/6] Ignore the alg signature parameter, and prove it RFC 9421 Section 1.4 gives three ways to establish the signature algorithm; Section 3.3.7 develops the second for JOSE algorithms and states that "the explicit alg signature parameter is not used at all when using JOSE signing algorithms". This package was already on that path -- fetch() never emitted alg and verify() took the algorithm from the key -- but neither was stated as deliberate, and nothing proved it. State it in both places and cover it with tests. The distinction that matters is between ignoring the parameter and discarding it. alg lives inside @signature-params, which is covered by the signature, so it must still be reproduced verbatim when the base is reconstructed; dropping it would change the base and fail an otherwise valid signature. Ignoring it means only that it does not select the algorithm. The tests sign requests by hand so the covered parameters can include an alg the library would never emit: one declaring ed25519, one declaring rsa-pss-sha512 against an Ed25519 key, and one declaring the symmetric hmac-sha256. All three verify, and the algorithm used is the key's. A verifier that honoured the parameter would attempt the wrong operation on the second and fail. A final test signs with one key and presents another to confirm the suite can still produce a failure, so the others cannot pass vacuously. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LXExbWHeem2SNb7tZ2LGnc --- httpsig/MIGRATING-2.0.md | 23 +++ httpsig/src/utils/signature.ts | 7 + httpsig/src/verify.ts | 12 +- httpsig/tests/test-signature-input-alg.ts | 209 ++++++++++++++++++++++ 4 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 httpsig/tests/test-signature-input-alg.ts diff --git a/httpsig/MIGRATING-2.0.md b/httpsig/MIGRATING-2.0.md index db3e7f3..384a70d 100644 --- a/httpsig/MIGRATING-2.0.md +++ b/httpsig/MIGRATING-2.0.md @@ -51,6 +51,29 @@ nothing. `unsupported_algorithm` rather than failing as malformed. WebCrypto cannot implement them. +## The `alg` signature parameter is not used + +RFC 9421 Section 1.4 gives three ways to establish the algorithm — state it in +the `alg` signature parameter, derive it from the key material, or agree it out +of band. This package takes the second, which Section 3.3.7 develops for JOSE +signing algorithms: _"the explicit `alg` signature parameter is not used at all +when using JOSE signing algorithms."_ + +So `fetch()` never emits `alg` in `Signature-Input`, and `verify()` ignores one +if a signer sends it. A signer that declares a misleading `alg` does not change +which operation the verifier performs — the key decides. + +Ignoring is not the same as discarding: `alg` lives inside +`@signature-params`, which is covered by the signature, so it is still +reproduced verbatim when the signature base is reconstructed. Dropping it would +change the base and fail verification. + +Nothing changes for callers — 1.x behaved this way too — but it is now +guaranteed and tested rather than incidental. + +Note this is only the **Signature-Input** `alg`. The `alg` member of a JWK is +required (above), and `Accept-Signature`'s own `alg` parameter is unaffected. + ## `hwk` carries `alg` and must not carry `kid` The `hwk` scheme now emits and requires an `alg` parameter. In `-06` it was diff --git a/httpsig/src/utils/signature.ts b/httpsig/src/utils/signature.ts index ef77b95..a4143e9 100644 --- a/httpsig/src/utils/signature.ts +++ b/httpsig/src/utils/signature.ts @@ -35,6 +35,13 @@ export function generateSignatureBase( /** * Generate Signature-Input header value + * + * The `alg` signature parameter is deliberately never emitted. The algorithm + * is signaled by the key, per the JOSE path of RFC 9421 Section 3.3.7, which + * states that "the explicit alg signature parameter is not used at all when + * using JOSE signing algorithms". Emitting it would create a second source of + * truth in a namespace that does not correspond to the JWK `alg` anyway -- + * JWA values are not registered in the HTTP Signature Algorithms registry. */ export function generateSignatureInputHeader( label: string, diff --git a/httpsig/src/verify.ts b/httpsig/src/verify.ts index 9d8e3f8..2e9896e 100644 --- a/httpsig/src/verify.ts +++ b/httpsig/src/verify.ts @@ -602,7 +602,17 @@ export async function verify( componentValues.set(component, value) } - // Add @signature-params + // Add @signature-params. + // + // Every parameter the signer sent is reproduced verbatim, including an + // `alg` parameter if one is present. @signature-params is covered by + // the signature, so dropping a parameter here would change the + // signature base and fail verification. + // + // Reproducing `alg` is not the same as honouring it: the algorithm is + // taken from the key material only (see getAlgorithmFromJwk below), + // per RFC 9421 Section 3.3.7. A signer that declares a misleading + // `alg` does not change which operation the verifier performs. const componentList = components.map((c) => `"${c}"`).join(' ') const paramPairs = Object.entries(params) .map(([key, value]) => { diff --git a/httpsig/tests/test-signature-input-alg.ts b/httpsig/tests/test-signature-input-alg.ts new file mode 100644 index 0000000..a31dd53 --- /dev/null +++ b/httpsig/tests/test-signature-input-alg.ts @@ -0,0 +1,209 @@ +/** + * The `alg` signature parameter is not used to determine the algorithm + * + * RFC 9421 Section 1.4 gives three ways to establish the signature algorithm: + * state it in the `alg` signature parameter, derive it from the key material, + * or agree it out of band. This implementation takes the second, which Section + * 3.3.7 develops for JOSE signing algorithms -- "the explicit alg signature + * parameter is not used at all when using JOSE signing algorithms". + * + * So: signers never emit `alg`, and verifiers ignore it if a signer sends one. + * Ignoring it is not the same as dropping it -- `alg` lives inside + * @signature-params, which is covered by the signature, so it must still be + * reproduced verbatim in the signature base. + */ + +import { test } from 'node:test' +import assert from 'node:assert' +import { fetch, verify } from '../src/index.js' +import { generateSignatureBase } from '../src/utils/signature.js' +import { base64Encode } from '../src/utils/base64.js' + +async function generateEd25519KeyPair() { + const keyPair = (await crypto.subtle.generateKey( + { name: 'Ed25519' }, + true, + ['sign', 'verify'], + )) as CryptoKeyPair + + const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey) + const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey) + + privateJwk.alg = 'Ed25519' + publicJwk.alg = 'Ed25519' + + return { privateJwk, publicJwk, privateKey: keyPair.privateKey } +} + +/** + * Sign a request by hand so the covered signature parameters can include an + * `alg` the library would never emit itself. + */ +async function signWithParams( + privateKey: CryptoKey, + publicJwk: JsonWebKey, + signatureParamsSuffix: string, +) { + const method = 'GET' + const authority = 'api.example.com' + const path = '/data' + const components = ['@method', '@authority', '@path', 'signature-key'] + + const signatureKey = + `sig=hwk;alg="${publicJwk.alg}";kty="${publicJwk.kty}";` + + `crv="${publicJwk.crv}";x="${publicJwk.x}"` + + const componentList = components.map((c) => `"${c}"`).join(' ') + const signatureParams = `(${componentList});${signatureParamsSuffix}` + + const values = new Map([ + ['@method', method], + ['@authority', authority], + ['@path', path], + ['signature-key', signatureKey], + ['@signature-params', signatureParams], + ]) + + const base = generateSignatureBase( + [...components, '@signature-params'], + values, + ) + + const signature = new Uint8Array( + await crypto.subtle.sign( + { name: 'Ed25519' }, + privateKey, + new TextEncoder().encode(base), + ), + ) + + return { + request: { + method, + authority, + path, + headers: { + 'signature-key': signatureKey, + 'signature-input': `sig=${signatureParams}`, + signature: `sig=:${base64Encode(signature)}:`, + }, + }, + } +} + +test('Signature-Input: fetch() never emits an alg parameter', async () => { + const { privateJwk } = await generateEd25519KeyPair() + + const result = (await fetch('https://api.example.com/data', { + method: 'GET', + signingKey: privateJwk, + signatureKey: { type: 'hwk' }, + dryRun: true, + })) as { headers: Headers } + + const signatureInput = result.headers.get('signature-input') + assert.ok(signatureInput, 'Signature-Input should be present') + assert.ok( + !/;alg=/.test(signatureInput), + `Signature-Input must not carry an alg parameter, got: ${signatureInput}`, + ) +}) + +test('Signature-Input: a signature carrying alg still verifies', async () => { + // Proves the parameter is reproduced in the signature base. If the + // verifier dropped it, the base would differ and this would fail. + const { publicJwk, privateKey } = await generateEd25519KeyPair() + const created = Math.floor(Date.now() / 1000) + + const { request } = await signWithParams( + privateKey, + publicJwk, + `created=${created};alg="ed25519"`, + ) + + const result = await verify(request) + + assert.strictEqual( + result.verified, + true, + `Should verify with alg present: ${result.error}`, + ) +}) + +test('Signature-Input: a misleading alg does not change the algorithm used', async () => { + // The key is Ed25519. The signer declares rsa-pss-sha512. A verifier that + // honoured the parameter would attempt the wrong operation and fail; one + // that takes the algorithm from the key verifies successfully. + const { publicJwk, privateKey } = await generateEd25519KeyPair() + const created = Math.floor(Date.now() / 1000) + + const { request } = await signWithParams( + privateKey, + publicJwk, + `created=${created};alg="rsa-pss-sha512"`, + ) + + const result = await verify(request) + + assert.strictEqual( + result.verified, + true, + `Misleading alg must be ignored, not honoured: ${result.error}`, + ) + assert.strictEqual( + result.publicKey.alg, + 'Ed25519', + 'Algorithm must come from the key, not the signature parameter', + ) +}) + +test('Signature-Input: an alg naming a banned algorithm is still ignored', async () => { + // hmac-sha256 is registered for HTTP Message Signatures but is symmetric + // and forbidden here. Since the parameter is ignored entirely, it neither + // selects an algorithm nor triggers the symmetric-key rejection -- the + // key is what is checked, and the key is fine. + const { publicJwk, privateKey } = await generateEd25519KeyPair() + const created = Math.floor(Date.now() / 1000) + + const { request } = await signWithParams( + privateKey, + publicJwk, + `created=${created};alg="hmac-sha256"`, + ) + + const result = await verify(request) + + assert.strictEqual( + result.verified, + true, + `Should verify from the key regardless of the alg parameter: ${result.error}`, + ) + assert.strictEqual(result.publicKey.alg, 'Ed25519') +}) + +test('Signature-Input: verification still fails when the key itself is wrong', async () => { + // Guard against the tests above passing for the wrong reason. Sign with + // one key, present another; ignoring alg must not mean ignoring the key. + const signer = await generateEd25519KeyPair() + const other = await generateEd25519KeyPair() + const created = Math.floor(Date.now() / 1000) + + const { request } = await signWithParams( + signer.privateKey, + signer.publicJwk, + `created=${created};alg="ed25519"`, + ) + + // Swap in a different key without re-signing. + request.headers['signature-key'] = + `sig=hwk;alg="Ed25519";kty="${other.publicJwk.kty}";` + + `crv="${other.publicJwk.crv}";x="${other.publicJwk.x}"` + + const result = await verify(request) + + assert.strictEqual( + result.verified, + false, + 'A substituted key must not verify', + ) +}) From d8c929821d4344841de087f8eeaada4253b2b2a4 Mon Sep 17 00:00:00 2001 From: dickhardt Date: Mon, 3 Aug 2026 19:22:11 +0100 Subject: [PATCH 3/6] Correct the tracked draft revision: -08, not -07 -07 was published to the datatracker on 2026-07-05 and was editorial -- a paragraph in the Introduction citing AAuth and Email Verification. Everything this branch implements lands in -08. The hwk alg parameter in particular was forbidden in -06 and remained forbidden in -07, so the break this package straddles is -07 to -08. Every reference saying otherwise was off by one revision. Bumped to 2.0.0-alpha.1 rather than correcting in place, because 2.0.0-alpha.0 is published and its tarball embeds the wrong claim in README.md and MIGRATING-2.0.md. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LXExbWHeem2SNb7tZ2LGnc --- httpsig/MIGRATING-2.0.md | 6 +++--- httpsig/README.md | 4 ++-- httpsig/package.json | 2 +- httpsig/src/types.ts | 4 ++-- httpsig/src/utils/crypto.ts | 2 +- httpsig/src/utils/signature.ts | 4 ++-- package-lock.json | 2 +- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/httpsig/MIGRATING-2.0.md b/httpsig/MIGRATING-2.0.md index 384a70d..2a362d6 100644 --- a/httpsig/MIGRATING-2.0.md +++ b/httpsig/MIGRATING-2.0.md @@ -1,6 +1,6 @@ # Migrating to 2.0 -2.0 tracks `draft-hardt-httpbis-signature-key-07`, which is not backward +2.0 tracks `draft-hardt-httpbis-signature-key-08`, which is not backward compatible with the `-05`-era protocol that 1.x implements. A 1.x client and a 2.x verifier will not interoperate, in either direction. There is no version negotiation in the protocol, so both ends have to move together. @@ -76,8 +76,8 @@ required (above), and `Accept-Signature`'s own `alg` parameter is unaffected. ## `hwk` carries `alg` and must not carry `kid` -The `hwk` scheme now emits and requires an `alg` parameter. In `-06` it was -forbidden, so a header serialized by 1.x is rejected by 2.x and vice versa. +The `hwk` scheme now emits and requires an `alg` parameter. It was forbidden +through `-07`, so a header serialized by 1.x is rejected by 2.x and vice versa. ``` # 1.x diff --git a/httpsig/README.md b/httpsig/README.md index 87feeab..d0914ad 100644 --- a/httpsig/README.md +++ b/httpsig/README.md @@ -10,10 +10,10 @@ This package implements [RFC 9421 HTTP Message Signatures](https://datatracker.i | Package line | Implements | npm dist-tag | | ------------ | -------------------------------------- | ------------ | -| `2.x` | `draft-hardt-httpbis-signature-key-07` | `alpha` | +| `2.x` | `draft-hardt-httpbis-signature-key-08` | `alpha` | | `1.x` | `draft-hardt-httpbis-signature-key-05` | `latest` | -The draft is not yet adopted and `-07` is not backward compatible with `-05`. +The draft is not yet adopted and `-08` is not backward compatible with `-05`. The `2.x` line tracks it and is published as a prerelease; `npm install @hellocoop/httpsig` continues to give you `1.x` until `2.0.0` is released. See [MIGRATING-2.0.md](./MIGRATING-2.0.md) for what changed. diff --git a/httpsig/package.json b/httpsig/package.json index 584dd45..741fb48 100644 --- a/httpsig/package.json +++ b/httpsig/package.json @@ -1,6 +1,6 @@ { "name": "@hellocoop/httpsig", - "version": "2.0.0-alpha.0", + "version": "2.0.0-alpha.1", "description": "HTTP Message Signatures (RFC 9421) with Signature-Key header support", "repository": { "type": "git", diff --git a/httpsig/src/types.ts b/httpsig/src/types.ts index 86da5b7..44106bc 100644 --- a/httpsig/src/types.ts +++ b/httpsig/src/types.ts @@ -208,7 +208,7 @@ export type SignatureErrorCode = /** * Parsed Signature-Error header * - * The supported_algorithms member was removed in -07; a server states what + * The supported_algorithms member was removed in -08; a server states what * would have worked in Accept-Signature-Alg instead. */ export interface SignatureError { @@ -231,7 +231,7 @@ export type SignatureKeyScheme = /** * Parsed Accept-Signature header parameters * - * The sigkey parameter was removed in -07. Use the Accept-Signature-Scheme + * The sigkey parameter was removed in -08. Use the Accept-Signature-Scheme * and Accept-Signature-Alg header fields. */ export interface AcceptSignatureParams { diff --git a/httpsig/src/utils/crypto.ts b/httpsig/src/utils/crypto.ts index 8aa1e2f..ec62d62 100644 --- a/httpsig/src/utils/crypto.ts +++ b/httpsig/src/utils/crypto.ts @@ -1,7 +1,7 @@ /** * Cryptographic utilities for HTTP Message Signatures * - * Algorithm determination follows draft-hardt-httpbis-signature-key-07, + * Algorithm determination follows draft-hardt-httpbis-signature-key-08, * Algorithm Determination: the signature algorithm is taken from the JWK `alg` * member, which must be a fully-specified identifier (RFC 9864). It is never * derived from `kty` and `crv` -- those underdetermine the algorithm for RSA diff --git a/httpsig/src/utils/signature.ts b/httpsig/src/utils/signature.ts index a4143e9..480ab0b 100644 --- a/httpsig/src/utils/signature.ts +++ b/httpsig/src/utils/signature.ts @@ -357,7 +357,7 @@ export function parseSignatureKey(header: string): ParsedSignatureKey[] { * Generate Signature-Error header value as RFC 8941 Dictionary * Format: error=[, required_input=("comp1" "comp2")] * - * The supported_algorithms member was removed in -07. A server states what + * The supported_algorithms member was removed in -08. A server states what * would have worked in the Accept-Signature-Alg and Accept-Signature-Scheme * header fields, which work on a challenge and on an error alike. */ @@ -482,7 +482,7 @@ export function parseAcceptSignatureAlg(header: string): string[] { * Generate Accept-Signature header value * Format: label=("comp1" "comp2")[;alg="algo"][;tag="tag"] * - * The sigkey parameter was removed in -07. A parameter value is a bare Item + * The sigkey parameter was removed in -08. A parameter value is a bare Item * and cannot be a list, so sigkey could name only one scheme. Use * Accept-Signature-Scheme and Accept-Signature-Alg instead. */ diff --git a/package-lock.json b/package-lock.json index d9d4f11..0b34d2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -555,7 +555,7 @@ }, "httpsig": { "name": "@hellocoop/httpsig", - "version": "2.0.0-alpha.0", + "version": "2.0.0-alpha.1", "license": "MIT", "devDependencies": { "@misskey-dev/node-http-message-signatures": "^0.0.10", From 48d00152e57f8b29ddc446064b6e77ee2b995836 Mon Sep 17 00:00:00 2001 From: dickhardt Date: Tue, 4 Aug 2026 16:17:54 +0100 Subject: [PATCH 4/6] Add supportedAlgorithms, and test JWKS tolerance A verifier could not say which algorithms it accepts. It implemented eleven and accepted all of them, so a deployment wanting Ed25519 only, or wanting to refuse RSASSA-PKCS1-v1_5 while still implementing it, had no way to express that. An unknown alg happened to produce unsupported_algorithm by falling out of the lookup table rather than by policy, and nothing ever reported what would have worked. verify() now takes supportedAlgorithms, defaulting to everything the library implements, exported as SUPPORTED_ALGORITHMS. A key outside the set is rejected before any signature verification -- there is no point verifying with an algorithm that will be declined either way -- and the set comes back on the result as acceptSignatureAlg for the caller to send in an Accept-Signature-Alg response header. It travels on the result rather than inside SignatureError deliberately: -08 removed the supported_algorithms member from Signature-Error, and what a verifier accepts now belongs in Accept-Signature-Alg. A test asserts it does not appear in the error. Also covered the rule that a verifier resolving a key from a JWKS must select by kid without requiring any other member to be usable. The library already behaved this way -- keys.find() only reads kid -- but nothing proved it, and it is one refactor away from being lost by someone validating inside the predicate. Tests place an unparseable ML-DSA key before and after the usable one, and confirm that selecting the ML-DSA key itself declines cleanly rather than crashing. Each JWKS test uses its own issuer origin. The JWKS cache is module-level and keyed by URL, so sharing one origin serves a previous test's keys. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LXExbWHeem2SNb7tZ2LGnc --- httpsig/MIGRATING-2.0.md | 43 ++++ httpsig/src/errors.ts | 18 +- httpsig/src/index.ts | 6 +- httpsig/src/types.ts | 20 ++ httpsig/src/utils/crypto.ts | 8 + httpsig/src/verify.ts | 25 +- httpsig/tests/test-supported-algorithms.ts | 263 +++++++++++++++++++++ 7 files changed, 379 insertions(+), 4 deletions(-) create mode 100644 httpsig/tests/test-supported-algorithms.ts diff --git a/httpsig/MIGRATING-2.0.md b/httpsig/MIGRATING-2.0.md index 2a362d6..3c633b2 100644 --- a/httpsig/MIGRATING-2.0.md +++ b/httpsig/MIGRATING-2.0.md @@ -74,6 +74,49 @@ guaranteed and tested rather than incidental. Note this is only the **Signature-Input** `alg`. The `alg` member of a JWK is required (above), and `Accept-Signature`'s own `alg` parameter is unaffected. +## New: `supportedAlgorithms` + +A verifier now declares which algorithms it accepts. A key whose `alg` falls +outside that set is rejected with `unsupported_algorithm`, and the set comes +back on the result so you can send it in an `Accept-Signature-Alg` response +header. + +```js +const result = await verify(request, { + supportedAlgorithms: ['Ed25519', 'ES256'], +}) + +if (result.signatureError?.error === 'unsupported_algorithm') { + res.setHeader( + 'Accept-Signature-Alg', + generateAcceptSignatureAlgHeader(result.acceptSignatureAlg), + ) +} +``` + +Defaults to every algorithm the library implements, exported as +`SUPPORTED_ALGORITHMS`, so omitting it changes nothing. Narrow it to decline an +algorithm by policy — refusing RSASSA-PKCS1-v1_5 while still implementing it, +say. Declining by policy and not implementing at all report the same code; +the difference is only which set you advertise. + +Note the accepted set travels on the **result**, not inside `SignatureError`. +The `supported_algorithms` member of `Signature-Error` was removed in `-08`. + +## Unusable keys elsewhere in a JWKS are ignored + +A verifier resolving a key from a JWKS selects the member matching `kid` +without requiring any other member to be usable, and does not fail because +some other entry names a key type it cannot parse. + +This is what lets a signer introduce a new algorithm at all: an issuer adding +a post-quantum key alongside a classical one would otherwise break every +verifier that does not implement the new type, including verifiers only ever +going to use the classical key. + +Behaviour is unchanged from 1.x — the library already selected by `kid` +without parsing the rest — but it is now specified and tested. + ## `hwk` carries `alg` and must not carry `kid` The `hwk` scheme now emits and requires an `alg` parameter. It was forbidden diff --git a/httpsig/src/errors.ts b/httpsig/src/errors.ts index 945a521..62e5819 100644 --- a/httpsig/src/errors.ts +++ b/httpsig/src/errors.ts @@ -10,12 +10,19 @@ import { SignatureErrorCode } from './types.js' export interface SignatureVerificationErrorOptions { /** Covered components the server requires, for invalid_input. */ requiredInput?: string[] + /** + * Algorithms the verifier accepts, for unsupported_algorithm. Sent as + * Accept-Signature-Alg -- NOT as a Signature-Error member, since the + * supported_algorithms member was removed in -08. + */ + supportedAlgorithms?: string[] cause?: unknown } export class SignatureVerificationError extends Error { readonly code: SignatureErrorCode readonly requiredInput?: string[] + readonly supportedAlgorithms?: string[] constructor( code: SignatureErrorCode, @@ -26,6 +33,7 @@ export class SignatureVerificationError extends Error { this.name = 'SignatureVerificationError' this.code = code this.requiredInput = options.requiredInput + this.supportedAlgorithms = options.supportedAlgorithms } } @@ -34,11 +42,17 @@ export function invalidKey(message: string): SignatureVerificationError { return new SignatureVerificationError('invalid_key', message) } -/** The algorithm is well formed but this implementation cannot use it. */ +/** + * The algorithm is well formed but this verifier will not use it -- either + * unimplemented, or outside the accepted set. + */ export function unsupportedAlgorithm( message: string, + supportedAlgorithms?: string[], ): SignatureVerificationError { - return new SignatureVerificationError('unsupported_algorithm', message) + return new SignatureVerificationError('unsupported_algorithm', message, { + supportedAlgorithms, + }) } /** The Signature-Key scheme is not one this implementation understands. */ diff --git a/httpsig/src/index.ts b/httpsig/src/index.ts index 1f8d128..ae96f40 100644 --- a/httpsig/src/index.ts +++ b/httpsig/src/index.ts @@ -24,7 +24,11 @@ export { parseAcceptSignatureAlg, } from './utils/signature.js' -export { generateKeyPair, determineAlgorithm } from './utils/crypto.js' +export { + generateKeyPair, + determineAlgorithm, + SUPPORTED_ALGORITHMS, +} from './utils/crypto.js' export type { GenerateKeyPairOptions, GeneratableAlgorithm, diff --git a/httpsig/src/types.ts b/httpsig/src/types.ts index 44106bc..d5e6f18 100644 --- a/httpsig/src/types.ts +++ b/httpsig/src/types.ts @@ -108,6 +108,18 @@ export interface VerifyOptions { // JWKS caching jwksCacheTtl?: number // JWKS cache TTL in ms (default: 3600000) + + /** + * The algorithms this verifier accepts. A key whose `alg` falls outside + * this set is rejected with `unsupported_algorithm`, and the set is + * reported back on the result as `acceptSignatureAlg` so the caller can + * send it in an Accept-Signature-Alg response header. + * + * Defaults to every algorithm this implementation can verify + * (SUPPORTED_ALGORITHMS). Narrow it to decline algorithms by policy -- + * for example to accept Ed25519 only, or to refuse RSASSA-PKCS1-v1_5. + */ + supportedAlgorithms?: SignatureAlgorithm[] } // Note: the strictAAuth option was removed in 2.0. Covering `signature-key` is @@ -153,6 +165,14 @@ export interface VerificationResult { // Structured error for Signature-Error response header signatureError?: SignatureError + + /** + * The algorithms this verifier accepts. Present when verification failed + * with `unsupported_algorithm`. Send it in an Accept-Signature-Alg + * response header -- not in Signature-Error, whose supported_algorithms + * member was removed in -08. + */ + acceptSignatureAlg?: string[] } export interface ParsedSignatureInput { diff --git a/httpsig/src/utils/crypto.ts b/httpsig/src/utils/crypto.ts index ec62d62..0f26f3e 100644 --- a/httpsig/src/utils/crypto.ts +++ b/httpsig/src/utils/crypto.ts @@ -83,6 +83,14 @@ export const FULLY_SPECIFIED_ALGORITHMS: Readonly< }, } +/** + * Every algorithm this implementation can verify. A deployment accepting a + * narrower set passes `supportedAlgorithms` to verify(). + */ +export const SUPPORTED_ALGORITHMS: readonly string[] = Object.freeze( + Object.keys(FULLY_SPECIFIED_ALGORITHMS), +) + /** * Identifiers that name a different signature algorithm depending on the key * they are used with. Deprecated by RFC 9864 and forbidden by the draft. diff --git a/httpsig/src/verify.ts b/httpsig/src/verify.ts index 2e9896e..ce94980 100644 --- a/httpsig/src/verify.ts +++ b/httpsig/src/verify.ts @@ -13,6 +13,7 @@ import { verify as cryptoVerify, getAlgorithmFromJwk, validateJwk, + SUPPORTED_ALGORITHMS, } from './utils/crypto.js' import { parseSignatureInput, @@ -28,6 +29,7 @@ import { invalidInput, invalidJwt, expiredJwt, + unsupportedAlgorithm, } from './errors.js' // JWKS cache. Bounded: the cache key is a URL derived from the request being @@ -400,8 +402,14 @@ export async function verify( const { maxClockSkew = 60, jwksCacheTtl = 3600000, // 1 hour + supportedAlgorithms, } = options + // The set this verifier accepts. Reported in Accept-Signature-Alg on an + // unsupported_algorithm rejection, so a client learns what would work. + const accepted: readonly string[] = + supportedAlgorithms ?? SUPPORTED_ALGORITHMS + try { // Normalize headers const headers = normalizeHeaders(request.headers) @@ -528,9 +536,20 @@ export async function verify( ) } - // Validate public key + // Validate public key. This determines the algorithm from the key's + // alg member and rejects a key that has none. validateJwk(publicJwk) + // The algorithm must be one this verifier accepts. Checked before any + // signature verification: there is no point verifying with an + // algorithm that will be declined either way. + if (!accepted.includes(publicJwk.alg as string)) { + throw unsupportedAlgorithm( + `Algorithm "${publicJwk.alg}" is not accepted by this verifier`, + [...accepted], + ) + } + // Parse Signature header const signatureHeader = headers.get('signature') if (!signatureHeader) { @@ -685,6 +704,10 @@ export async function verify( created: 0, error: errorMessage, signatureError: toSignatureError(error), + ...(error instanceof SignatureVerificationError && + error.supportedAlgorithms + ? { acceptSignatureAlg: error.supportedAlgorithms } + : {}), } } } diff --git a/httpsig/tests/test-supported-algorithms.ts b/httpsig/tests/test-supported-algorithms.ts new file mode 100644 index 0000000..739e506 --- /dev/null +++ b/httpsig/tests/test-supported-algorithms.ts @@ -0,0 +1,263 @@ +/** + * supportedAlgorithms, and tolerance of unusable keys in a JWKS + * + * A verifier rejects a key whose `alg` falls outside the set it accepts, and + * reports that set so the caller can send it in Accept-Signature-Alg. It does + * NOT go in Signature-Error -- the supported_algorithms member was removed + * in -08. + * + * Separately, a verifier resolving a key from a JWKS must select the member + * matching `kid` without requiring any other member to be usable. Without + * that, an issuer could never add a post-quantum key alongside a classical + * one: doing so would break every verifier that does not implement the new + * key type, including verifiers that were only going to use the classical key. + */ + +import { test } from 'node:test' +import assert from 'node:assert' +import { fetch, verify } from '../src/index.js' +import { SUPPORTED_ALGORITHMS } from '../src/utils/crypto.js' + +async function generateEd25519KeyPair() { + const keyPair = (await crypto.subtle.generateKey( + { name: 'Ed25519' }, + true, + ['sign', 'verify'], + )) as CryptoKeyPair + + const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey) + const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey) + + privateJwk.alg = 'Ed25519' + publicJwk.alg = 'Ed25519' + + return { privateJwk, publicJwk } +} + +/** Sign a request with hwk and return it in verify()'s request shape. */ +async function signedRequest(privateJwk: JsonWebKey) { + const result = (await fetch('https://api.example.com/data', { + method: 'GET', + signingKey: privateJwk, + signatureKey: { type: 'hwk' }, + dryRun: true, + })) as { headers: Headers } + + const headers: Record = {} + result.headers.forEach((v, k) => { + headers[k] = v + }) + + return { + method: 'GET', + authority: 'api.example.com', + path: '/data', + headers, + } +} + +test('supportedAlgorithms: defaults to everything the library implements', async () => { + const { privateJwk } = await generateEd25519KeyPair() + const request = await signedRequest(privateJwk) + + const result = await verify(request) + + assert.strictEqual(result.verified, true, result.error) + assert.ok(SUPPORTED_ALGORITHMS.includes('Ed25519')) +}) + +test('supportedAlgorithms: accepts a key inside the configured set', async () => { + const { privateJwk } = await generateEd25519KeyPair() + const request = await signedRequest(privateJwk) + + const result = await verify(request, { + supportedAlgorithms: ['Ed25519', 'ES256'], + }) + + assert.strictEqual(result.verified, true, result.error) +}) + +test('supportedAlgorithms: rejects a key outside the configured set', async () => { + // The key is a perfectly good Ed25519 key. This verifier declines Ed25519 + // by policy, which is a different thing from not implementing it. + const { privateJwk } = await generateEd25519KeyPair() + const request = await signedRequest(privateJwk) + + const result = await verify(request, { supportedAlgorithms: ['ES256'] }) + + assert.strictEqual(result.verified, false) + assert.strictEqual(result.signatureError?.error, 'unsupported_algorithm') +}) + +test('supportedAlgorithms: reports the accepted set for Accept-Signature-Alg', async () => { + const { privateJwk } = await generateEd25519KeyPair() + const request = await signedRequest(privateJwk) + + const result = await verify(request, { + supportedAlgorithms: ['ES256', 'ES384'], + }) + + assert.deepStrictEqual(result.acceptSignatureAlg, ['ES256', 'ES384']) +}) + +test('supportedAlgorithms: the accepted set is not a Signature-Error member', async () => { + // -08 removed supported_algorithms from Signature-Error. What the verifier + // accepts travels in Accept-Signature-Alg instead. + const { privateJwk } = await generateEd25519KeyPair() + const request = await signedRequest(privateJwk) + + const result = await verify(request, { supportedAlgorithms: ['ES256'] }) + + assert.ok( + !('supported_algorithms' in (result.signatureError ?? {})), + 'Signature-Error must not carry supported_algorithms', + ) +}) + +test('supportedAlgorithms: an empty set accepts nothing', async () => { + const { privateJwk } = await generateEd25519KeyPair() + const request = await signedRequest(privateJwk) + + const result = await verify(request, { supportedAlgorithms: [] }) + + assert.strictEqual(result.verified, false) + assert.strictEqual(result.signatureError?.error, 'unsupported_algorithm') +}) + +/** + * Mock a JWKS containing a key this implementation cannot parse alongside one + * it can, and select the usable one by kid. + */ +function setupMixedJwks( + usableKey: JsonWebKey, + order: 'first' | 'last', + issuer: string, +) { + const originalFetch = globalThis.fetch + + // An ML-DSA key. kty "AKP" (RFC 9964) is not implemented here, and the + // value is not even well formed -- the point is that it is never touched. + const mlDsaKey = { + kty: 'AKP', + alg: 'ML-DSA-44', + pub: 'not-real-key-material', + kid: 'pq-key', + use: 'sig', + } + const ed = { ...usableKey, kid: 'classical-key', use: 'sig' } + + const keys = order === 'first' ? [mlDsaKey, ed] : [ed, mlDsaKey] + + globalThis.fetch = (async (url: string | URL | Request) => { + const u = typeof url === 'string' ? url : url.toString() + if (u === `${issuer}/.well-known/test-metadata`) { + return new Response( + JSON.stringify({ + issuer, + jwks_uri: `${issuer}/jwks.json`, + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ) + } + if (u === `${issuer}/jwks.json`) { + return new Response(JSON.stringify({ keys }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + return new Response('not found', { status: 404 }) + }) as typeof globalThis.fetch + + return () => { + globalThis.fetch = originalFetch + } +} + +for (const order of ['first', 'last'] as const) { + test(`JWKS: an unimplemented key type listed ${order} does not prevent selecting a usable key`, async () => { + const { privateJwk, publicJwk } = await generateEd25519KeyPair() + const issuer = `https://issuer-${order}.example` + const restore = setupMixedJwks(publicJwk, order, issuer) + + try { + const signed = (await fetch('https://api.example.com/data', { + method: 'GET', + signingKey: privateJwk, + signatureKey: { + type: 'jwks_uri', + id: issuer, + kid: 'classical-key', + dwk: 'test-metadata', + }, + dryRun: true, + })) as { headers: Headers } + + const headers: Record = {} + signed.headers.forEach((v, k) => { + headers[k] = v + }) + + const result = await verify({ + method: 'GET', + authority: 'api.example.com', + path: '/data', + headers, + }) + + assert.strictEqual( + result.verified, + true, + `An ML-DSA key elsewhere in the JWKS must not prevent verification: ${result.error}`, + ) + assert.strictEqual(result.publicKey.alg, 'Ed25519') + } finally { + restore() + } + }) +} + +test('JWKS: selecting the unimplemented key itself is declined, not a crash', async () => { + // The complement of the rule above: when the kid does select the key this + // implementation cannot use, it declines cleanly. + const { privateJwk, publicJwk } = await generateEd25519KeyPair() + const issuer = 'https://issuer-decline.example' + const restore = setupMixedJwks(publicJwk, 'first', issuer) + + try { + const signed = (await fetch('https://api.example.com/data', { + method: 'GET', + signingKey: privateJwk, + signatureKey: { + type: 'jwks_uri', + id: issuer, + kid: 'pq-key', + dwk: 'test-metadata', + }, + dryRun: true, + })) as { headers: Headers } + + const headers: Record = {} + signed.headers.forEach((v, k) => { + headers[k] = v + }) + + const result = await verify({ + method: 'GET', + authority: 'api.example.com', + path: '/data', + headers, + }) + + assert.strictEqual(result.verified, false) + assert.strictEqual( + result.signatureError?.error, + 'unsupported_algorithm', + 'Absence of support is a reason to decline, not a parsing failure', + ) + } finally { + restore() + } +}) From 0436154b763406bc2af7d198bba3108af603e275 Mon Sep 17 00:00:00 2001 From: dickhardt Date: Wed, 5 Aug 2026 21:37:20 +0100 Subject: [PATCH 5/6] Verify the discovery metadata issuer draft-hardt-httpbis-signature-key-08 requires the metadata document at {id}/.well-known/{dwk} to carry an issuer member equal to the identity it was fetched under. This library followed jwks_uri without checking. Without the check, a document served at that location -- through misconfigured shared hosting, a subdomain takeover, or any other means -- can point jwks_uri at keys that do not belong to id, and the verifier attributes the request to the identity in the header. It is the same check RFC 8414 Section 3.3 requires of authorization server metadata. Reject a document with no issuer as issuer_missing, and one whose issuer differs from id as issuer_mismatch, both new error codes in -08. The comparison is byte equality as presented, with no normalization, so a trailing slash is a different identifier and is rejected; a test pins that. Two existing jwks_uri fixtures served metadata without an issuer and now fail, correctly. Added it to them. This is the only normative change in -08 the library had not tracked. The rest of the revision was already implemented on this branch before it merged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LXExbWHeem2SNb7tZ2LGnc --- httpsig/MIGRATING-2.0.md | 21 ++- httpsig/src/errors.ts | 14 ++ httpsig/src/types.ts | 2 + httpsig/src/utils/signature.ts | 2 + httpsig/src/verify.ts | 19 +++ httpsig/tests/test-issuer-verification.ts | 170 ++++++++++++++++++++++ httpsig/tests/test-jwks.ts | 4 + 7 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 httpsig/tests/test-issuer-verification.ts diff --git a/httpsig/MIGRATING-2.0.md b/httpsig/MIGRATING-2.0.md index 3c633b2..293564f 100644 --- a/httpsig/MIGRATING-2.0.md +++ b/httpsig/MIGRATING-2.0.md @@ -1,6 +1,7 @@ # Migrating to 2.0 -2.0 tracks `draft-hardt-httpbis-signature-key-08`, which is not backward +2.0 tracks `draft-hardt-httpbis-signature-key-08`, published to the IETF +datatracker on 2026-08-05, which is not backward compatible with the `-05`-era protocol that 1.x implements. A 1.x client and a 2.x verifier will not interoperate, in either direction. There is no version negotiation in the protocol, so both ends have to move together. @@ -103,6 +104,24 @@ the difference is only which set you advertise. Note the accepted set travels on the **result**, not inside `SignatureError`. The `supported_algorithms` member of `Signature-Error` was removed in `-08`. +## Discovery metadata must carry a matching `issuer` + +New in `-08`. The metadata document at `{id}/.well-known/{dwk}` must contain an +`issuer` member equal to `id`, compared by byte equality with no normalization +— a trailing slash is a different identifier. + +Two new error codes: `issuer_missing` and `issuer_mismatch`. + +This is the check RFC 8414 Section 3.3 requires of authorization server +metadata. Without it a document served under one identity — misconfigured +shared hosting, a subdomain takeover — could point `jwks_uri` at keys belonging +to someone else, and the verifier would attribute the request to the identity +in the header. 1.x followed `jwks_uri` without checking. + +Documents conforming to RFC 8414 or OpenID Connect Discovery already carry +`issuer`, so existing metadata is unaffected. A hand-rolled `.well-known` +document that omits it will now be rejected. + ## Unusable keys elsewhere in a JWKS are ignored A verifier resolving a key from a JWKS selects the member matching `kid` diff --git a/httpsig/src/errors.ts b/httpsig/src/errors.ts index 62e5819..ef5c7ae 100644 --- a/httpsig/src/errors.ts +++ b/httpsig/src/errors.ts @@ -65,6 +65,20 @@ export function invalidJwt(message: string): SignatureVerificationError { return new SignatureVerificationError('invalid_jwt', message) } +/** The discovery metadata document has no issuer member. */ +export function issuerMissing(message: string): SignatureVerificationError { + return new SignatureVerificationError('issuer_missing', message) +} + +/** + * The metadata document's issuer does not match the identity it was fetched + * under. Binds the document to that identity, so a document served under one + * origin cannot point jwks_uri at another's keys. + */ +export function issuerMismatch(message: string): SignatureVerificationError { + return new SignatureVerificationError('issuer_mismatch', message) +} + /** The assertion is well formed but no longer valid. */ export function expiredJwt(message: string): SignatureVerificationError { return new SignatureVerificationError('expired_jwt', message) diff --git a/httpsig/src/types.ts b/httpsig/src/types.ts index d5e6f18..a65c975 100644 --- a/httpsig/src/types.ts +++ b/httpsig/src/types.ts @@ -224,6 +224,8 @@ export type SignatureErrorCode = | 'unknown_key' | 'invalid_jwt' | 'expired_jwt' + | 'issuer_missing' + | 'issuer_mismatch' /** * Parsed Signature-Error header diff --git a/httpsig/src/utils/signature.ts b/httpsig/src/utils/signature.ts index 480ab0b..4e30442 100644 --- a/httpsig/src/utils/signature.ts +++ b/httpsig/src/utils/signature.ts @@ -399,6 +399,8 @@ export function parseSignatureError(header: string): SignatureError { 'unknown_key', 'invalid_jwt', 'expired_jwt', + 'issuer_missing', + 'issuer_mismatch', ] if (!validCodes.includes(error)) { throw new Error(`Invalid Signature-Error code: ${error}`) diff --git a/httpsig/src/verify.ts b/httpsig/src/verify.ts index ce94980..864538a 100644 --- a/httpsig/src/verify.ts +++ b/httpsig/src/verify.ts @@ -30,6 +30,8 @@ import { invalidJwt, expiredJwt, unsupportedAlgorithm, + issuerMissing, + issuerMismatch, } from './errors.js' // JWKS cache. Bounded: the cache key is a URL derived from the request being @@ -183,6 +185,23 @@ async function getPublicKeyFromJWKS( const metadataUrl = `${id}/.well-known/${dwk}` const metadata = await fetchJWKS(metadataUrl, cacheTtl) + // Bind the metadata document to the identity it was fetched under. Without + // this, a document served at {id}/.well-known/{dwk} -- via misconfigured + // shared hosting or a subdomain takeover -- could point jwks_uri at keys + // that do not belong to id, and the verifier would attribute the request + // accordingly. Same check as RFC 8414 Section 3.3. + if (metadata.issuer === undefined) { + throw issuerMissing(`Metadata document missing issuer: ${metadataUrl}`) + } + + // Byte equality as presented, matching the identifier-comparison rule the + // draft uses for the jwks url. No normalization. + if (metadata.issuer !== id) { + throw issuerMismatch( + `Metadata issuer "${metadata.issuer}" does not match id "${id}"`, + ) + } + if (!metadata.jwks_uri) { throw new Error(`Metadata document missing jwks_uri: ${metadataUrl}`) } diff --git a/httpsig/tests/test-issuer-verification.ts b/httpsig/tests/test-issuer-verification.ts new file mode 100644 index 0000000..997308d --- /dev/null +++ b/httpsig/tests/test-issuer-verification.ts @@ -0,0 +1,170 @@ +/** + * Discovery metadata must be bound to the identity it was fetched under + * + * The metadata document at `{id}/.well-known/{dwk}` MUST carry an `issuer` + * member equal to `id`. Without that check a document served under one + * identity -- misconfigured shared hosting, a subdomain takeover -- could + * point `jwks_uri` at keys belonging to someone else, and the verifier would + * attribute the request to the identity in the header. + * + * Same check RFC 8414 Section 3.3 requires of authorization server metadata. + */ + +import { test } from 'node:test' +import assert from 'node:assert' +import { fetch, verify } from '../src/index.js' + +async function generateEd25519KeyPair() { + const keyPair = (await crypto.subtle.generateKey( + { name: 'Ed25519' }, + true, + ['sign', 'verify'], + )) as CryptoKeyPair + + const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey) + const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey) + + privateJwk.alg = 'Ed25519' + publicJwk.alg = 'Ed25519' + + return { privateJwk, publicJwk } +} + +/** + * Serve metadata and a JWKS for `id`. `issuer` is what the metadata document + * claims, which the tests vary independently of the identity it is served + * under. Each test uses its own origin: the JWKS cache is module-level and + * keyed by URL. + */ +function setupDiscovery( + id: string, + publicJwk: JsonWebKey, + issuer: string | undefined, +) { + const originalFetch = globalThis.fetch + + const metadata: Record = { + jwks_uri: `${id}/jwks.json`, + } + if (issuer !== undefined) { + metadata.issuer = issuer + } + + globalThis.fetch = (async (url: string | URL | Request) => { + const u = typeof url === 'string' ? url : url.toString() + if (u === `${id}/.well-known/test-metadata`) { + return new Response(JSON.stringify(metadata), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + if (u === `${id}/jwks.json`) { + return new Response( + JSON.stringify({ + keys: [{ ...publicJwk, kid: 'key-1', use: 'sig' }], + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ) + } + return new Response('not found', { status: 404 }) + }) as typeof globalThis.fetch + + return () => { + globalThis.fetch = originalFetch + } +} + +async function signAndVerify(privateJwk: JsonWebKey, id: string) { + const signed = (await fetch('https://api.example.com/data', { + method: 'GET', + signingKey: privateJwk, + signatureKey: { + type: 'jwks_uri', + id, + kid: 'key-1', + dwk: 'test-metadata', + }, + dryRun: true, + })) as { headers: Headers } + + const headers: Record = {} + signed.headers.forEach((v, k) => { + headers[k] = v + }) + + return verify({ + method: 'GET', + authority: 'api.example.com', + path: '/data', + headers, + }) +} + +test('discovery: metadata whose issuer matches id verifies', async () => { + const id = 'https://issuer-ok.example' + const { privateJwk, publicJwk } = await generateEd25519KeyPair() + const restore = setupDiscovery(id, publicJwk, id) + + try { + const result = await signAndVerify(privateJwk, id) + assert.strictEqual(result.verified, true, result.error) + } finally { + restore() + } +}) + +test('discovery: metadata with no issuer is rejected as issuer_missing', async () => { + const id = 'https://issuer-absent.example' + const { privateJwk, publicJwk } = await generateEd25519KeyPair() + const restore = setupDiscovery(id, publicJwk, undefined) + + try { + const result = await signAndVerify(privateJwk, id) + + assert.strictEqual(result.verified, false) + assert.strictEqual(result.signatureError?.error, 'issuer_missing') + } finally { + restore() + } +}) + +test('discovery: metadata claiming a different issuer is rejected as issuer_mismatch', async () => { + // The attack this prevents: a document served at attacker.example claiming + // to be victim.example, pointing jwks_uri at the attacker's keys. + const id = 'https://issuer-wrong.example' + const { privateJwk, publicJwk } = await generateEd25519KeyPair() + const restore = setupDiscovery( + id, + publicJwk, + 'https://someone-else.example', + ) + + try { + const result = await signAndVerify(privateJwk, id) + + assert.strictEqual(result.verified, false) + assert.strictEqual(result.signatureError?.error, 'issuer_mismatch') + } finally { + restore() + } +}) + +test('discovery: issuer comparison is byte equality, not normalized', async () => { + // A trailing slash is a different identifier. The draft specifies byte + // equality as presented, so no normalization is applied. + const id = 'https://issuer-slash.example' + const { privateJwk, publicJwk } = await generateEd25519KeyPair() + const restore = setupDiscovery(id, publicJwk, `${id}/`) + + try { + const result = await signAndVerify(privateJwk, id) + + assert.strictEqual(result.verified, false) + assert.strictEqual(result.signatureError?.error, 'issuer_mismatch') + } finally { + restore() + } +}) diff --git a/httpsig/tests/test-jwks.ts b/httpsig/tests/test-jwks.ts index e51a0a0..b4f4f24 100644 --- a/httpsig/tests/test-jwks.ts +++ b/httpsig/tests/test-jwks.ts @@ -153,6 +153,8 @@ test('jwks_uri: POST request with body', async () => { keys: [{ ...publicJwk, kid: 'key-post', use: 'sig' }], } const mockMetadata = { + // issuer is REQUIRED and must equal the id the document is fetched under + issuer: 'https://agent-post.example', jwks_uri: 'https://agent-post.example/jwks.json', } globalThis.fetch = (async ( @@ -279,6 +281,8 @@ test('jwks_uri: Caching should work (second verify should not re-fetch)', async } const mockMetadata = { + // issuer is REQUIRED and must equal the id the document is fetched under + issuer: 'https://agent-cache.example', jwks_uri: 'https://agent-cache.example/jwks.json', } From 5adbee9d0f6b4a9fb97400bfebe95d947fa39b90 Mon Sep 17 00:00:00 2001 From: dickhardt Date: Wed, 5 Aug 2026 21:59:21 +0100 Subject: [PATCH 6/6] Bump to 2.0.0-alpha.2 alpha.1 predates supportedAlgorithms, the JWKS tolerance tests, and the discovery issuer verification of -08. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LXExbWHeem2SNb7tZ2LGnc --- httpsig/package.json | 2 +- package-lock.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/httpsig/package.json b/httpsig/package.json index 741fb48..fbd3878 100644 --- a/httpsig/package.json +++ b/httpsig/package.json @@ -1,6 +1,6 @@ { "name": "@hellocoop/httpsig", - "version": "2.0.0-alpha.1", + "version": "2.0.0-alpha.2", "description": "HTTP Message Signatures (RFC 9421) with Signature-Key header support", "repository": { "type": "git", diff --git a/package-lock.json b/package-lock.json index 0b34d2a..644e7c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -555,7 +555,7 @@ }, "httpsig": { "name": "@hellocoop/httpsig", - "version": "2.0.0-alpha.1", + "version": "2.0.0-alpha.2", "license": "MIT", "devDependencies": { "@misskey-dev/node-http-message-signatures": "^0.0.10",