diff --git a/httpsig/MIGRATING-2.0.md b/httpsig/MIGRATING-2.0.md new file mode 100644 index 0000000..293564f --- /dev/null +++ b/httpsig/MIGRATING-2.0.md @@ -0,0 +1,231 @@ +# Migrating to 2.0 + +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. + +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. + +## 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. + +## 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`. + +## 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` +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 +through `-07`, 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..d0914ad 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-08` | `alpha` | +| `1.x` | `draft-hardt-httpbis-signature-key-05` | `latest` | + +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. + **Key Features:** - Zero dependencies diff --git a/httpsig/package.json b/httpsig/package.json index 78f587a..fbd3878 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.2", "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..ef5c7ae --- /dev/null +++ b/httpsig/src/errors.ts @@ -0,0 +1,95 @@ +/** + * 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[] + /** + * 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, + message: string, + options: SignatureVerificationErrorOptions = {}, + ) { + super(message, { cause: options.cause }) + this.name = 'SignatureVerificationError' + this.code = code + this.requiredInput = options.requiredInput + this.supportedAlgorithms = options.supportedAlgorithms + } +} + +/** 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 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, { + supportedAlgorithms, + }) +} + +/** 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 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) +} + +/** 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..ae96f40 100644 --- a/httpsig/src/index.ts +++ b/httpsig/src/index.ts @@ -18,10 +18,24 @@ 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, + SUPPORTED_ALGORITHMS, +} 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 +49,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..a65c975 100644 --- a/httpsig/src/types.ts +++ b/httpsig/src/types.ts @@ -109,11 +109,24 @@ 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 + /** + * 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 +// 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 @@ -152,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 { @@ -170,6 +191,8 @@ export interface ParsedSignatureKey { } export interface HwkValue { + /** REQUIRED. A fully-specified JOSE algorithm identifier. */ + alg: string kty: string crv?: string x?: string @@ -193,6 +216,7 @@ export interface JwksUriValue { */ export type SignatureErrorCode = | 'unsupported_algorithm' + | 'unsupported_scheme' | 'invalid_signature' | 'invalid_input' | 'invalid_request' @@ -200,36 +224,60 @@ export type SignatureErrorCode = | 'unknown_key' | 'invalid_jwt' | 'expired_jwt' + | 'issuer_missing' + | 'issuer_mismatch' /** * Parsed Signature-Error header + * + * The supported_algorithms member was removed in -08; 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 -08. 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..0f26f3e 100644 --- a/httpsig/src/utils/crypto.ts +++ b/httpsig/src/utils/crypto.ts @@ -1,35 +1,235 @@ /** * Cryptographic utilities for HTTP Message Signatures + * + * 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 + * 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' }, + }, +} + +/** + * 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. + */ +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', + ) + } + + 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}"`, + ) } - if (jwk.kty === 'EC') { - if (jwk.crv === 'P-256') { - return { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' } + 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 +238,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 +279,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 +297,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 +312,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..4e30442 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 @@ -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, @@ -62,8 +69,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 +269,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 +347,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 -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. */ 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 +391,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', @@ -376,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}`) @@ -383,15 +408,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 +420,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 -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. */ 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 +507,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 +530,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..864538a 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, @@ -23,12 +24,41 @@ import { import { base64urlDecode } from './utils/base64.js' import { calculateThumbprint } from './utils/thumbprint.js' import { BoundedTtlCache } from './utils/cache.js' +import { + SignatureVerificationError, + invalidInput, + invalidJwt, + expiredJwt, + unsupportedAlgorithm, + issuerMissing, + issuerMismatch, +} 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 */ @@ -155,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}`) } @@ -181,28 +228,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,9 +421,14 @@ export async function verify( const { maxClockSkew = 60, jwksCacheTtl = 3600000, // 1 hour - strictAAuth = true, // Enforce AAuth profile by default + 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) @@ -380,11 +462,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 +502,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, @@ -465,9 +555,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) { @@ -539,7 +640,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]) => { @@ -611,7 +722,11 @@ export async function verify( thumbprint: '', created: 0, error: errorMessage, - signatureError: mapToSignatureError(errorMessage), + signatureError: toSignatureError(error), + ...(error instanceof SignatureVerificationError && + error.supportedAlgorithms + ? { acceptSignatureAlg: error.supportedAlgorithms } + : {}), } } } 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-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-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..b4f4f24 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 } } @@ -149,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 ( @@ -275,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', } 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-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', + ) +}) 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-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() + } +}) 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..644e7c5 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.2", "license": "MIT", "devDependencies": { "@misskey-dev/node-http-message-signatures": "^0.0.10",