From db2ebbf566753a080d6737f5cd122d69c082a733 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Tue, 10 Mar 2026 09:28:25 +0100 Subject: [PATCH 1/6] init --- http/deno.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/http/deno.json b/http/deno.json index edd7e31e079a..6b24b1e6af2d 100644 --- a/http/deno.json +++ b/http/deno.json @@ -19,6 +19,7 @@ "./unstable-structured-fields": "./unstable_structured_fields.ts", "./user-agent": "./user_agent.ts", "./unstable-route": "./unstable_route.ts", - "./unstable-cache-control": "./unstable_cache_control.ts" + "./unstable-cache-control": "./unstable_cache_control.ts", + "./unstable-message-signatures": "./unstable_message_signatures.ts" } } From 7842318fdd12bd988a1a53de482a4b637a5e241f Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Tue, 10 Mar 2026 09:29:30 +0100 Subject: [PATCH 2/6] feat(http/unstable): add RFC 9421 message signatures --- http/unstable_message_signatures.ts | 1059 ++++++++++++++++++++++ http/unstable_message_signatures_test.ts | 810 +++++++++++++++++ 2 files changed, 1869 insertions(+) create mode 100644 http/unstable_message_signatures.ts create mode 100644 http/unstable_message_signatures_test.ts diff --git a/http/unstable_message_signatures.ts b/http/unstable_message_signatures.ts new file mode 100644 index 000000000000..cc1c6c05f042 --- /dev/null +++ b/http/unstable_message_signatures.ts @@ -0,0 +1,1059 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +// This module is browser compatible. + +/** + * Utilities for creating and verifying + * {@link https://www.rfc-editor.org/rfc/rfc9421 | RFC 9421} HTTP Message Signatures. + * + * HTTP Message Signatures provide end-to-end integrity and authenticity for + * components of an HTTP message by using detached digital signatures or MACs. + * The `Signature-Input` and `Signature` headers are serialized as Structured + * Fields Dictionaries ({@link https://www.rfc-editor.org/rfc/rfc9651 | RFC 9651}). + * + * @example Signing a request + * ```ts + * import { signMessage } from "@std/http/unstable-message-signatures"; + * + * const key = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]); + * const request = new Request("https://example.com/api", { + * method: "POST", + * headers: { "Content-Type": "application/json" }, + * body: '{"hello":"world"}', + * }); + * + * const signed = await signMessage({ + * message: request, + * params: { + * components: ["@method", "@authority", "@path", "content-type"], + * keyId: "my-key", + * created: Math.floor(Date.now() / 1000), + * }, + * key: key.privateKey, + * }); + * ``` + * + * @example Verifying a signed request + * ```ts ignore + * import { verifyMessage } from "@std/http/unstable-message-signatures"; + * + * const results = await verifyMessage( + * signedRequest, + * async (keyId) => lookupPublicKey(keyId), + * ); + * ``` + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @module + */ + +import type { + BareItem, + Dictionary, + InnerList, + Item, +} from "@std/http/unstable-structured-fields"; +import { + binary, + innerList as sfInnerList, + integer as sfInteger, + isInnerList, + isItem, + item as sfItem, + parseDictionary, + parseItem, + parseList, + serializeDictionary, + serializeItem, + serializeList, + string as sfString, +} from "@std/http/unstable-structured-fields"; + +const UTF8_ENCODER = new TextEncoder(); + +// ============================================================================= +// Public Types +// ============================================================================= + +/** + * Algorithm identifiers per + * {@link https://www.rfc-editor.org/rfc/rfc9421#section-3.3 | RFC 9421 section 3.3}. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + */ +export type SignatureAlgorithm = + | "rsa-pss-sha512" + | "rsa-v1_5-sha256" + | "hmac-sha256" + | "ecdsa-p256-sha256" + | "ecdsa-p384-sha384" + | "ed25519"; + +/** + * Parameters that can be attached to a component identifier. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9421#section-2.1} + */ +export interface ComponentParameters { + /** Strict Structured Field serialization. */ + sf?: boolean; + /** Dictionary member key. */ + key?: string; + /** Binary-wrapped field values. */ + bs?: boolean; + /** Derive value from the related request. */ + req?: boolean; + /** Derive value from trailer fields. */ + tr?: boolean; + /** Query parameter name (for `@query-param`). */ + name?: string; +} + +/** + * A component identifier consisting of a name and optional parameters. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9421#section-2} + */ +export interface ComponentIdentifier { + /** Lowercased field name or derived component name (e.g. `"@method"`). */ + name: string; + /** Optional parameters per RFC 9421 section 2.1. */ + parameters?: ComponentParameters; +} + +/** + * Convenience type accepting either a plain string or a full + * {@linkcode ComponentIdentifier}. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + */ +export type ComponentInput = string | ComponentIdentifier; + +/** + * Signature parameters used when signing a message. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9421#section-2.3} + */ +export interface SignatureParams { + /** Ordered list of covered components. */ + components: ComponentInput[]; + /** Signature algorithm. */ + algorithm?: SignatureAlgorithm; + /** Key identifier. Mapped to/from the wire-format parameter name `keyid`. */ + keyId?: string; + /** Creation time as seconds since Unix epoch (not milliseconds). */ + created?: number; + /** Expiration time as seconds since Unix epoch (not milliseconds). */ + expires?: number; + /** Nonce for replay protection. */ + nonce?: string; + /** Application-specific tag. */ + tag?: string; + /** Signature label, defaults to `"sig"`. */ + label?: string; +} + +/** + * Parsed signature parameters returned from verification. Components are always + * fully resolved {@linkcode ComponentIdentifier} objects. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + */ +export interface ParsedSignatureParams { + /** Ordered list of covered components. */ + components: ComponentIdentifier[]; + /** Signature algorithm, if specified. */ + algorithm?: SignatureAlgorithm; + /** Key identifier. */ + keyId?: string; + /** Creation time as seconds since Unix epoch. */ + created?: number; + /** Expiration time as seconds since Unix epoch. */ + expires?: number; + /** Nonce value. */ + nonce?: string; + /** Application-specific tag. */ + tag?: string; +} + +/** + * Options for {@linkcode signMessage}. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + */ +export interface SignOptions { + /** The HTTP request or response to sign. */ + message: T; + /** Signature parameters. */ + params: SignatureParams; + /** The signing key. */ + key: CryptoKey; + /** The originating request, needed when signing a response with `;req` components. */ + request?: Request; +} + +/** + * Options for {@linkcode verifyMessage}. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + */ +export interface VerifyOptions { + /** Maximum allowed age of the signature in seconds. */ + maxAge?: number; + /** Components that must be covered by each verified signature. */ + requiredComponents?: ComponentInput[]; + /** Specific signature label(s) to verify. If omitted, all are verified. */ + labels?: string[]; + /** The originating request, needed when verifying response signatures with `;req` components. */ + request?: Request; +} + +/** + * Result of a successful signature verification. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + */ +export interface VerifyResult { + /** The label of the verified signature. */ + label: string; + /** The parsed signature parameters. */ + params: ParsedSignatureParams; +} + +// ============================================================================= +// Algorithm Dispatch +// ============================================================================= + +const SIGNATURE_ALGORITHMS: Record = { + "rsa-pss-sha512": true, + "rsa-v1_5-sha256": true, + "hmac-sha256": true, + "ecdsa-p256-sha256": true, + "ecdsa-p384-sha384": true, + "ed25519": true, +}; + +function isSupportedAlgorithm(value: string): value is SignatureAlgorithm { + return value in SIGNATURE_ALGORITHMS; +} + +function getSignParams( + algorithm: SignatureAlgorithm, +): AlgorithmIdentifier | RsaPssParams | EcdsaParams { + switch (algorithm) { + case "rsa-pss-sha512": + return { name: "RSA-PSS", saltLength: 64 } as RsaPssParams; + case "rsa-v1_5-sha256": + return { name: "RSASSA-PKCS1-v1_5" }; + case "hmac-sha256": + return { name: "HMAC" }; + case "ecdsa-p256-sha256": + return { name: "ECDSA", hash: "SHA-256" } as EcdsaParams; + case "ecdsa-p384-sha384": + return { name: "ECDSA", hash: "SHA-384" } as EcdsaParams; + case "ed25519": + return { name: "Ed25519" }; + } +} + +function inferAlgorithm(key: CryptoKey): SignatureAlgorithm { + const alg = key.algorithm; + const name = alg.name; + if (name === "Ed25519") return "ed25519"; + if (name === "HMAC") return "hmac-sha256"; + if (name === "RSA-PSS") return "rsa-pss-sha512"; + if (name === "RSASSA-PKCS1-v1_5") return "rsa-v1_5-sha256"; + if (name === "ECDSA") { + const hash = (alg as EcKeyAlgorithm & { namedCurve: string }).namedCurve; + if (hash === "P-384") return "ecdsa-p384-sha384"; + return "ecdsa-p256-sha256"; + } + throw new TypeError(`Cannot infer signature algorithm from key: "${name}"`); +} + +// ============================================================================= +// Component Value Resolution +// ============================================================================= + +function normalizeIdentifier(input: ComponentInput): ComponentIdentifier { + if (typeof input === "string") { + return { name: input }; + } + return input; +} + +function resolveComponentValue( + id: ComponentIdentifier, + message: Request | Response, + relatedRequest?: Request, +): string { + const params = id.parameters ?? {}; + + // Validate incompatible parameter combinations + if (params.bs && params.sf) { + throw new TypeError( + `Cannot combine "bs" and "sf" parameters on component "${id.name}"`, + ); + } + if (params.bs && params.key !== undefined) { + throw new TypeError( + `Cannot combine "bs" and "key" parameters on component "${id.name}"`, + ); + } + + // Handle ;req parameter + if (params.req) { + if (message instanceof Request) { + throw new TypeError( + `Cannot use "req" parameter on component "${id.name}" for a request message`, + ); + } + if (!relatedRequest) { + throw new TypeError( + `Cannot resolve "req" parameter on component "${id.name}": no related request provided`, + ); + } + const { req: _, ...restParams } = params; + return resolveComponentValue( + { name: id.name, parameters: restParams }, + relatedRequest, + ); + } + + if (id.name.startsWith("@")) { + return resolveDerivedComponent(id.name, message, params); + } + + return resolveFieldComponent(id.name, message, params); +} + +function resolveDerivedComponent( + name: string, + message: Request | Response, + params: ComponentParameters, +): string { + switch (name) { + case "@method": { + if (!(message instanceof Request)) { + throw new TypeError( + `Cannot use "${name}" on a response message`, + ); + } + return message.method; + } + case "@target-uri": { + if (!(message instanceof Request)) { + throw new TypeError( + `Cannot use "${name}" on a response message`, + ); + } + return message.url; + } + case "@authority": { + if (!(message instanceof Request)) { + throw new TypeError( + `Cannot use "${name}" on a response message`, + ); + } + const url = new URL(message.url); + return url.host; + } + case "@scheme": { + if (!(message instanceof Request)) { + throw new TypeError( + `Cannot use "${name}" on a response message`, + ); + } + const url = new URL(message.url); + return url.protocol.slice(0, -1); + } + case "@request-target": { + if (!(message instanceof Request)) { + throw new TypeError( + `Cannot use "${name}" on a response message`, + ); + } + const url = new URL(message.url); + return url.pathname + url.search; + } + case "@path": { + if (!(message instanceof Request)) { + throw new TypeError( + `Cannot use "${name}" on a response message`, + ); + } + const url = new URL(message.url); + return url.pathname || "/"; + } + case "@query": { + if (!(message instanceof Request)) { + throw new TypeError( + `Cannot use "${name}" on a response message`, + ); + } + const url = new URL(message.url); + // search includes the "?" prefix, but if absent we return "?" + return url.search || "?"; + } + case "@query-param": { + if (!(message instanceof Request)) { + throw new TypeError( + `Cannot use "${name}" on a response message`, + ); + } + if (params.name === undefined) { + throw new TypeError( + `Component "${name}" requires "name" parameter`, + ); + } + const url = new URL(message.url); + const decoded = decodeURIComponent(params.name); + const searchParams = new URLSearchParams(url.search); + const values: string[] = []; + for (const [k, v] of searchParams) { + if (k === decoded) { + values.push(v); + } + } + if (values.length === 0) { + throw new TypeError( + `Query parameter "${params.name}" not found in request URL`, + ); + } + if (values.length > 1) { + throw new TypeError( + `Query parameter "${params.name}" occurs multiple times`, + ); + } + // Re-encode per the spec's application/x-www-form-urlencoded serializing + return encodeQueryParamValue(values[0]!); + } + case "@status": { + if (message instanceof Request) { + throw new TypeError( + `Cannot use "${name}" on a request message`, + ); + } + return String(message.status); + } + default: + throw new TypeError( + `Unknown derived component "${name}"`, + ); + } +} + +function encodeQueryParamValue(value: string): string { + // Percent-encode per application/x-www-form-urlencoded serializing + // then convert + back to %20 for the signature base + return encodeURIComponent(value).replace(/%20/g, "%20"); +} + +function resolveFieldComponent( + name: string, + message: Request | Response, + params: ComponentParameters, +): string { + const headerValue = message.headers.get(name); + if (headerValue === null) { + throw new TypeError(`Missing "${name}" header field`); + } + + if (params.sf) { + return resolveStrictStructuredField(headerValue, name); + } + + if (params.key !== undefined) { + return resolveDictionaryMember(headerValue, name, params.key); + } + + if (params.bs) { + return resolveBinaryWrapped(headerValue); + } + + return headerValue; +} + +function resolveStrictStructuredField( + headerValue: string, + fieldName: string, +): string { + // Try Dictionary, then List, then Item — the first successful parse wins + try { + return serializeDictionary(parseDictionary(headerValue)); + } catch { /* not a dictionary */ } + try { + return serializeList(parseList(headerValue)); + } catch { /* not a list */ } + try { + return serializeItem(parseItem(headerValue)); + } catch { /* not an item */ } + throw new TypeError( + `Cannot apply "sf" parameter to field "${fieldName}": unknown Structured Field type`, + ); +} + +function resolveDictionaryMember( + headerValue: string, + fieldName: string, + key: string, +): string { + let dict: Dictionary; + try { + dict = parseDictionary(headerValue); + } catch (cause) { + throw new TypeError( + `Cannot parse "${fieldName}" as Dictionary for "key" parameter`, + { cause }, + ); + } + const member = dict.get(key); + if (member === undefined) { + throw new TypeError( + `Dictionary key "${key}" not found in "${fieldName}" header`, + ); + } + if (isItem(member)) { + return serializeItem(member); + } + // Inner list member — serialize as inner list value (member_value) + return serializeList([member]); +} + +function resolveBinaryWrapped(headerValue: string): string { + // Each field value is individually wrapped as a Byte Sequence + // For the Fetch API, headers.get() already combines multiple values + // We split on ", " to get individual values, then wrap each as binary + const values = headerValue.split(", "); + const items: Item[] = values.map((v) => { + const bytes = UTF8_ENCODER.encode(v.trim()); + return sfItem(binary(bytes)); + }); + return serializeList(items); +} + +// ============================================================================= +// Signature Base Construction +// ============================================================================= + +function serializeComponentIdentifier(id: ComponentIdentifier): string { + let result = `"${id.name}"`; + const params = id.parameters ?? {}; + if (params.sf) result += ";sf"; + if (params.key !== undefined) result += `;key="${params.key}"`; + if (params.bs) result += ";bs"; + if (params.req) result += ";req"; + if (params.tr) result += ";tr"; + if (params.name !== undefined) result += `;name="${params.name}"`; + return result; +} + +function buildSignatureParamsValue( + components: ComponentIdentifier[], + params: SignatureParams, +): string { + // Build the inner list items (component identifiers as String items with params) + const items: Item[] = components.map((id) => { + const sfParams = new Map(); + const p = id.parameters ?? {}; + if (p.sf) sfParams.set("sf", { type: "boolean", value: true }); + if (p.key !== undefined) sfParams.set("key", { type: "string", value: p.key }); + if (p.bs) sfParams.set("bs", { type: "boolean", value: true }); + if (p.req) sfParams.set("req", { type: "boolean", value: true }); + if (p.tr) sfParams.set("tr", { type: "boolean", value: true }); + if (p.name !== undefined) sfParams.set("name", { type: "string", value: p.name }); + return sfItem(sfString(id.name), sfParams); + }); + + // Build the inner list parameters (signature metadata) + const listParams = new Map(); + if (params.created !== undefined) { + listParams.set("created", sfInteger(params.created)); + } + if (params.expires !== undefined) { + listParams.set("expires", sfInteger(params.expires)); + } + if (params.nonce !== undefined) { + listParams.set("nonce", sfString(params.nonce)); + } + if (params.algorithm !== undefined) { + listParams.set("alg", sfString(params.algorithm)); + } + if (params.keyId !== undefined) { + listParams.set("keyid", sfString(params.keyId)); + } + if (params.tag !== undefined) { + listParams.set("tag", sfString(params.tag)); + } + + const il = sfInnerList(items, listParams); + // Serialize just the inner list (not as a dictionary member) + return serializeInnerListValue(il); +} + +function serializeInnerListValue(il: InnerList): string { + const items = il.items.map((i) => serializeItem(i)).join(" "); + let result = `(${items})`; + for (const [key, value] of il.parameters) { + result += `;${key}=${serializeBareItemValue(value)}`; + } + return result; +} + +function serializeBareItemValue(bareItem: BareItem): string { + switch (bareItem.type) { + case "integer": + return String(bareItem.value); + case "string": + return `"${bareItem.value}"`; + default: + return serializeItem(sfItem(bareItem)); + } +} + +/** Options for {@linkcode createSignatureBase}. */ +export interface CreateSignatureBaseOptions { + /** The HTTP request or response. */ + message: Request | Response; + /** Signature parameters including covered components. */ + params: SignatureParams; + /** The originating request when signing a response with `;req` components. */ + request?: Request; +} + +/** + * Construct the signature base string for a message per + * {@link https://www.rfc-editor.org/rfc/rfc9421#section-2.5 | RFC 9421 section 2.5}. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Usage + * ```ts + * import { createSignatureBase } from "@std/http/unstable-message-signatures"; + * import { assert } from "@std/assert"; + * + * const request = new Request("https://example.com/path", { + * method: "GET", + * headers: { "Content-Type": "text/plain" }, + * }); + * const base = createSignatureBase({ + * message: request, + * params: { + * components: ["@method", "@authority"], + * keyId: "my-key", + * created: 1618884473, + * }, + * }); + * + * assert(base.includes('"@method": GET')); + * ``` + * + * @param options The message, signature parameters, and optional related request. + * @returns The signature base string. + */ +export function createSignatureBase( + options: CreateSignatureBaseOptions, +): string { + const { message, params, request: relatedRequest } = options; + const components = params.components.map(normalizeIdentifier); + + // Check for duplicate component identifiers + const seen = new Set(); + for (const id of components) { + const key = serializeComponentIdentifier(id); + if (seen.has(key)) { + throw new TypeError( + `Duplicate component identifier ${key} in covered components`, + ); + } + seen.add(key); + } + + const lines: string[] = []; + for (const id of components) { + const serializedId = serializeComponentIdentifier(id); + const value = resolveComponentValue(id, message, relatedRequest); + lines.push(`${serializedId}: ${value}`); + } + + const sigParamsValue = buildSignatureParamsValue(components, params); + lines.push(`"@signature-params": ${sigParamsValue}`); + + return lines.join("\n"); +} + +// ============================================================================= +// Input Validation +// ============================================================================= + +function validateTimestamp(value: number, name: string): void { + if (!Number.isInteger(value) || value < 0) { + throw new RangeError( + `${name} must be a non-negative integer, got ${value}`, + ); + } +} + +function validateSignParams(params: SignatureParams): void { + if (params.created !== undefined) { + validateTimestamp(params.created, "created"); + } + if (params.expires !== undefined) { + validateTimestamp(params.expires, "expires"); + } + if ( + params.algorithm !== undefined && !isSupportedAlgorithm(params.algorithm) + ) { + throw new TypeError( + `Unsupported signature algorithm: "${params.algorithm}"`, + ); + } +} + +// ============================================================================= +// signMessage +// ============================================================================= + +/** + * Sign an HTTP message per + * {@link https://www.rfc-editor.org/rfc/rfc9421 | RFC 9421}. + * + * Returns a new Request/Response with `Signature` and `Signature-Input` + * headers appended. The original message is not mutated. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Usage + * ```ts + * import { signMessage } from "@std/http/unstable-message-signatures"; + * import { assert } from "@std/assert"; + * + * const key = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]); + * const request = new Request("https://example.com/", { method: "POST" }); + * const signed = await signMessage({ + * message: request, + * params: { + * components: ["@method", "@authority"], + * keyId: "test-key", + * created: Math.floor(Date.now() / 1000), + * }, + * key: key.privateKey, + * }); + * + * assert(signed.headers.has("Signature")); + * assert(signed.headers.has("Signature-Input")); + * ``` + * + * @typeParam T The type of the message (Request or Response). + * @param options Signing options. + * @returns A new message with signature headers appended. + */ +export async function signMessage( + options: SignOptions, +): Promise { + const { message, params, key, request } = options; + const label = params.label ?? "sig"; + + validateSignParams(params); + + const algorithm = params.algorithm ?? inferAlgorithm(key); + + const baseOpts: CreateSignatureBaseOptions = { message, params }; + if (request) baseOpts.request = request; + const base = createSignatureBase(baseOpts); + const baseBytes = UTF8_ENCODER.encode(base); + + const signParams = getSignParams(algorithm); + const signatureBytes: Uint8Array = new Uint8Array( + await crypto.subtle.sign(signParams, key, baseBytes), + ); + // Web Crypto in Deno/browsers already produces raw (r, s) for ECDSA, + // which is the format RFC 9421 requires. No DER conversion needed. + + // Build Signature-Input value + const components = params.components.map(normalizeIdentifier); + const sigParamsValue = buildSignatureParamsValue(components, params); + + // Build headers + const sigInputHeader = `${label}=${sigParamsValue}`; + const sigHeader = serializeDictionary( + new Map([[label, sfItem(binary(signatureBytes))]]), + ); + + // Clone the message and append headers + if (message instanceof Request) { + const clone = new Request(message, { + headers: new Headers(message.headers), + }); + appendHeader(clone.headers, "Signature-Input", sigInputHeader); + appendHeader(clone.headers, "Signature", sigHeader); + return clone as T; + } else { + const bodyBytes = message.body + ? await message.clone().arrayBuffer() + : null; + const clone = new Response(bodyBytes, { + status: message.status, + statusText: message.statusText, + headers: new Headers(message.headers), + }); + appendHeader(clone.headers, "Signature-Input", sigInputHeader); + appendHeader(clone.headers, "Signature", sigHeader); + return clone as T; + } +} + +function appendHeader(headers: Headers, name: string, value: string): void { + const existing = headers.get(name); + if (existing) { + headers.set(name, `${existing}, ${value}`); + } else { + headers.set(name, value); + } +} + +// ============================================================================= +// verifyMessage +// ============================================================================= + +/** + * Verify one or more signatures on an HTTP message per + * {@link https://www.rfc-editor.org/rfc/rfc9421 | RFC 9421}. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Usage + * ```ts ignore + * import { verifyMessage } from "@std/http/unstable-message-signatures"; + * + * const results = await verifyMessage( + * signedRequest, + * async (keyId) => lookupPublicKey(keyId), + * { requiredComponents: ["@method", "@authority"] }, + * ); + * ``` + * + * @param message The HTTP request or response to verify. + * @param keyLookup Resolves a key identifier to a CryptoKey. + * @param options Optional verification constraints. + * @returns Array of verified signature results. + */ +export async function verifyMessage( + message: Request | Response, + keyLookup: ( + keyId: string, + algorithm?: SignatureAlgorithm, + ) => Promise | CryptoKey, + options?: VerifyOptions, +): Promise { + if (options?.maxAge !== undefined) { + if (!Number.isInteger(options.maxAge) || options.maxAge < 0) { + throw new RangeError( + `maxAge must be a non-negative integer, got ${options.maxAge}`, + ); + } + } + + const sigInputHeader = message.headers.get("Signature-Input"); + if (sigInputHeader === null) { + throw new TypeError('Missing "Signature-Input" header'); + } + const sigHeader = message.headers.get("Signature"); + if (sigHeader === null) { + throw new TypeError('Missing "Signature" header'); + } + + const sigInputDict = parseDictionary(sigInputHeader); + const sigDict = parseDictionary(sigHeader); + + // Validate label consistency + for (const [label] of sigInputDict) { + if (!sigDict.has(label)) { + throw new TypeError( + `Label "${label}" found in Signature-Input but missing in Signature`, + ); + } + } + for (const [label] of sigDict) { + if (!sigInputDict.has(label)) { + throw new TypeError( + `Label "${label}" found in Signature but missing in Signature-Input`, + ); + } + } + + const results: VerifyResult[] = []; + + for (const [label, sigInputMember] of sigInputDict) { + // Filter by labels option + if (options?.labels && !options.labels.includes(label)) continue; + + if (!isInnerList(sigInputMember)) { + throw new TypeError( + `Signature-Input member "${label}" is not an Inner List`, + ); + } + + // Parse covered components from the inner list + const parsedParams = parseSignatureInput(sigInputMember, label); + + // Enforce required components + if (options?.requiredComponents) { + for (const required of options.requiredComponents) { + const reqId = normalizeIdentifier(required); + const reqKey = serializeComponentIdentifier(reqId); + const found = parsedParams.components.some( + (c) => serializeComponentIdentifier(c) === reqKey, + ); + if (!found) { + throw new Error( + `Signature "${label}" does not cover required component ${reqKey}`, + ); + } + } + } + + // Check maxAge + if (options?.maxAge !== undefined && parsedParams.created !== undefined) { + const now = Math.floor(Date.now() / 1000); + if (now - parsedParams.created > options.maxAge) { + throw new Error(`Signature "${label}" has expired`); + } + } + + // Reconstruct signature base + const reconstructedParams: SignatureParams = { + components: parsedParams.components, + ...(parsedParams.algorithm !== undefined && { algorithm: parsedParams.algorithm }), + ...(parsedParams.keyId !== undefined && { keyId: parsedParams.keyId }), + ...(parsedParams.created !== undefined && { created: parsedParams.created }), + ...(parsedParams.expires !== undefined && { expires: parsedParams.expires }), + ...(parsedParams.nonce !== undefined && { nonce: parsedParams.nonce }), + ...(parsedParams.tag !== undefined && { tag: parsedParams.tag }), + }; + const verifyBaseOpts: CreateSignatureBaseOptions = { + message, + params: reconstructedParams, + }; + if (options?.request) verifyBaseOpts.request = options.request; + const base = createSignatureBase(verifyBaseOpts); + const baseBytes = UTF8_ENCODER.encode(base); + + // Get signature bytes + const sigMember = sigDict.get(label); + if (!sigMember || !isItem(sigMember)) { + throw new TypeError( + `Signature member "${label}" is not an Item`, + ); + } + if (sigMember.value.type !== "binary") { + throw new TypeError( + `Signature member "${label}" is not a Byte Sequence`, + ); + } + const sigBytes: Uint8Array = new Uint8Array(sigMember.value.value); + + // Look up key + const algorithm = parsedParams.algorithm ?? undefined; + const keyId = parsedParams.keyId ?? ""; + const verifyKey = await keyLookup(keyId, algorithm); + + const verifyAlgorithm = algorithm ?? inferAlgorithm(verifyKey); + const verifyParams = getSignParams(verifyAlgorithm); + + // Web Crypto in Deno/browsers accepts raw (r, s) for ECDSA directly, + // which is the format RFC 9421 uses. No DER conversion needed. + const valid = await crypto.subtle.verify( + verifyParams, + verifyKey, + sigBytes, + baseBytes, + ); + + if (!valid) { + throw new Error( + `Signature verification failed for label "${label}"`, + ); + } + + results.push({ label, params: parsedParams }); + } + + return results; +} + +function parseSignatureInput( + il: InnerList, + label: string, +): ParsedSignatureParams { + const components: ComponentIdentifier[] = []; + + for (const member of il.items) { + if (member.value.type !== "string") { + throw new TypeError( + `Component identifier in "${label}" is not a String`, + ); + } + const name = member.value.value; + const params: ComponentParameters = {}; + for (const [key, value] of member.parameters) { + switch (key) { + case "sf": + params.sf = value.type === "boolean" ? value.value : true; + break; + case "key": + if (value.type === "string") params.key = value.value; + break; + case "bs": + params.bs = value.type === "boolean" ? value.value : true; + break; + case "req": + params.req = value.type === "boolean" ? value.value : true; + break; + case "tr": + params.tr = value.type === "boolean" ? value.value : true; + break; + case "name": + if (value.type === "string") params.name = value.value; + break; + } + } + const hasParams = Object.values(params).some((v) => v !== undefined); + components.push(hasParams ? { name, parameters: params } : { name }); + } + + const result: ParsedSignatureParams = { components }; + + for (const [key, value] of il.parameters) { + switch (key) { + case "created": + if (value.type === "integer") result.created = value.value; + break; + case "expires": + if (value.type === "integer") result.expires = value.value; + break; + case "nonce": + if (value.type === "string") result.nonce = value.value; + break; + case "alg": + if (value.type === "string" && isSupportedAlgorithm(value.value)) { + result.algorithm = value.value; + } + break; + case "keyid": + if (value.type === "string") result.keyId = value.value; + break; + case "tag": + if (value.type === "string") result.tag = value.value; + break; + } + } + + return result; +} diff --git a/http/unstable_message_signatures_test.ts b/http/unstable_message_signatures_test.ts new file mode 100644 index 000000000000..da2994d50939 --- /dev/null +++ b/http/unstable_message_signatures_test.ts @@ -0,0 +1,810 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +import { assertEquals, assertRejects, assertThrows } from "@std/assert"; +import { + createSignatureBase, + signMessage, + verifyMessage, +} from "./unstable_message_signatures.ts"; +import type { SignatureAlgorithm } from "./unstable_message_signatures.ts"; + +// ============================================================================= +// Helpers +// ============================================================================= + +function makeRequest( + url = "https://example.com/foo?param=Value&Pet=dog", + init?: RequestInit, +): Request { + return new Request(url, { + method: "POST", + headers: { + "Host": "example.com", + "Date": "Tue, 20 Apr 2021 02:07:55 GMT", + "Content-Type": "application/json", + "Content-Digest": + "sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==:", + "Content-Length": "18", + }, + body: '{"hello": "world"}', + ...init, + }); +} + +async function generateEd25519(): Promise { + return await crypto.subtle.generateKey( + "Ed25519", + true, + ["sign", "verify"], + ) as CryptoKeyPair; +} + +async function generateHmacKey(): Promise { + return await crypto.subtle.generateKey( + { name: "HMAC", hash: "SHA-256" }, + true, + ["sign", "verify"], + ); +} + +async function generateEcdsaP256(): Promise { + return await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ) as CryptoKeyPair; +} + +async function generateEcdsaP384(): Promise { + return await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-384" }, + true, + ["sign", "verify"], + ) as CryptoKeyPair; +} + +async function generateRsaPss(): Promise { + return await crypto.subtle.generateKey( + { + name: "RSA-PSS", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-512", + }, + true, + ["sign", "verify"], + ) as CryptoKeyPair; +} + +async function generateRsaPkcs1(): Promise { + return await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ) as CryptoKeyPair; +} + +// ============================================================================= +// Layer 1: Component value resolution — derived components +// ============================================================================= + +Deno.test("createSignatureBase() resolves @method", () => { + const request = new Request("https://example.com/path", { method: "POST" }); + const base = createSignatureBase({ + message: request, + params: { components: ["@method"], created: 1618884473 }, + }); + assertEquals(base.split("\n")[0], '"@method": POST'); +}); + +Deno.test("createSignatureBase() resolves @target-uri", () => { + const request = new Request("https://www.example.com/path?param=value"); + const base = createSignatureBase({ + message: request, + params: { components: ["@target-uri"], created: 1618884473 }, + }); + assertEquals( + base.split("\n")[0], + '"@target-uri": https://www.example.com/path?param=value', + ); +}); + +Deno.test("createSignatureBase() resolves @authority with default port omitted", () => { + const request = new Request("https://www.example.com/path"); + const base = createSignatureBase({ + message: request, + params: { components: ["@authority"], created: 1618884473 }, + }); + assertEquals(base.split("\n")[0], '"@authority": www.example.com'); +}); + +Deno.test("createSignatureBase() resolves @scheme", () => { + const request = new Request("https://example.com/"); + const base = createSignatureBase({ + message: request, + params: { components: ["@scheme"], created: 1618884473 }, + }); + assertEquals(base.split("\n")[0], '"@scheme": https'); +}); + +Deno.test("createSignatureBase() resolves @request-target", () => { + const request = new Request("https://example.com/path?param=value"); + const base = createSignatureBase({ + message: request, + params: { components: ["@request-target"], created: 1618884473 }, + }); + assertEquals(base.split("\n")[0], '"@request-target": /path?param=value'); +}); + +Deno.test("createSignatureBase() resolves @path with empty path normalized to slash", () => { + const request = new Request("https://example.com"); + const base = createSignatureBase({ + message: request, + params: { components: ["@path"], created: 1618884473 }, + }); + assertEquals(base.split("\n")[0], '"@path": /'); +}); + +Deno.test("createSignatureBase() resolves @query with absent query as ?", () => { + const request = new Request("https://example.com/path"); + const base = createSignatureBase({ + message: request, + params: { components: ["@query"], created: 1618884473 }, + }); + assertEquals(base.split("\n")[0], '"@query": ?'); +}); + +Deno.test("createSignatureBase() resolves @query-param with percent-encoding", () => { + const request = new Request( + "https://example.com/path?param=value&foo=bar", + ); + const base = createSignatureBase({ + message: request, + params: { + components: [{ name: "@query-param", parameters: { name: "param" } }], + created: 1618884473, + }, + }); + assertEquals(base.split("\n")[0], '"@query-param";name="param": value'); +}); + +Deno.test("createSignatureBase() resolves @status from response", () => { + const response = new Response(null, { status: 200 }); + const base = createSignatureBase({ + message: response, + params: { components: ["@status"], created: 1618884473 }, + }); + assertEquals(base.split("\n")[0], '"@status": 200'); +}); + +// ============================================================================= +// Layer 1b: Field component parameters +// ============================================================================= + +Deno.test("createSignatureBase() resolves plain header field value", () => { + const request = new Request("https://example.com/", { + headers: { "Content-Type": "application/json" }, + }); + const base = createSignatureBase({ + message: request, + params: { components: ["content-type"], created: 1618884473 }, + }); + assertEquals(base.split("\n")[0], '"content-type": application/json'); +}); + +Deno.test("createSignatureBase() resolves ;sf strict serialization", () => { + const request = new Request("https://example.com/", { + headers: { "Example-Dict": "a=1, b=2;x=1;y=2, c=(a b c)" }, + }); + const base = createSignatureBase({ + message: request, + params: { + components: [{ name: "example-dict", parameters: { sf: true } }], + created: 1618884473, + }, + }); + assertEquals( + base.split("\n")[0], + '"example-dict";sf: a=1, b=2;x=1;y=2, c=(a b c)', + ); +}); + +Deno.test("createSignatureBase() resolves ;key dictionary member", () => { + const request = new Request("https://example.com/", { + headers: { "Example-Dict": "a=1, b=2;x=1;y=2, c=(a b c), d" }, + }); + const base = createSignatureBase({ + message: request, + params: { + components: [{ name: "example-dict", parameters: { key: "a" } }], + created: 1618884473, + }, + }); + assertEquals(base.split("\n")[0], '"example-dict";key="a": 1'); +}); + +Deno.test("createSignatureBase() resolves ;bs binary-wrapped field", () => { + const request = new Request("https://example.com/", { + headers: { "Example-Header": "value, with, lots" }, + }); + const base = createSignatureBase({ + message: request, + params: { + components: [{ name: "example-header", parameters: { bs: true } }], + created: 1618884473, + }, + }); + const line = base.split("\n")[0]!; + // The bs parameter wraps each value as a Byte Sequence + assertEquals(line.startsWith('"example-header";bs: :'), true); +}); + +// ============================================================================= +// Layer 1c: Component resolution errors +// ============================================================================= + +Deno.test("createSignatureBase() throws TypeError on missing header field", () => { + const request = new Request("https://example.com/"); + assertThrows( + () => + createSignatureBase({ + message: request, + params: { components: ["x-nonexistent"] }, + }), + TypeError, + 'Missing "x-nonexistent" header field', + ); +}); + +Deno.test("createSignatureBase() throws TypeError on unknown derived component", () => { + const request = new Request("https://example.com/"); + assertThrows( + () => + createSignatureBase({ + message: request, + params: { components: ["@unknown"] }, + }), + TypeError, + 'Unknown derived component "@unknown"', + ); +}); + +Deno.test("createSignatureBase() throws TypeError on @status for request message", () => { + const request = new Request("https://example.com/"); + assertThrows( + () => + createSignatureBase({ + message: request, + params: { components: ["@status"] }, + }), + TypeError, + 'Cannot use "@status" on a request message', + ); +}); + +Deno.test("createSignatureBase() throws TypeError on incompatible ;bs and ;sf", () => { + const request = new Request("https://example.com/", { + headers: { "Example": "value" }, + }); + assertThrows( + () => + createSignatureBase({ + message: request, + params: { + components: [ + { name: "example", parameters: { bs: true, sf: true } }, + ], + }, + }), + TypeError, + 'Cannot combine "bs" and "sf"', + ); +}); + +// ============================================================================= +// Layer 1d: ;req parameter +// ============================================================================= + +Deno.test("createSignatureBase() resolves ;req from related request", () => { + const request = new Request("https://example.com/foo", { method: "POST" }); + const response = new Response(null, { status: 200 }); + const base = createSignatureBase({ + message: response, + params: { + components: [ + "@status", + { name: "@method", parameters: { req: true } }, + ], + created: 1618884473, + }, + request, + }); + const lines = base.split("\n"); + assertEquals(lines[0], '"@status": 200'); + assertEquals(lines[1], '"@method";req: POST'); +}); + +// ============================================================================= +// Layer 2: Signature base construction +// ============================================================================= + +Deno.test("createSignatureBase() builds correct base for RFC 9421 section 2.5 example", () => { + const request = makeRequest(); + const base = createSignatureBase({ + message: request, + params: { + components: [ + "@method", + "@authority", + "@path", + "content-digest", + "content-length", + "content-type", + ], + created: 1618884473, + keyId: "test-key-rsa-pss", + }, + }); + + const expected = [ + '"@method": POST', + '"@authority": example.com', + '"@path": /foo', + '"content-digest": sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==:', + '"content-length": 18', + '"content-type": application/json', + '"@signature-params": ("@method" "@authority" "@path" "content-digest" "content-length" "content-type");created=1618884473;keyid="test-key-rsa-pss"', + ].join("\n"); + + assertEquals(base, expected); +}); + +Deno.test("createSignatureBase() rejects duplicate component identifier", () => { + const request = new Request("https://example.com/", { + headers: { "Content-Type": "text/plain" }, + }); + assertThrows( + () => + createSignatureBase({ + message: request, + params: { components: ["content-type", "content-type"] }, + }), + TypeError, + "Duplicate component identifier", + ); +}); + +// ============================================================================= +// Layer 2b: ECDSA DER/raw conversion (tested via round-trip) +// ============================================================================= + +Deno.test("signMessage() and verifyMessage() round-trip with ecdsa-p256-sha256 exercises DER/raw conversion", async () => { + const keys = await generateEcdsaP256(); + const request = new Request("https://example.com/", { method: "GET" }); + const signed = await signMessage({ + message: request, + params: { + components: ["@method"], + keyId: "test-ecdsa-p256", + algorithm: "ecdsa-p256-sha256", + created: Math.floor(Date.now() / 1000), + }, + key: keys.privateKey, + }); + + const results = await verifyMessage( + signed, + () => keys.publicKey, + ); + assertEquals(results.length, 1); + assertEquals(results[0]!.label, "sig"); +}); + +// ============================================================================= +// Layer 3: Sign and verify integration — one per algorithm +// ============================================================================= + +Deno.test("signMessage() and verifyMessage() round-trip with ed25519", async () => { + const keys = await generateEd25519(); + const request = new Request("https://example.com/", { method: "POST" }); + const signed = await signMessage({ + message: request, + params: { + components: ["@method", "@authority"], + keyId: "test-ed25519", + created: Math.floor(Date.now() / 1000), + }, + key: keys.privateKey, + }); + + const results = await verifyMessage(signed, () => keys.publicKey); + assertEquals(results.length, 1); + assertEquals(results[0]!.params.keyId, "test-ed25519"); +}); + +Deno.test("signMessage() and verifyMessage() round-trip with hmac-sha256", async () => { + const key = await generateHmacKey(); + const request = new Request("https://example.com/", { method: "GET" }); + const signed = await signMessage({ + message: request, + params: { + components: ["@method"], + keyId: "test-hmac", + algorithm: "hmac-sha256", + created: Math.floor(Date.now() / 1000), + }, + key, + }); + + const results = await verifyMessage(signed, () => key); + assertEquals(results.length, 1); +}); + +Deno.test("signMessage() and verifyMessage() round-trip with ecdsa-p384-sha384", async () => { + const keys = await generateEcdsaP384(); + const request = new Request("https://example.com/", { method: "GET" }); + const signed = await signMessage({ + message: request, + params: { + components: ["@method"], + keyId: "test-ecdsa-p384", + algorithm: "ecdsa-p384-sha384", + created: Math.floor(Date.now() / 1000), + }, + key: keys.privateKey, + }); + + const results = await verifyMessage(signed, () => keys.publicKey); + assertEquals(results.length, 1); +}); + +Deno.test("signMessage() and verifyMessage() round-trip with rsa-pss-sha512", async () => { + const keys = await generateRsaPss(); + const request = new Request("https://example.com/", { method: "GET" }); + const signed = await signMessage({ + message: request, + params: { + components: ["@method"], + keyId: "test-rsa-pss", + algorithm: "rsa-pss-sha512", + created: Math.floor(Date.now() / 1000), + }, + key: keys.privateKey, + }); + + const results = await verifyMessage(signed, () => keys.publicKey); + assertEquals(results.length, 1); +}); + +Deno.test("signMessage() and verifyMessage() round-trip with rsa-v1_5-sha256", async () => { + const keys = await generateRsaPkcs1(); + const request = new Request("https://example.com/", { method: "GET" }); + const signed = await signMessage({ + message: request, + params: { + components: ["@method"], + keyId: "test-rsa-pkcs1", + algorithm: "rsa-v1_5-sha256", + created: Math.floor(Date.now() / 1000), + }, + key: keys.privateKey, + }); + + const results = await verifyMessage(signed, () => keys.publicKey); + assertEquals(results.length, 1); +}); + +// ============================================================================= +// Layer 3b: signMessage behaviour +// ============================================================================= + +Deno.test("signMessage() preserves existing headers on the message", async () => { + const keys = await generateEd25519(); + const request = new Request("https://example.com/", { + method: "GET", + headers: { "X-Custom": "preserved" }, + }); + const signed = await signMessage({ + message: request, + params: { + components: ["@method"], + keyId: "k", + created: Math.floor(Date.now() / 1000), + }, + key: keys.privateKey, + }); + assertEquals(signed.headers.get("X-Custom"), "preserved"); + assertEquals(signed.headers.has("Signature"), true); + assertEquals(signed.headers.has("Signature-Input"), true); +}); + +Deno.test("signMessage() defaults label to sig", async () => { + const keys = await generateEd25519(); + const request = new Request("https://example.com/", { method: "GET" }); + const signed = await signMessage({ + message: request, + params: { + components: ["@method"], + keyId: "k", + created: Math.floor(Date.now() / 1000), + }, + key: keys.privateKey, + }); + const sigInput = signed.headers.get("Signature-Input")!; + assertEquals(sigInput.startsWith("sig="), true); +}); + +Deno.test("signMessage() throws TypeError on unsupported algorithm", async () => { + const keys = await generateEd25519(); + const request = new Request("https://example.com/", { method: "GET" }); + await assertRejects( + () => + signMessage({ + message: request, + params: { + components: ["@method"], + algorithm: "invalid-algo" as SignatureAlgorithm, + }, + key: keys.privateKey, + }), + TypeError, + "Unsupported signature algorithm", + ); +}); + +Deno.test("signMessage() throws RangeError on negative created timestamp", async () => { + const keys = await generateEd25519(); + const request = new Request("https://example.com/", { method: "GET" }); + await assertRejects( + () => + signMessage({ + message: request, + params: { + components: ["@method"], + keyId: "k", + created: -1, + }, + key: keys.privateKey, + }), + RangeError, + "created must be a non-negative integer", + ); +}); + +// ============================================================================= +// Layer 3c: verifyMessage behaviour +// ============================================================================= + +Deno.test("verifyMessage() throws Error on tampered header value", async () => { + const keys = await generateEd25519(); + const request = new Request("https://example.com/", { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + const signed = await signMessage({ + message: request, + params: { + components: ["@method", "content-type"], + keyId: "k", + created: Math.floor(Date.now() / 1000), + }, + key: keys.privateKey, + }); + + // Tamper with the content-type header + const tampered = new Request(signed.url, { + method: signed.method, + headers: new Headers(signed.headers), + }); + tampered.headers.set("Content-Type", "text/plain"); + + await assertRejects( + () => verifyMessage(tampered, () => keys.publicKey), + Error, + "Signature verification failed", + ); +}); + +Deno.test("verifyMessage() throws Error on wrong key", async () => { + const keys1 = await generateEd25519(); + const keys2 = await generateEd25519(); + const request = new Request("https://example.com/", { method: "GET" }); + const signed = await signMessage({ + message: request, + params: { + components: ["@method"], + keyId: "k", + created: Math.floor(Date.now() / 1000), + }, + key: keys1.privateKey, + }); + + await assertRejects( + () => verifyMessage(signed, () => keys2.publicKey), + Error, + "Signature verification failed", + ); +}); + +Deno.test("verifyMessage() throws Error on expired signature when maxAge is set", async () => { + const keys = await generateEd25519(); + const request = new Request("https://example.com/", { method: "GET" }); + const signed = await signMessage({ + message: request, + params: { + components: ["@method"], + keyId: "k", + created: Math.floor(Date.now() / 1000) - 3600, + }, + key: keys.privateKey, + }); + + await assertRejects( + () => verifyMessage(signed, () => keys.publicKey, { maxAge: 60 }), + Error, + "has expired", + ); +}); + +Deno.test("verifyMessage() throws Error when requiredComponents are not covered", async () => { + const keys = await generateEd25519(); + const request = new Request("https://example.com/", { method: "GET" }); + const signed = await signMessage({ + message: request, + params: { + components: ["@method"], + keyId: "k", + created: Math.floor(Date.now() / 1000), + }, + key: keys.privateKey, + }); + + await assertRejects( + () => + verifyMessage(signed, () => keys.publicKey, { + requiredComponents: ["@authority"], + }), + Error, + "does not cover required component", + ); +}); + +Deno.test("verifyMessage() filters by labels option", async () => { + const keys = await generateEd25519(); + const request = new Request("https://example.com/", { method: "GET" }); + const signed = await signMessage({ + message: request, + params: { + components: ["@method"], + keyId: "k", + label: "mysig", + created: Math.floor(Date.now() / 1000), + }, + key: keys.privateKey, + }); + + // Verify only "mysig" — should succeed + const results = await verifyMessage(signed, () => keys.publicKey, { + labels: ["mysig"], + }); + assertEquals(results.length, 1); + + // Verify only "other" — should return empty (no matching labels) + const empty = await verifyMessage(signed, () => keys.publicKey, { + labels: ["other"], + }); + assertEquals(empty.length, 0); +}); + +Deno.test("verifyMessage() throws TypeError on missing Signature-Input header", async () => { + const request = new Request("https://example.com/", { method: "GET" }); + await assertRejects( + () => verifyMessage(request, () => { throw new Error("unreachable"); }), + TypeError, + 'Missing "Signature-Input" header', + ); +}); + +Deno.test("verifyMessage() throws RangeError on negative maxAge", async () => { + const keys = await generateEd25519(); + const request = new Request("https://example.com/", { method: "GET" }); + const signed = await signMessage({ + message: request, + params: { + components: ["@method"], + keyId: "k", + created: Math.floor(Date.now() / 1000), + }, + key: keys.privateKey, + }); + + await assertRejects( + () => verifyMessage(signed, () => keys.publicKey, { maxAge: -1 }), + RangeError, + "maxAge must be a non-negative integer", + ); +}); + +// ============================================================================= +// Layer 3d: Advanced scenarios +// ============================================================================= + +Deno.test("signMessage() supports multiple signatures with different labels", async () => { + const keys1 = await generateEd25519(); + const keys2 = await generateEd25519(); + const request = new Request("https://example.com/", { method: "GET" }); + + const signed1 = await signMessage({ + message: request, + params: { + components: ["@method"], + keyId: "key1", + label: "sig1", + created: Math.floor(Date.now() / 1000), + }, + key: keys1.privateKey, + }); + + const signed2 = await signMessage({ + message: signed1, + params: { + components: ["@method", "@authority"], + keyId: "key2", + label: "sig2", + created: Math.floor(Date.now() / 1000), + }, + key: keys2.privateKey, + }); + + const sigInput = signed2.headers.get("Signature-Input")!; + assertEquals(sigInput.includes("sig1="), true); + assertEquals(sigInput.includes("sig2="), true); + + // Verify both + const results = await verifyMessage( + signed2, + (keyId) => { + if (keyId === "key1") return keys1.publicKey; + return keys2.publicKey; + }, + ); + assertEquals(results.length, 2); +}); + +Deno.test("verifyMessage() verifies response signature with ;req components", async () => { + const keys = await generateEd25519(); + const request = new Request("https://example.com/foo", { method: "POST" }); + const response = new Response('{"ok":true}', { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + + const signed = await signMessage({ + message: response, + params: { + components: [ + "@status", + "content-type", + { name: "@method", parameters: { req: true } }, + { name: "@path", parameters: { req: true } }, + ], + keyId: "server-key", + created: Math.floor(Date.now() / 1000), + }, + key: keys.privateKey, + request, + }); + + const results = await verifyMessage( + signed, + () => keys.publicKey, + { request }, + ); + assertEquals(results.length, 1); + assertEquals(results[0]!.params.keyId, "server-key"); +}); From 45a8130ee57d31ec4cfddb9e66961c2bcc9fa861 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Tue, 10 Mar 2026 12:34:07 +0100 Subject: [PATCH 3/6] feat(http/unstable): add RFC 9421 message signatures --- http/unstable_message_signatures.ts | 361 +++++++++++++---------- http/unstable_message_signatures_test.ts | 5 +- 2 files changed, 204 insertions(+), 162 deletions(-) diff --git a/http/unstable_message_signatures.ts b/http/unstable_message_signatures.ts index cc1c6c05f042..79de9c1d4ff9 100644 --- a/http/unstable_message_signatures.ts +++ b/http/unstable_message_signatures.ts @@ -11,7 +11,7 @@ * Fields Dictionaries ({@link https://www.rfc-editor.org/rfc/rfc9651 | RFC 9651}). * * @example Signing a request - * ```ts + * ```ts ignore * import { signMessage } from "@std/http/unstable-message-signatures"; * * const key = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]); @@ -70,6 +70,7 @@ import { } from "@std/http/unstable-structured-fields"; const UTF8_ENCODER = new TextEncoder(); +const SF_KEY_REGEXP = /^[a-z*][a-z0-9_\-.*]*$/; // ============================================================================= // Public Types @@ -98,15 +99,15 @@ export type SignatureAlgorithm = */ export interface ComponentParameters { /** Strict Structured Field serialization. */ - sf?: boolean; + sf?: true; /** Dictionary member key. */ key?: string; /** Binary-wrapped field values. */ - bs?: boolean; + bs?: true; /** Derive value from the related request. */ - req?: boolean; + req?: true; /** Derive value from trailer fields. */ - tr?: boolean; + tr?: true; /** Query parameter name (for `@query-param`). */ name?: string; } @@ -125,13 +126,34 @@ export interface ComponentIdentifier { parameters?: ComponentParameters; } +/** + * Known derived component names per + * {@link https://www.rfc-editor.org/rfc/rfc9421#section-2.2 | RFC 9421 section 2.2}. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + */ +export type DerivedComponent = + | "@method" + | "@target-uri" + | "@authority" + | "@scheme" + | "@request-target" + | "@path" + | "@query" + | "@query-param" + | "@status"; + /** * Convenience type accepting either a plain string or a full - * {@linkcode ComponentIdentifier}. + * {@linkcode ComponentIdentifier}. Known derived component names are + * autocompleted. * * @experimental **UNSTABLE**: New API, yet to be vetted. */ -export type ComponentInput = string | ComponentIdentifier; +export type ComponentInput = + | DerivedComponent + | (string & NonNullable) + | ComponentIdentifier; /** * Signature parameters used when signing a message. @@ -155,7 +177,7 @@ export interface SignatureParams { nonce?: string; /** Application-specific tag. */ tag?: string; - /** Signature label, defaults to `"sig"`. */ + /** Signature label, defaults to `"sig"`. Must be a valid sf-key (lowercase alphanumeric, `_`, `-`, `.`, `*`). */ label?: string; } @@ -187,7 +209,9 @@ export interface ParsedSignatureParams { * * @experimental **UNSTABLE**: New API, yet to be vetted. */ -export interface SignOptions { +export interface SignOptions< + T extends Request | Response = Request | Response, +> { /** The HTTP request or response to sign. */ message: T; /** Signature parameters. */ @@ -230,17 +254,17 @@ export interface VerifyResult { // Algorithm Dispatch // ============================================================================= -const SIGNATURE_ALGORITHMS: Record = { - "rsa-pss-sha512": true, - "rsa-v1_5-sha256": true, - "hmac-sha256": true, - "ecdsa-p256-sha256": true, - "ecdsa-p384-sha384": true, - "ed25519": true, -}; +const SUPPORTED_ALGORITHMS: ReadonlySet = new Set([ + "rsa-pss-sha512", + "rsa-v1_5-sha256", + "hmac-sha256", + "ecdsa-p256-sha256", + "ecdsa-p384-sha384", + "ed25519", +]); function isSupportedAlgorithm(value: string): value is SignatureAlgorithm { - return value in SIGNATURE_ALGORITHMS; + return SUPPORTED_ALGORITHMS.has(value); } function getSignParams( @@ -270,8 +294,8 @@ function inferAlgorithm(key: CryptoKey): SignatureAlgorithm { if (name === "RSA-PSS") return "rsa-pss-sha512"; if (name === "RSASSA-PKCS1-v1_5") return "rsa-v1_5-sha256"; if (name === "ECDSA") { - const hash = (alg as EcKeyAlgorithm & { namedCurve: string }).namedCurve; - if (hash === "P-384") return "ecdsa-p384-sha384"; + const curve = (alg as EcKeyAlgorithm).namedCurve; + if (curve === "P-384") return "ecdsa-p384-sha384"; return "ecdsa-p256-sha256"; } throw new TypeError(`Cannot infer signature algorithm from key: "${name}"`); @@ -291,7 +315,9 @@ function normalizeIdentifier(input: ComponentInput): ComponentIdentifier { function resolveComponentValue( id: ComponentIdentifier, message: Request | Response, + parsedUrl: { value?: URL }, relatedRequest?: Request, + relatedParsedUrl?: { value?: URL }, ): string { const params = id.parameters ?? {}; @@ -307,6 +333,12 @@ function resolveComponentValue( ); } + if (params.tr) { + throw new TypeError( + `Trailer field resolution (";tr") is not supported for component "${id.name}"`, + ); + } + // Handle ;req parameter if (params.req) { if (message instanceof Request) { @@ -323,103 +355,83 @@ function resolveComponentValue( return resolveComponentValue( { name: id.name, parameters: restParams }, relatedRequest, + relatedParsedUrl ?? {}, ); } if (id.name.startsWith("@")) { - return resolveDerivedComponent(id.name, message, params); + return resolveDerivedComponent(id.name, message, params, parsedUrl); } return resolveFieldComponent(id.name, message, params); } +const REQUEST_ONLY_DERIVED: ReadonlySet = new Set([ + "@method", + "@target-uri", + "@authority", + "@scheme", + "@request-target", + "@path", + "@query", + "@query-param", +]); + function resolveDerivedComponent( name: string, message: Request | Response, params: ComponentParameters, + parsedUrl: { value?: URL }, ): string { - switch (name) { - case "@method": { - if (!(message instanceof Request)) { - throw new TypeError( - `Cannot use "${name}" on a response message`, - ); - } - return message.method; + if (REQUEST_ONLY_DERIVED.has(name)) { + if (!(message instanceof Request)) { + throw new TypeError(`Cannot use "${name}" on a response message`); } - case "@target-uri": { - if (!(message instanceof Request)) { - throw new TypeError( - `Cannot use "${name}" on a response message`, - ); - } - return message.url; + return resolveRequestDerived(name, message, params, parsedUrl); + } + + if (name === "@status") { + if (message instanceof Request) { + throw new TypeError(`Cannot use "${name}" on a request message`); } - case "@authority": { - if (!(message instanceof Request)) { - throw new TypeError( - `Cannot use "${name}" on a response message`, - ); - } - const url = new URL(message.url); + return String(message.status); + } + + throw new TypeError(`Unknown derived component "${name}"`); +} + +function resolveRequestDerived( + name: string, + request: Request, + params: ComponentParameters, + parsedUrl: { value?: URL }, +): string { + if (name === "@method") return request.method.toUpperCase(); + if (name === "@target-uri") return request.url; + + const url = parsedUrl.value ??= new URL(request.url); + switch (name) { + case "@authority": return url.host; - } - case "@scheme": { - if (!(message instanceof Request)) { - throw new TypeError( - `Cannot use "${name}" on a response message`, - ); - } - const url = new URL(message.url); + case "@scheme": return url.protocol.slice(0, -1); - } - case "@request-target": { - if (!(message instanceof Request)) { - throw new TypeError( - `Cannot use "${name}" on a response message`, - ); - } - const url = new URL(message.url); + case "@request-target": return url.pathname + url.search; - } - case "@path": { - if (!(message instanceof Request)) { - throw new TypeError( - `Cannot use "${name}" on a response message`, - ); - } - const url = new URL(message.url); + case "@path": return url.pathname || "/"; - } - case "@query": { - if (!(message instanceof Request)) { - throw new TypeError( - `Cannot use "${name}" on a response message`, - ); - } - const url = new URL(message.url); - // search includes the "?" prefix, but if absent we return "?" + case "@query": return url.search || "?"; - } case "@query-param": { - if (!(message instanceof Request)) { - throw new TypeError( - `Cannot use "${name}" on a response message`, - ); - } if (params.name === undefined) { throw new TypeError( `Component "${name}" requires "name" parameter`, ); } - const url = new URL(message.url); const decoded = decodeURIComponent(params.name); const searchParams = new URLSearchParams(url.search); const values: string[] = []; for (const [k, v] of searchParams) { - if (k === decoded) { - values.push(v); - } + if (k === decoded) values.push(v); } if (values.length === 0) { throw new TypeError( @@ -431,28 +443,22 @@ function resolveDerivedComponent( `Query parameter "${params.name}" occurs multiple times`, ); } - // Re-encode per the spec's application/x-www-form-urlencoded serializing return encodeQueryParamValue(values[0]!); } - case "@status": { - if (message instanceof Request) { - throw new TypeError( - `Cannot use "${name}" on a request message`, - ); - } - return String(message.status); - } default: - throw new TypeError( - `Unknown derived component "${name}"`, - ); + throw new TypeError(`Unknown derived component "${name}"`); } } function encodeQueryParamValue(value: string): string { - // Percent-encode per application/x-www-form-urlencoded serializing - // then convert + back to %20 for the signature base - return encodeURIComponent(value).replace(/%20/g, "%20"); + // RFC 9421 section 2.2.8: use "percent-encode after encoding" from the + // WHATWG URL spec (application/x-www-form-urlencoded serializing), which + // differs from encodeURIComponent in that it also encodes !'()* characters. + // URLSearchParams serializes with + for spaces; convert back to %20. + return new URLSearchParams([["", value]]).toString().slice(1).replaceAll( + "+", + "%20", + ); } function resolveFieldComponent( @@ -527,9 +533,11 @@ function resolveDictionaryMember( } function resolveBinaryWrapped(headerValue: string): string { - // Each field value is individually wrapped as a Byte Sequence - // For the Fetch API, headers.get() already combines multiple values - // We split on ", " to get individual values, then wrap each as binary + // Each field value is individually wrapped as a Byte Sequence. + // The Fetch API Headers.get() joins multiple values with ", " and does not + // expose getAll(). Splitting on ", " is therefore the best we can do, but + // it will mishandle single header values that contain a literal ", " (e.g. + // the Date header). Avoid using ;bs on such fields. const values = headerValue.split(", "); const items: Item[] = values.map((v) => { const bytes = UTF8_ENCODER.encode(v.trim()); @@ -563,11 +571,15 @@ function buildSignatureParamsValue( const sfParams = new Map(); const p = id.parameters ?? {}; if (p.sf) sfParams.set("sf", { type: "boolean", value: true }); - if (p.key !== undefined) sfParams.set("key", { type: "string", value: p.key }); + if (p.key !== undefined) { + sfParams.set("key", { type: "string", value: p.key }); + } if (p.bs) sfParams.set("bs", { type: "boolean", value: true }); if (p.req) sfParams.set("req", { type: "boolean", value: true }); if (p.tr) sfParams.set("tr", { type: "boolean", value: true }); - if (p.name !== undefined) sfParams.set("name", { type: "string", value: p.name }); + if (p.name !== undefined) { + sfParams.set("name", { type: "string", value: p.name }); + } return sfItem(sfString(id.name), sfParams); }); @@ -617,7 +629,11 @@ function serializeBareItemValue(bareItem: BareItem): string { } } -/** Options for {@linkcode createSignatureBase}. */ +/** + * Options for {@linkcode createSignatureBase}. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + */ export interface CreateSignatureBaseOptions { /** The HTTP request or response. */ message: Request | Response; @@ -663,22 +679,35 @@ export function createSignatureBase( const { message, params, request: relatedRequest } = options; const components = params.components.map(normalizeIdentifier); - // Check for duplicate component identifiers const seen = new Set(); + const lines: string[] = []; + const parsedUrl: { value?: URL } = {}; + const relatedParsedUrl: { value?: URL } = {}; for (const id of components) { - const key = serializeComponentIdentifier(id); - if (seen.has(key)) { + if (id.name === "@signature-params") { throw new TypeError( - `Duplicate component identifier ${key} in covered components`, + `"@signature-params" must not be listed in covered components`, + ); + } + if (id.name !== id.name.toLowerCase()) { + throw new TypeError( + `Component name "${id.name}" must be lowercase`, ); } - seen.add(key); - } - - const lines: string[] = []; - for (const id of components) { const serializedId = serializeComponentIdentifier(id); - const value = resolveComponentValue(id, message, relatedRequest); + if (seen.has(serializedId)) { + throw new TypeError( + `Duplicate component identifier ${serializedId} in covered components`, + ); + } + seen.add(serializedId); + const value = resolveComponentValue( + id, + message, + parsedUrl, + relatedRequest, + relatedParsedUrl, + ); lines.push(`${serializedId}: ${value}`); } @@ -701,6 +730,11 @@ function validateTimestamp(value: number, name: string): void { } function validateSignParams(params: SignatureParams): void { + if (params.label !== undefined && !SF_KEY_REGEXP.test(params.label)) { + throw new TypeError( + `Invalid signature label "${params.label}": must be a valid sf-key (lowercase alphanumeric, _, -, ., *)`, + ); + } if (params.created !== undefined) { validateTimestamp(params.created, "created"); } @@ -750,7 +784,7 @@ function validateSignParams(params: SignatureParams): void { * assert(signed.headers.has("Signature-Input")); * ``` * - * @typeParam T The type of the message (Request or Response). + * @typeParam T The message type ({@linkcode Request} or {@linkcode Response}). * @param options Signing options. * @returns A new message with signature headers appended. */ @@ -773,8 +807,6 @@ export async function signMessage( const signatureBytes: Uint8Array = new Uint8Array( await crypto.subtle.sign(signParams, key, baseBytes), ); - // Web Crypto in Deno/browsers already produces raw (r, s) for ECDSA, - // which is the format RFC 9421 requires. No DER conversion needed. // Build Signature-Input value const components = params.components.map(normalizeIdentifier); @@ -786,27 +818,11 @@ export async function signMessage( new Map([[label, sfItem(binary(signatureBytes))]]), ); - // Clone the message and append headers - if (message instanceof Request) { - const clone = new Request(message, { - headers: new Headers(message.headers), - }); - appendHeader(clone.headers, "Signature-Input", sigInputHeader); - appendHeader(clone.headers, "Signature", sigHeader); - return clone as T; - } else { - const bodyBytes = message.body - ? await message.clone().arrayBuffer() - : null; - const clone = new Response(bodyBytes, { - status: message.status, - statusText: message.statusText, - headers: new Headers(message.headers), - }); - appendHeader(clone.headers, "Signature-Input", sigInputHeader); - appendHeader(clone.headers, "Signature", sigHeader); - return clone as T; - } + // clone() preserves the concrete type at runtime + const clone = message.clone() as T; + appendHeader(clone.headers, "Signature-Input", sigInputHeader); + appendHeader(clone.headers, "Signature", sigHeader); + return clone; } function appendHeader(headers: Headers, name: string, value: string): void { @@ -840,7 +856,9 @@ function appendHeader(headers: Headers, name: string, value: string): void { * ``` * * @param message The HTTP request or response to verify. - * @param keyLookup Resolves a key identifier to a CryptoKey. + * @param keyLookup Resolves a key identifier to a CryptoKey, or `null` if the + * key is not found. When the signature has no `keyid` parameter, the empty + * string `""` is passed. * @param options Optional verification constraints. * @returns Array of verified signature results. */ @@ -849,15 +867,16 @@ export async function verifyMessage( keyLookup: ( keyId: string, algorithm?: SignatureAlgorithm, - ) => Promise | CryptoKey, + ) => Promise | CryptoKey | null, options?: VerifyOptions, ): Promise { - if (options?.maxAge !== undefined) { - if (!Number.isInteger(options.maxAge) || options.maxAge < 0) { - throw new RangeError( - `maxAge must be a non-negative integer, got ${options.maxAge}`, - ); - } + if ( + options?.maxAge !== undefined && + (!Number.isInteger(options.maxAge) || options.maxAge < 0) + ) { + throw new RangeError( + `maxAge must be a non-negative integer, got ${options.maxAge}`, + ); } const sigInputHeader = message.headers.get("Signature-Input"); @@ -889,6 +908,7 @@ export async function verifyMessage( } const results: VerifyResult[] = []; + const now = Math.floor(Date.now() / 1000); for (const [label, sigInputMember] of sigInputDict) { // Filter by labels option @@ -919,9 +939,22 @@ export async function verifyMessage( } } + // Check expires + if (parsedParams.expires !== undefined) { + if (now > parsedParams.expires) { + throw new Error( + `Signature "${label}" has expired (past "expires" timestamp)`, + ); + } + } + // Check maxAge - if (options?.maxAge !== undefined && parsedParams.created !== undefined) { - const now = Math.floor(Date.now() / 1000); + if (options?.maxAge !== undefined) { + if (parsedParams.created === undefined) { + throw new Error( + `Signature "${label}" has no "created" timestamp but maxAge was requested`, + ); + } if (now - parsedParams.created > options.maxAge) { throw new Error(`Signature "${label}" has expired`); } @@ -930,10 +963,13 @@ export async function verifyMessage( // Reconstruct signature base const reconstructedParams: SignatureParams = { components: parsedParams.components, - ...(parsedParams.algorithm !== undefined && { algorithm: parsedParams.algorithm }), + ...(parsedParams.algorithm !== undefined && + { algorithm: parsedParams.algorithm }), ...(parsedParams.keyId !== undefined && { keyId: parsedParams.keyId }), - ...(parsedParams.created !== undefined && { created: parsedParams.created }), - ...(parsedParams.expires !== undefined && { expires: parsedParams.expires }), + ...(parsedParams.created !== undefined && + { created: parsedParams.created }), + ...(parsedParams.expires !== undefined && + { expires: parsedParams.expires }), ...(parsedParams.nonce !== undefined && { nonce: parsedParams.nonce }), ...(parsedParams.tag !== undefined && { tag: parsedParams.tag }), }; @@ -957,18 +993,21 @@ export async function verifyMessage( `Signature member "${label}" is not a Byte Sequence`, ); } - const sigBytes: Uint8Array = new Uint8Array(sigMember.value.value); + const sigBytes: Uint8Array = new Uint8Array( + sigMember.value.value, + ); // Look up key const algorithm = parsedParams.algorithm ?? undefined; const keyId = parsedParams.keyId ?? ""; const verifyKey = await keyLookup(keyId, algorithm); + if (verifyKey === null) { + throw new TypeError(`Key not found for keyId "${keyId}"`); + } const verifyAlgorithm = algorithm ?? inferAlgorithm(verifyKey); const verifyParams = getSignParams(verifyAlgorithm); - // Web Crypto in Deno/browsers accepts raw (r, s) for ECDSA directly, - // which is the format RFC 9421 uses. No DER conversion needed. const valid = await crypto.subtle.verify( verifyParams, verifyKey, @@ -1005,19 +1044,19 @@ function parseSignatureInput( for (const [key, value] of member.parameters) { switch (key) { case "sf": - params.sf = value.type === "boolean" ? value.value : true; + if (value.type === "boolean" && value.value) params.sf = true; break; case "key": if (value.type === "string") params.key = value.value; break; case "bs": - params.bs = value.type === "boolean" ? value.value : true; + if (value.type === "boolean" && value.value) params.bs = true; break; case "req": - params.req = value.type === "boolean" ? value.value : true; + if (value.type === "boolean" && value.value) params.req = true; break; case "tr": - params.tr = value.type === "boolean" ? value.value : true; + if (value.type === "boolean" && value.value) params.tr = true; break; case "name": if (value.type === "string") params.name = value.value; diff --git a/http/unstable_message_signatures_test.ts b/http/unstable_message_signatures_test.ts index da2994d50939..9ed7377cbb82 100644 --- a/http/unstable_message_signatures_test.ts +++ b/http/unstable_message_signatures_test.ts @@ -704,7 +704,10 @@ Deno.test("verifyMessage() filters by labels option", async () => { Deno.test("verifyMessage() throws TypeError on missing Signature-Input header", async () => { const request = new Request("https://example.com/", { method: "GET" }); await assertRejects( - () => verifyMessage(request, () => { throw new Error("unreachable"); }), + () => + verifyMessage(request, () => { + throw new Error("unreachable"); + }), TypeError, 'Missing "Signature-Input" header', ); From e19dea303dd33c06c31c5ed536207fbf95048304 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Tue, 10 Mar 2026 12:51:25 +0100 Subject: [PATCH 4/6] Fix CI errors --- http/unstable_message_signatures.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/http/unstable_message_signatures.ts b/http/unstable_message_signatures.ts index 79de9c1d4ff9..101db3a602b9 100644 --- a/http/unstable_message_signatures.ts +++ b/http/unstable_message_signatures.ts @@ -47,6 +47,8 @@ * @module */ +import type { Uint8Array_ } from "./_types.ts"; +export type { Uint8Array_ }; import type { BareItem, Dictionary, @@ -768,7 +770,7 @@ function validateSignParams(params: SignatureParams): void { * import { signMessage } from "@std/http/unstable-message-signatures"; * import { assert } from "@std/assert"; * - * const key = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]); + * const key = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]) as CryptoKeyPair; * const request = new Request("https://example.com/", { method: "POST" }); * const signed = await signMessage({ * message: request, @@ -804,7 +806,7 @@ export async function signMessage( const baseBytes = UTF8_ENCODER.encode(base); const signParams = getSignParams(algorithm); - const signatureBytes: Uint8Array = new Uint8Array( + const signatureBytes: Uint8Array_ = new Uint8Array( await crypto.subtle.sign(signParams, key, baseBytes), ); @@ -993,7 +995,7 @@ export async function verifyMessage( `Signature member "${label}" is not a Byte Sequence`, ); } - const sigBytes: Uint8Array = new Uint8Array( + const sigBytes: Uint8Array_ = new Uint8Array( sigMember.value.value, ); From 291ccdfac618ebfcb980be3701c4b3949d89ac20 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Tue, 10 Mar 2026 12:55:17 +0100 Subject: [PATCH 5/6] add _types --- http/_types.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 http/_types.ts diff --git a/http/_types.ts b/http/_types.ts new file mode 100644 index 000000000000..d701d491e46d --- /dev/null +++ b/http/_types.ts @@ -0,0 +1,11 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +/** + * Proxy type of {@code Uint8Array} or {@code Uint8Array} in TypeScript 5.7 or below respectively. + * + * This type is internal utility type and should not be used directly. + * + * @internal @private + */ + +export type Uint8Array_ = ReturnType; From 035e57c3ac18490f33dabb7b468c73fa2e5921e5 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Tue, 10 Mar 2026 13:08:28 +0100 Subject: [PATCH 6/6] test coverage --- http/unstable_message_signatures_test.ts | 1188 +++++++++++++--------- 1 file changed, 699 insertions(+), 489 deletions(-) diff --git a/http/unstable_message_signatures_test.ts b/http/unstable_message_signatures_test.ts index 9ed7377cbb82..77e5589ffccf 100644 --- a/http/unstable_message_signatures_test.ts +++ b/http/unstable_message_signatures_test.ts @@ -12,6 +12,8 @@ import type { SignatureAlgorithm } from "./unstable_message_signatures.ts"; // Helpers // ============================================================================= +const NOW = Math.floor(Date.now() / 1000); + function makeRequest( url = "https://example.com/foo?param=Value&Pet=dog", init?: RequestInit, @@ -31,146 +33,110 @@ function makeRequest( }); } -async function generateEd25519(): Promise { - return await crypto.subtle.generateKey( - "Ed25519", - true, - ["sign", "verify"], - ) as CryptoKeyPair; -} - -async function generateHmacKey(): Promise { - return await crypto.subtle.generateKey( - { name: "HMAC", hash: "SHA-256" }, - true, - ["sign", "verify"], - ); -} - -async function generateEcdsaP256(): Promise { - return await crypto.subtle.generateKey( - { name: "ECDSA", namedCurve: "P-256" }, - true, - ["sign", "verify"], - ) as CryptoKeyPair; -} - -async function generateEcdsaP384(): Promise { - return await crypto.subtle.generateKey( - { name: "ECDSA", namedCurve: "P-384" }, - true, - ["sign", "verify"], - ) as CryptoKeyPair; -} - -async function generateRsaPss(): Promise { - return await crypto.subtle.generateKey( - { - name: "RSA-PSS", - modulusLength: 2048, - publicExponent: new Uint8Array([1, 0, 1]), - hash: "SHA-512", - }, - true, - ["sign", "verify"], - ) as CryptoKeyPair; -} - -async function generateRsaPkcs1(): Promise { - return await crypto.subtle.generateKey( - { - name: "RSASSA-PKCS1-v1_5", - modulusLength: 2048, - publicExponent: new Uint8Array([1, 0, 1]), - hash: "SHA-256", - }, - true, - ["sign", "verify"], - ) as CryptoKeyPair; +type KeyGenerator = () => Promise; + +const KEY_GENERATORS: Record = { + ed25519: () => + crypto.subtle.generateKey("Ed25519", true, [ + "sign", + "verify", + ]) as Promise, + "hmac-sha256": () => + crypto.subtle.generateKey({ name: "HMAC", hash: "SHA-256" }, true, [ + "sign", + "verify", + ]), + "ecdsa-p256-sha256": () => + crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ) as Promise, + "ecdsa-p384-sha384": () => + crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-384" }, + true, + ["sign", "verify"], + ) as Promise, + "rsa-pss-sha512": () => + crypto.subtle.generateKey( + { + name: "RSA-PSS", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-512", + }, + true, + ["sign", "verify"], + ) as Promise, + "rsa-v1_5-sha256": () => + crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ) as Promise, +}; + +function keys( + keyOrPair: CryptoKeyPair | CryptoKey, +): { privateKey: CryptoKey; publicKey: CryptoKey } { + if ("privateKey" in keyOrPair) return keyOrPair; + return { privateKey: keyOrPair, publicKey: keyOrPair }; } // ============================================================================= -// Layer 1: Component value resolution — derived components +// createSignatureBase — derived components // ============================================================================= -Deno.test("createSignatureBase() resolves @method", () => { - const request = new Request("https://example.com/path", { method: "POST" }); - const base = createSignatureBase({ - message: request, - params: { components: ["@method"], created: 1618884473 }, - }); - assertEquals(base.split("\n")[0], '"@method": POST'); -}); - -Deno.test("createSignatureBase() resolves @target-uri", () => { - const request = new Request("https://www.example.com/path?param=value"); +Deno.test("createSignatureBase() resolves all request-derived components", () => { + const request = new Request( + "https://www.example.com/path?param=value", + { method: "POST" }, + ); const base = createSignatureBase({ message: request, - params: { components: ["@target-uri"], created: 1618884473 }, + params: { + components: [ + "@method", + "@target-uri", + "@authority", + "@scheme", + "@request-target", + "@path", + "@query", + { name: "@query-param", parameters: { name: "param" } }, + ], + created: 1618884473, + }, }); + const lines = base.split("\n"); + assertEquals(lines[0], '"@method": POST'); assertEquals( - base.split("\n")[0], + lines[1], '"@target-uri": https://www.example.com/path?param=value', ); + assertEquals(lines[2], '"@authority": www.example.com'); + assertEquals(lines[3], '"@scheme": https'); + assertEquals(lines[4], '"@request-target": /path?param=value'); + assertEquals(lines[5], '"@path": /path'); + assertEquals(lines[6], '"@query": ?param=value'); + assertEquals(lines[7], '"@query-param";name="param": value'); }); -Deno.test("createSignatureBase() resolves @authority with default port omitted", () => { - const request = new Request("https://www.example.com/path"); - const base = createSignatureBase({ - message: request, - params: { components: ["@authority"], created: 1618884473 }, - }); - assertEquals(base.split("\n")[0], '"@authority": www.example.com'); -}); - -Deno.test("createSignatureBase() resolves @scheme", () => { - const request = new Request("https://example.com/"); - const base = createSignatureBase({ - message: request, - params: { components: ["@scheme"], created: 1618884473 }, - }); - assertEquals(base.split("\n")[0], '"@scheme": https'); -}); - -Deno.test("createSignatureBase() resolves @request-target", () => { - const request = new Request("https://example.com/path?param=value"); - const base = createSignatureBase({ - message: request, - params: { components: ["@request-target"], created: 1618884473 }, - }); - assertEquals(base.split("\n")[0], '"@request-target": /path?param=value'); -}); - -Deno.test("createSignatureBase() resolves @path with empty path normalized to slash", () => { +Deno.test("createSignatureBase() normalizes empty path to / and absent query to ?", () => { const request = new Request("https://example.com"); const base = createSignatureBase({ message: request, - params: { components: ["@path"], created: 1618884473 }, - }); - assertEquals(base.split("\n")[0], '"@path": /'); -}); - -Deno.test("createSignatureBase() resolves @query with absent query as ?", () => { - const request = new Request("https://example.com/path"); - const base = createSignatureBase({ - message: request, - params: { components: ["@query"], created: 1618884473 }, - }); - assertEquals(base.split("\n")[0], '"@query": ?'); -}); - -Deno.test("createSignatureBase() resolves @query-param with percent-encoding", () => { - const request = new Request( - "https://example.com/path?param=value&foo=bar", - ); - const base = createSignatureBase({ - message: request, - params: { - components: [{ name: "@query-param", parameters: { name: "param" } }], - created: 1618884473, - }, + params: { components: ["@path", "@query"], created: 1618884473 }, }); - assertEquals(base.split("\n")[0], '"@query-param";name="param": value'); + const lines = base.split("\n"); + assertEquals(lines[0], '"@path": /'); + assertEquals(lines[1], '"@query": ?'); }); Deno.test("createSignatureBase() resolves @status from response", () => { @@ -183,25 +149,22 @@ Deno.test("createSignatureBase() resolves @status from response", () => { }); // ============================================================================= -// Layer 1b: Field component parameters +// createSignatureBase — field component parameters (;sf, ;key, ;bs) // ============================================================================= -Deno.test("createSignatureBase() resolves plain header field value", () => { +Deno.test("createSignatureBase() resolves plain header, ;sf, ;key item, ;key inner-list, and ;bs", () => { const request = new Request("https://example.com/", { - headers: { "Content-Type": "application/json" }, - }); - const base = createSignatureBase({ - message: request, - params: { components: ["content-type"], created: 1618884473 }, + headers: { + "Content-Type": "application/json", + "Example-Dict": "a=1, b=2;x=1;y=2, c=(a b c)", + "List-Dict": "items=(1 2 3), other=4", + "Example-Header": "value, with, lots", + "X-Num": "42", + }, }); - assertEquals(base.split("\n")[0], '"content-type": application/json'); -}); -Deno.test("createSignatureBase() resolves ;sf strict serialization", () => { - const request = new Request("https://example.com/", { - headers: { "Example-Dict": "a=1, b=2;x=1;y=2, c=(a b c)" }, - }); - const base = createSignatureBase({ + // ;sf — dictionary + const sfDict = createSignatureBase({ message: request, params: { components: [{ name: "example-dict", parameters: { sf: true } }], @@ -209,105 +172,53 @@ Deno.test("createSignatureBase() resolves ;sf strict serialization", () => { }, }); assertEquals( - base.split("\n")[0], + sfDict.split("\n")[0], '"example-dict";sf: a=1, b=2;x=1;y=2, c=(a b c)', ); -}); -Deno.test("createSignatureBase() resolves ;key dictionary member", () => { - const request = new Request("https://example.com/", { - headers: { "Example-Dict": "a=1, b=2;x=1;y=2, c=(a b c), d" }, + // ;sf — item fallback + const sfItem = createSignatureBase({ + message: request, + params: { + components: [{ name: "x-num", parameters: { sf: true } }], + created: 1618884473, + }, }); - const base = createSignatureBase({ + assertEquals(sfItem.split("\n")[0], '"x-num";sf: 42'); + + // ;key — scalar member + const keyScalar = createSignatureBase({ message: request, params: { components: [{ name: "example-dict", parameters: { key: "a" } }], created: 1618884473, }, }); - assertEquals(base.split("\n")[0], '"example-dict";key="a": 1'); -}); + assertEquals(keyScalar.split("\n")[0], '"example-dict";key="a": 1'); -Deno.test("createSignatureBase() resolves ;bs binary-wrapped field", () => { - const request = new Request("https://example.com/", { - headers: { "Example-Header": "value, with, lots" }, - }); - const base = createSignatureBase({ + // ;key — inner list member + const keyList = createSignatureBase({ message: request, params: { - components: [{ name: "example-header", parameters: { bs: true } }], + components: [{ name: "list-dict", parameters: { key: "items" } }], created: 1618884473, }, }); - const line = base.split("\n")[0]!; - // The bs parameter wraps each value as a Byte Sequence - assertEquals(line.startsWith('"example-header";bs: :'), true); -}); - -// ============================================================================= -// Layer 1c: Component resolution errors -// ============================================================================= - -Deno.test("createSignatureBase() throws TypeError on missing header field", () => { - const request = new Request("https://example.com/"); - assertThrows( - () => - createSignatureBase({ - message: request, - params: { components: ["x-nonexistent"] }, - }), - TypeError, - 'Missing "x-nonexistent" header field', - ); -}); - -Deno.test("createSignatureBase() throws TypeError on unknown derived component", () => { - const request = new Request("https://example.com/"); - assertThrows( - () => - createSignatureBase({ - message: request, - params: { components: ["@unknown"] }, - }), - TypeError, - 'Unknown derived component "@unknown"', - ); -}); - -Deno.test("createSignatureBase() throws TypeError on @status for request message", () => { - const request = new Request("https://example.com/"); - assertThrows( - () => - createSignatureBase({ - message: request, - params: { components: ["@status"] }, - }), - TypeError, - 'Cannot use "@status" on a request message', - ); -}); + assertEquals(keyList.split("\n")[0], '"list-dict";key="items": (1 2 3)'); -Deno.test("createSignatureBase() throws TypeError on incompatible ;bs and ;sf", () => { - const request = new Request("https://example.com/", { - headers: { "Example": "value" }, + // ;bs + const bs = createSignatureBase({ + message: request, + params: { + components: [{ name: "example-header", parameters: { bs: true } }], + created: 1618884473, + }, }); - assertThrows( - () => - createSignatureBase({ - message: request, - params: { - components: [ - { name: "example", parameters: { bs: true, sf: true } }, - ], - }, - }), - TypeError, - 'Cannot combine "bs" and "sf"', - ); + assertEquals(bs.split("\n")[0]!.startsWith('"example-header";bs: :'), true); }); // ============================================================================= -// Layer 1d: ;req parameter +// createSignatureBase — ;req parameter // ============================================================================= Deno.test("createSignatureBase() resolves ;req from related request", () => { @@ -330,7 +241,7 @@ Deno.test("createSignatureBase() resolves ;req from related request", () => { }); // ============================================================================= -// Layer 2: Signature base construction +// createSignatureBase — full RFC 9421 section 2.5 example // ============================================================================= Deno.test("createSignatureBase() builds correct base for RFC 9421 section 2.5 example", () => { @@ -364,202 +275,358 @@ Deno.test("createSignatureBase() builds correct base for RFC 9421 section 2.5 ex assertEquals(base, expected); }); -Deno.test("createSignatureBase() rejects duplicate component identifier", () => { +// ============================================================================= +// createSignatureBase — error paths +// ============================================================================= + +Deno.test("createSignatureBase() rejects invalid component configurations", () => { const request = new Request("https://example.com/", { - headers: { "Content-Type": "text/plain" }, + headers: { "Example": "value", "Content-Type": "text/plain" }, }); + const response = new Response(null, { status: 200 }); + + // Missing header assertThrows( () => createSignatureBase({ message: request, - params: { components: ["content-type", "content-type"] }, + params: { components: ["x-nonexistent"] }, }), TypeError, - "Duplicate component identifier", + 'Missing "x-nonexistent" header field', ); -}); - -// ============================================================================= -// Layer 2b: ECDSA DER/raw conversion (tested via round-trip) -// ============================================================================= -Deno.test("signMessage() and verifyMessage() round-trip with ecdsa-p256-sha256 exercises DER/raw conversion", async () => { - const keys = await generateEcdsaP256(); - const request = new Request("https://example.com/", { method: "GET" }); - const signed = await signMessage({ - message: request, - params: { - components: ["@method"], - keyId: "test-ecdsa-p256", - algorithm: "ecdsa-p256-sha256", - created: Math.floor(Date.now() / 1000), - }, - key: keys.privateKey, - }); + // Unknown derived component + assertThrows( + () => + createSignatureBase({ + message: request, + params: { components: ["@unknown"] }, + }), + TypeError, + 'Unknown derived component "@unknown"', + ); - const results = await verifyMessage( - signed, - () => keys.publicKey, + // @status on request + assertThrows( + () => + createSignatureBase({ + message: request, + params: { components: ["@status"] }, + }), + TypeError, + 'Cannot use "@status" on a request message', ); - assertEquals(results.length, 1); - assertEquals(results[0]!.label, "sig"); -}); -// ============================================================================= -// Layer 3: Sign and verify integration — one per algorithm -// ============================================================================= + // Request-only component on response + assertThrows( + () => + createSignatureBase({ + message: response, + params: { components: ["@method"] }, + }), + TypeError, + 'Cannot use "@method" on a response message', + ); -Deno.test("signMessage() and verifyMessage() round-trip with ed25519", async () => { - const keys = await generateEd25519(); - const request = new Request("https://example.com/", { method: "POST" }); - const signed = await signMessage({ - message: request, - params: { - components: ["@method", "@authority"], - keyId: "test-ed25519", - created: Math.floor(Date.now() / 1000), - }, - key: keys.privateKey, - }); + // ;bs + ;sf + assertThrows( + () => + createSignatureBase({ + message: request, + params: { + components: [ + { name: "example", parameters: { bs: true, sf: true } }, + ], + }, + }), + TypeError, + 'Cannot combine "bs" and "sf"', + ); - const results = await verifyMessage(signed, () => keys.publicKey); - assertEquals(results.length, 1); - assertEquals(results[0]!.params.keyId, "test-ed25519"); -}); + // ;bs + ;key + assertThrows( + () => + createSignatureBase({ + message: request, + params: { + components: [ + { name: "example", parameters: { bs: true, key: "a" } }, + ], + }, + }), + TypeError, + 'Cannot combine "bs" and "key"', + ); -Deno.test("signMessage() and verifyMessage() round-trip with hmac-sha256", async () => { - const key = await generateHmacKey(); - const request = new Request("https://example.com/", { method: "GET" }); - const signed = await signMessage({ - message: request, - params: { - components: ["@method"], - keyId: "test-hmac", - algorithm: "hmac-sha256", - created: Math.floor(Date.now() / 1000), - }, - key, - }); + // ;tr unsupported + assertThrows( + () => + createSignatureBase({ + message: request, + params: { + components: [{ name: "example", parameters: { tr: true } }], + }, + }), + TypeError, + "Trailer field resolution", + ); - const results = await verifyMessage(signed, () => key); - assertEquals(results.length, 1); -}); + // ;req on a request + assertThrows( + () => + createSignatureBase({ + message: request, + params: { + components: [{ name: "@method", parameters: { req: true } }], + }, + }), + TypeError, + 'Cannot use "req" parameter', + ); -Deno.test("signMessage() and verifyMessage() round-trip with ecdsa-p384-sha384", async () => { - const keys = await generateEcdsaP384(); - const request = new Request("https://example.com/", { method: "GET" }); - const signed = await signMessage({ - message: request, - params: { - components: ["@method"], - keyId: "test-ecdsa-p384", - algorithm: "ecdsa-p384-sha384", - created: Math.floor(Date.now() / 1000), - }, - key: keys.privateKey, - }); + // ;req without related request + assertThrows( + () => + createSignatureBase({ + message: response, + params: { + components: [{ name: "@method", parameters: { req: true } }], + }, + }), + TypeError, + "no related request provided", + ); - const results = await verifyMessage(signed, () => keys.publicKey); - assertEquals(results.length, 1); + // Duplicate component + assertThrows( + () => + createSignatureBase({ + message: request, + params: { components: ["content-type", "content-type"] }, + }), + TypeError, + "Duplicate component identifier", + ); + + // @signature-params in components + assertThrows( + () => + createSignatureBase({ + message: request, + params: { components: ["@signature-params"] }, + }), + TypeError, + '"@signature-params" must not be listed', + ); + + // Uppercase component name + assertThrows( + () => + createSignatureBase({ + message: request, + params: { components: ["Content-Type"] }, + }), + TypeError, + "must be lowercase", + ); }); -Deno.test("signMessage() and verifyMessage() round-trip with rsa-pss-sha512", async () => { - const keys = await generateRsaPss(); - const request = new Request("https://example.com/", { method: "GET" }); - const signed = await signMessage({ - message: request, - params: { - components: ["@method"], - keyId: "test-rsa-pss", - algorithm: "rsa-pss-sha512", - created: Math.floor(Date.now() / 1000), - }, - key: keys.privateKey, - }); +Deno.test("createSignatureBase() rejects @query-param edge cases", () => { + // Missing name parameter + assertThrows( + () => + createSignatureBase({ + message: new Request("https://example.com/path?foo=bar"), + params: { components: [{ name: "@query-param" }] }, + }), + TypeError, + 'requires "name" parameter', + ); - const results = await verifyMessage(signed, () => keys.publicKey); - assertEquals(results.length, 1); + // Non-existent parameter + assertThrows( + () => + createSignatureBase({ + message: new Request("https://example.com/path?foo=bar"), + params: { + components: [ + { name: "@query-param", parameters: { name: "nonexistent" } }, + ], + }, + }), + TypeError, + "not found in request URL", + ); + + // Duplicate parameter + assertThrows( + () => + createSignatureBase({ + message: new Request("https://example.com/path?foo=1&foo=2"), + params: { + components: [ + { name: "@query-param", parameters: { name: "foo" } }, + ], + }, + }), + TypeError, + "occurs multiple times", + ); }); -Deno.test("signMessage() and verifyMessage() round-trip with rsa-v1_5-sha256", async () => { - const keys = await generateRsaPkcs1(); - const request = new Request("https://example.com/", { method: "GET" }); - const signed = await signMessage({ - message: request, - params: { - components: ["@method"], - keyId: "test-rsa-pkcs1", - algorithm: "rsa-v1_5-sha256", - created: Math.floor(Date.now() / 1000), - }, - key: keys.privateKey, - }); +Deno.test("createSignatureBase() rejects ;sf and ;key errors", () => { + // ;sf with unparseable value + assertThrows( + () => + createSignatureBase({ + message: new Request("https://example.com/", { + headers: { "X-Bad": "@@@ not a structured field @@@" }, + }), + params: { + components: [{ name: "x-bad", parameters: { sf: true } }], + }, + }), + TypeError, + 'Cannot apply "sf" parameter', + ); - const results = await verifyMessage(signed, () => keys.publicKey); - assertEquals(results.length, 1); + // ;key with non-dictionary header + assertThrows( + () => + createSignatureBase({ + message: new Request("https://example.com/", { + headers: { "X-Simple": "just a plain value" }, + }), + params: { + components: [{ name: "x-simple", parameters: { key: "a" } }], + }, + }), + TypeError, + 'Cannot parse "x-simple" as Dictionary', + ); + + // ;key with missing key + assertThrows( + () => + createSignatureBase({ + message: new Request("https://example.com/", { + headers: { "Example-Dict": "a=1, b=2" }, + }), + params: { + components: [ + { name: "example-dict", parameters: { key: "nonexistent" } }, + ], + }, + }), + TypeError, + 'Dictionary key "nonexistent" not found', + ); }); // ============================================================================= -// Layer 3b: signMessage behaviour +// Sign and verify round-trip — all algorithms (inferred from key) +// ============================================================================= + +for ( + const algorithm of [ + "ed25519", + "hmac-sha256", + "ecdsa-p256-sha256", + "ecdsa-p384-sha384", + "rsa-pss-sha512", + "rsa-v1_5-sha256", + ] as const +) { + Deno.test(`signMessage() and verifyMessage() round-trip with ${algorithm}`, async () => { + const keyOrPair = await KEY_GENERATORS[algorithm]!(); + const { privateKey, publicKey } = keys(keyOrPair); + const request = new Request("https://example.com/", { method: "POST" }); + + const signed = await signMessage({ + message: request, + params: { + components: ["@method", "@authority"], + keyId: `test-${algorithm}`, + created: NOW, + }, + key: privateKey, + }); + + const results = await verifyMessage(signed, () => publicKey); + assertEquals(results.length, 1); + assertEquals(results[0]!.label, "sig"); + assertEquals(results[0]!.params.keyId, `test-${algorithm}`); + }); +} + +// ============================================================================= +// signMessage — behaviour and validation // ============================================================================= -Deno.test("signMessage() preserves existing headers on the message", async () => { - const keys = await generateEd25519(); +Deno.test("signMessage() preserves headers, defaults label, and returns correct type", async () => { + const keyOrPair = await KEY_GENERATORS["ed25519"]!(); + const { privateKey, publicKey } = keys(keyOrPair); + + // Request: preserves existing headers, defaults label to "sig" const request = new Request("https://example.com/", { method: "GET", headers: { "X-Custom": "preserved" }, }); - const signed = await signMessage({ + const signedReq = await signMessage({ message: request, - params: { - components: ["@method"], - keyId: "k", - created: Math.floor(Date.now() / 1000), - }, - key: keys.privateKey, + params: { components: ["@method"], keyId: "k", created: NOW }, + key: privateKey, }); - assertEquals(signed.headers.get("X-Custom"), "preserved"); - assertEquals(signed.headers.has("Signature"), true); - assertEquals(signed.headers.has("Signature-Input"), true); -}); + assertEquals(signedReq instanceof Request, true); + assertEquals(signedReq.headers.get("X-Custom"), "preserved"); + assertEquals( + signedReq.headers.get("Signature-Input")!.startsWith("sig="), + true, + ); -Deno.test("signMessage() defaults label to sig", async () => { - const keys = await generateEd25519(); - const request = new Request("https://example.com/", { method: "GET" }); - const signed = await signMessage({ - message: request, + // Response: returns Response type, signs correctly + const response = new Response("body", { + status: 200, + headers: { "Content-Type": "text/plain" }, + }); + const signedRes = await signMessage({ + message: response, params: { - components: ["@method"], + components: ["@status", "content-type"], keyId: "k", - created: Math.floor(Date.now() / 1000), + created: NOW, }, - key: keys.privateKey, + key: privateKey, }); - const sigInput = signed.headers.get("Signature-Input")!; - assertEquals(sigInput.startsWith("sig="), true); + assertEquals(signedRes instanceof Response, true); + assertEquals(signedRes.status, 200); + + const results = await verifyMessage(signedRes, () => publicKey); + assertEquals(results.length, 1); }); -Deno.test("signMessage() throws TypeError on unsupported algorithm", async () => { - const keys = await generateEd25519(); +Deno.test("signMessage() rejects invalid params", async () => { + const keyOrPair = await KEY_GENERATORS["ed25519"]!(); + const { privateKey } = keys(keyOrPair); const request = new Request("https://example.com/", { method: "GET" }); + + // Unsupported algorithm await assertRejects( () => signMessage({ message: request, params: { components: ["@method"], - algorithm: "invalid-algo" as SignatureAlgorithm, + algorithm: "invalid" as SignatureAlgorithm, }, - key: keys.privateKey, + key: privateKey, }), TypeError, "Unsupported signature algorithm", ); -}); -Deno.test("signMessage() throws RangeError on negative created timestamp", async () => { - const keys = await generateEd25519(); - const request = new Request("https://example.com/", { method: "GET" }); + // Invalid label await assertRejects( () => signMessage({ @@ -567,220 +634,367 @@ Deno.test("signMessage() throws RangeError on negative created timestamp", async params: { components: ["@method"], keyId: "k", - created: -1, + label: "INVALID", + created: NOW, }, - key: keys.privateKey, + key: privateKey, + }), + TypeError, + "Invalid signature label", + ); + + // Negative created + await assertRejects( + () => + signMessage({ + message: request, + params: { components: ["@method"], keyId: "k", created: -1 }, + key: privateKey, }), RangeError, "created must be a non-negative integer", ); + + // Negative expires + await assertRejects( + () => + signMessage({ + message: request, + params: { + components: ["@method"], + keyId: "k", + created: NOW, + expires: -1, + }, + key: privateKey, + }), + RangeError, + "expires must be a non-negative integer", + ); }); // ============================================================================= -// Layer 3c: verifyMessage behaviour +// signMessage — metadata round-trip (nonce, tag, expires) // ============================================================================= -Deno.test("verifyMessage() throws Error on tampered header value", async () => { - const keys = await generateEd25519(); +Deno.test("signMessage() and verifyMessage() round-trip preserves nonce, tag, and expires", async () => { + const keyOrPair = await KEY_GENERATORS["ed25519"]!(); + const { privateKey, publicKey } = keys(keyOrPair); + const request = new Request("https://example.com/", { method: "GET" }); + + const signed = await signMessage({ + message: request, + params: { + components: ["@method"], + keyId: "k", + algorithm: "ed25519", + created: NOW, + expires: NOW + 3600, + nonce: "abc123", + tag: "my-app", + }, + key: privateKey, + }); + + const results = await verifyMessage(signed, () => publicKey); + assertEquals(results.length, 1); + assertEquals(results[0]!.params.algorithm, "ed25519"); + assertEquals(results[0]!.params.nonce, "abc123"); + assertEquals(results[0]!.params.tag, "my-app"); + assertEquals(results[0]!.params.expires, NOW + 3600); +}); + +// ============================================================================= +// signMessage — multiple signatures +// ============================================================================= + +Deno.test("signMessage() supports multiple signatures with different labels", async () => { + const k1 = keys(await KEY_GENERATORS["ed25519"]!()); + const k2 = keys(await KEY_GENERATORS["ed25519"]!()); + const request = new Request("https://example.com/", { method: "GET" }); + + const signed1 = await signMessage({ + message: request, + params: { + components: ["@method"], + keyId: "key1", + label: "sig1", + created: NOW, + }, + key: k1.privateKey, + }); + const signed2 = await signMessage({ + message: signed1, + params: { + components: ["@method", "@authority"], + keyId: "key2", + label: "sig2", + created: NOW, + }, + key: k2.privateKey, + }); + + const sigInput = signed2.headers.get("Signature-Input")!; + assertEquals(sigInput.includes("sig1="), true); + assertEquals(sigInput.includes("sig2="), true); + + const results = await verifyMessage( + signed2, + (keyId) => keyId === "key1" ? k1.publicKey : k2.publicKey, + ); + assertEquals(results.length, 2); +}); + +// ============================================================================= +// verifyMessage — tampering and wrong key +// ============================================================================= + +Deno.test("verifyMessage() rejects tampered message and wrong key", async () => { + const k1 = keys(await KEY_GENERATORS["ed25519"]!()); + const k2 = keys(await KEY_GENERATORS["ed25519"]!()); const request = new Request("https://example.com/", { method: "GET", headers: { "Content-Type": "application/json" }, }); + const signed = await signMessage({ message: request, params: { components: ["@method", "content-type"], keyId: "k", - created: Math.floor(Date.now() / 1000), + created: NOW, }, - key: keys.privateKey, + key: k1.privateKey, }); - // Tamper with the content-type header + // Tampered header const tampered = new Request(signed.url, { method: signed.method, headers: new Headers(signed.headers), }); tampered.headers.set("Content-Type", "text/plain"); - await assertRejects( - () => verifyMessage(tampered, () => keys.publicKey), + () => verifyMessage(tampered, () => k1.publicKey), Error, "Signature verification failed", ); -}); - -Deno.test("verifyMessage() throws Error on wrong key", async () => { - const keys1 = await generateEd25519(); - const keys2 = await generateEd25519(); - const request = new Request("https://example.com/", { method: "GET" }); - const signed = await signMessage({ - message: request, - params: { - components: ["@method"], - keyId: "k", - created: Math.floor(Date.now() / 1000), - }, - key: keys1.privateKey, - }); + // Wrong key await assertRejects( - () => verifyMessage(signed, () => keys2.publicKey), + () => verifyMessage(signed, () => k2.publicKey), Error, "Signature verification failed", ); }); -Deno.test("verifyMessage() throws Error on expired signature when maxAge is set", async () => { - const keys = await generateEd25519(); - const request = new Request("https://example.com/", { method: "GET" }); - const signed = await signMessage({ - message: request, - params: { - components: ["@method"], - keyId: "k", - created: Math.floor(Date.now() / 1000) - 3600, - }, - key: keys.privateKey, - }); +// ============================================================================= +// verifyMessage — constraint enforcement +// ============================================================================= +Deno.test("verifyMessage() enforces maxAge, requiredComponents, labels, and expires", async () => { + const { privateKey, publicKey } = keys(await KEY_GENERATORS["ed25519"]!()); + + // maxAge exceeded + const old = await signMessage({ + message: new Request("https://example.com/", { method: "GET" }), + params: { components: ["@method"], keyId: "k", created: NOW - 3600 }, + key: privateKey, + }); await assertRejects( - () => verifyMessage(signed, () => keys.publicKey, { maxAge: 60 }), + () => verifyMessage(old, () => publicKey, { maxAge: 60 }), Error, "has expired", ); -}); -Deno.test("verifyMessage() throws Error when requiredComponents are not covered", async () => { - const keys = await generateEd25519(); - const request = new Request("https://example.com/", { method: "GET" }); - const signed = await signMessage({ - message: request, + // maxAge without created + const noCreated = await signMessage({ + message: new Request("https://example.com/", { method: "GET" }), + params: { components: ["@method"], keyId: "k" }, + key: privateKey, + }); + await assertRejects( + () => verifyMessage(noCreated, () => publicKey, { maxAge: 60 }), + Error, + 'no "created" timestamp but maxAge was requested', + ); + + // Expired via expires param + const expired = await signMessage({ + message: new Request("https://example.com/", { method: "GET" }), params: { components: ["@method"], keyId: "k", - created: Math.floor(Date.now() / 1000), + created: 1000, + expires: 1001, }, - key: keys.privateKey, + key: privateKey, }); + await assertRejects( + () => verifyMessage(expired, () => publicKey), + Error, + 'past "expires" timestamp', + ); + // Required component not covered + const minimal = await signMessage({ + message: new Request("https://example.com/", { method: "GET" }), + params: { components: ["@method"], keyId: "k", created: NOW }, + key: privateKey, + }); await assertRejects( () => - verifyMessage(signed, () => keys.publicKey, { + verifyMessage(minimal, () => publicKey, { requiredComponents: ["@authority"], }), Error, "does not cover required component", ); -}); -Deno.test("verifyMessage() filters by labels option", async () => { - const keys = await generateEd25519(); - const request = new Request("https://example.com/", { method: "GET" }); - const signed = await signMessage({ - message: request, + // Labels filter — match vs no match + const labeled = await signMessage({ + message: new Request("https://example.com/", { method: "GET" }), params: { components: ["@method"], keyId: "k", label: "mysig", - created: Math.floor(Date.now() / 1000), + created: NOW, }, - key: keys.privateKey, + key: privateKey, }); - - // Verify only "mysig" — should succeed - const results = await verifyMessage(signed, () => keys.publicKey, { + const matched = await verifyMessage(labeled, () => publicKey, { labels: ["mysig"], }); - assertEquals(results.length, 1); - - // Verify only "other" — should return empty (no matching labels) - const empty = await verifyMessage(signed, () => keys.publicKey, { + assertEquals(matched.length, 1); + const unmatched = await verifyMessage(labeled, () => publicKey, { labels: ["other"], }); - assertEquals(empty.length, 0); + assertEquals(unmatched.length, 0); }); -Deno.test("verifyMessage() throws TypeError on missing Signature-Input header", async () => { - const request = new Request("https://example.com/", { method: "GET" }); +// ============================================================================= +// verifyMessage — input validation and malformed headers +// ============================================================================= + +Deno.test("verifyMessage() rejects invalid inputs and malformed headers", async () => { + const { privateKey } = keys(await KEY_GENERATORS["ed25519"]!()); + + // Negative maxAge + const signed = await signMessage({ + message: new Request("https://example.com/", { method: "GET" }), + params: { components: ["@method"], keyId: "k", created: NOW }, + key: privateKey, + }); await assertRejects( () => - verifyMessage(request, () => { + verifyMessage(signed, () => { throw new Error("unreachable"); - }), + }, { maxAge: -1 }), + RangeError, + "maxAge must be a non-negative integer", + ); + + // Missing Signature-Input + await assertRejects( + () => + verifyMessage( + new Request("https://example.com/", { method: "GET" }), + () => { + throw new Error("unreachable"); + }, + ), TypeError, 'Missing "Signature-Input" header', ); -}); - -Deno.test("verifyMessage() throws RangeError on negative maxAge", async () => { - const keys = await generateEd25519(); - const request = new Request("https://example.com/", { method: "GET" }); - const signed = await signMessage({ - message: request, - params: { - components: ["@method"], - keyId: "k", - created: Math.floor(Date.now() / 1000), - }, - key: keys.privateKey, - }); + // Missing Signature await assertRejects( - () => verifyMessage(signed, () => keys.publicKey, { maxAge: -1 }), - RangeError, - "maxAge must be a non-negative integer", + () => + verifyMessage( + new Request("https://example.com/", { + method: "GET", + headers: { "Signature-Input": 'sig=("@method");created=1618884473' }, + }), + () => { + throw new Error("unreachable"); + }, + ), + TypeError, + 'Missing "Signature" header', ); -}); -// ============================================================================= -// Layer 3d: Advanced scenarios -// ============================================================================= + // Label in Signature-Input but not Signature + await assertRejects( + () => + verifyMessage( + new Request("https://example.com/", { + method: "GET", + headers: { + "Signature-Input": 'sig=("@method");created=1618884473', + "Signature": "other=:AAAA:", + }, + }), + () => { + throw new Error("unreachable"); + }, + ), + TypeError, + "found in Signature-Input but missing in Signature", + ); -Deno.test("signMessage() supports multiple signatures with different labels", async () => { - const keys1 = await generateEd25519(); - const keys2 = await generateEd25519(); - const request = new Request("https://example.com/", { method: "GET" }); + // Label in Signature but not Signature-Input + await assertRejects( + () => + verifyMessage( + new Request("https://example.com/", { + method: "GET", + headers: { + "Signature-Input": 'sig=("@method");created=1618884473', + "Signature": "sig=:AAAA:, extra=:BBBB:", + }, + }), + () => { + throw new Error("unreachable"); + }, + ), + TypeError, + "found in Signature but missing in Signature-Input", + ); - const signed1 = await signMessage({ - message: request, - params: { - components: ["@method"], - keyId: "key1", - label: "sig1", - created: Math.floor(Date.now() / 1000), - }, - key: keys1.privateKey, - }); + // Signature-Input member is not an inner list + await assertRejects( + () => + verifyMessage( + new Request("https://example.com/", { + method: "GET", + headers: { "Signature-Input": "sig=42", "Signature": "sig=:AAAA:" }, + }), + () => { + throw new Error("unreachable"); + }, + ), + TypeError, + "is not an Inner List", + ); - const signed2 = await signMessage({ - message: signed1, - params: { - components: ["@method", "@authority"], - keyId: "key2", - label: "sig2", - created: Math.floor(Date.now() / 1000), - }, - key: keys2.privateKey, + // keyLookup returns null + const signedForNull = await signMessage({ + message: new Request("https://example.com/", { method: "GET" }), + params: { components: ["@method"], keyId: "unknown", created: NOW }, + key: privateKey, }); - - const sigInput = signed2.headers.get("Signature-Input")!; - assertEquals(sigInput.includes("sig1="), true); - assertEquals(sigInput.includes("sig2="), true); - - // Verify both - const results = await verifyMessage( - signed2, - (keyId) => { - if (keyId === "key1") return keys1.publicKey; - return keys2.publicKey; - }, + await assertRejects( + () => verifyMessage(signedForNull, () => null), + TypeError, + "Key not found", ); - assertEquals(results.length, 2); }); +// ============================================================================= +// Response signing with ;req components (end-to-end) +// ============================================================================= + Deno.test("verifyMessage() verifies response signature with ;req components", async () => { - const keys = await generateEd25519(); + const { privateKey, publicKey } = keys(await KEY_GENERATORS["ed25519"]!()); const request = new Request("https://example.com/foo", { method: "POST" }); const response = new Response('{"ok":true}', { status: 200, @@ -797,17 +1011,13 @@ Deno.test("verifyMessage() verifies response signature with ;req components", as { name: "@path", parameters: { req: true } }, ], keyId: "server-key", - created: Math.floor(Date.now() / 1000), + created: NOW, }, - key: keys.privateKey, + key: privateKey, request, }); - const results = await verifyMessage( - signed, - () => keys.publicKey, - { request }, - ); + const results = await verifyMessage(signed, () => publicKey, { request }); assertEquals(results.length, 1); assertEquals(results[0]!.params.keyId, "server-key"); });