/** * HTTP Message Signatures for the Fetch API. * * Implements the sender, recipient, and `Accept-Signature` operations from [RFC * 9421](https://www.rfc-editor.org/info/rfc9421/) on top of `Request`, `Response`, `Headers`, and * `fetch`. The module constructs and parses the required Structured Fields, includes Web * Cryptography implementations of the ECDSA, Ed25519, and RSA signature algorithms, and supports * custom cryptographic providers. * * Package configuration records, including Fetch-wrapper `RequestInit` values, must be object * literals or null-prototype objects containing only own enumerable data properties. Messages, * headers, provider implementations, and host objects retain their own documented semantics. * * @module fetch-message-signatures * @example * * Sign and verify a request with Ed25519 through Web Cryptography. * * ```ts * import * as FetchSig from 'fetch-message-signatures' * * const { privateKey, publicKey } = await FetchSig.generateEd25519KeyPair() * const signer = FetchSig.ed25519Signer(privateKey) * const verifyWithKey = FetchSig.ed25519Verifier(publicKey) * * const verifier: FetchSig.VerifierFactory = (signature, context) => { * const keyid = FetchSig.getSignatureParameter(signature, 'keyid') * if (keyid !== 'example-key') { * throw new FetchSig.VerificationError('unknown_key', 'Unknown signing key') * } * return verifyWithKey(signature, context) * } * * const request = await FetchSig.sign(new Request('https://api.example/orders/123'), { * signer, * components: ['@method', '@authority', '@path'], * parameters: { alg: 'ed25519', keyid: 'example-key' }, * }) * * // sig1=("@method" "@authority" "@path");created=1735689600;alg="ed25519";keyid="example-key" * console.log(request.headers.get('signature-input')) * * const verified = await FetchSig.verify(request, { * verifier, * policy: { * requiredComponents: ['@method', '@authority', '@path'], * requiredParameters: ['created', 'alg', 'keyid'], * algorithms: ['ed25519'], * maxAge: 60, * }, * }) * * // sig1 ed25519 * console.log(verified.label, verified.algorithm) * ``` * * @example * * Send signed requests through `fetch`. Three wrappers cover the three directions: * * | wrapper | outgoing request | incoming response | * | ---------------------------- | ---------------- | ----------------- | * | {@link createSigningFetch} | signed | not verified | * | {@link createVerifyingFetch} | not signed | verified | * | {@link createSignedFetch} | signed | verified | * * Signing outgoing requests is the common case, because it only requires the recipient to verify. * Verifying responses additionally requires the server to sign them. * * ```ts * import * as FetchSig from 'fetch-message-signatures' * * declare const clientPrivateKey: CryptoKey * * const signingFetch = FetchSig.createSigningFetch({ * sign: { * signer: FetchSig.ed25519Signer(clientPrivateKey), * components: ['@method', '@authority', '@path'], * parameters: { alg: 'ed25519', keyid: 'client-key' }, * }, * }) * * // Used exactly like fetch. The request is signed on the way out. * const response = await signingFetch('https://api.example/orders/123') * const order = await response.json() * ``` * * When the server signs its responses too, {@link createSignedFetch} takes the same `sign` options * plus a `verify` block, and checks the response against the exact request that produced it. */ const encoder = /* @__PURE__ */ new TextEncoder() const decoder = /* @__PURE__ */ new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }) const DERIVED_COMPONENTS = new Set([ '@method', '@target-uri', '@authority', '@scheme', '@request-target', '@path', '@query', '@query-param', '@status', ]) const SIGNATURE_PARAMETERS = new Set(['created', 'expires', 'nonce', 'alg', 'keyid', 'tag']) const HTTP_FIELD_NAME = /^[!#$%&'*+\-.^_`|~0-9a-z]+$/ const SF_KEY = /^[a-z*][a-z0-9_.*-]*$/ const SF_TOKEN = /^[A-Za-z*][!#$%&'*+\-.^_`|~A-Za-z0-9:/*]*$/ const ASCII = /^[\x00-\x7f]*$/ const PRINTABLE_ASCII = /^[\x20-\x7e]*$/ /** * A Web Cryptography key, resolved from the host runtime. * * The host's own `CryptoKey` type is aliased whenever one is declared, so keys flow freely to and * from Web Cryptography's `SubtleCrypto` APIs and this package never introduces a competing nominal * type. It is resolved through `globalThis` rather than named directly because not every supported * configuration declares a `CryptoKey` **type**: `@types/node` declares only the constructor value, * so a Node.js consumer compiling with `"lib": ["esnext"]` and no DOM lib would otherwise get a * type error from these declarations. Such a consumer gets {@link CryptoKeyStructuralFallback} * instead, which is still checked. */ export type CryptoKey = typeof globalThis extends { crypto: { subtle: { generateKey(...args: any[]): Promise } } } ? Extract : CryptoKeyStructuralFallback /** * Used as {@link CryptoKey} when the host runtime's `crypto` global is not exposed on `typeof * globalThis`, including when it is absent from ambient types or declared with `const` or `let`. It * stays structurally compatible with host `CryptoKey` declarations. */ export interface CryptoKeyStructuralFallback { readonly algorithm: { name: string } readonly extractable: boolean readonly type: string readonly usages: string[] } /** * A Web Cryptography key pair, resolved from the host runtime the same way {@link CryptoKey} is. * * Declared structurally because no supported runtime exposes a global `CryptoKeyPair` **type** on * every configuration: the DOM lib declares one, `@types/node` does not declare one at all. */ export interface CryptoKeyPair { readonly privateKey: CryptoKey readonly publicKey: CryptoKey } /** The top-level type of an HTTP Structured Field. */ export type StructuredFieldType = 'dictionary' | 'list' | 'item' /** A Structured Field Token. Plain JavaScript strings represent Structured Field Strings. */ export interface StructuredFieldToken { readonly type: 'token' readonly value: string } /** A Structured Field Decimal, including integral decimal values such as `1.0`. */ export interface StructuredFieldDecimal { readonly type: 'decimal' readonly value: number } /** A Structured Field Date represented as integer UNIX seconds. */ export interface StructuredFieldDate { readonly type: 'date' readonly value: number } /** A Structured Field Display String. */ export interface StructuredFieldDisplayString { readonly type: 'display-string' readonly value: string } /** * A bare item value in an HTTP Structured Field. * * Plain JavaScript values represent the types that cannot be confused for one another: `string` is * a String, an integral `number` is an Integer, `boolean` is a Boolean, and `Uint8Array` is a Byte * Sequence. The four types that would otherwise be ambiguous are wrapped, so a Token is never * mistaken for a String, nor a Decimal for an Integer. */ export type StructuredFieldBareItem = | string | number | boolean | Uint8Array | StructuredFieldToken | StructuredFieldDecimal | StructuredFieldDate | StructuredFieldDisplayString /** A value that can be used as an HTTP signature metadata parameter. */ export type SignatureParameterValue = StructuredFieldBareItem /** An ordered parameter on a Structured Field Item or Inner List. */ export type StructuredFieldParameter = readonly [name: string, value: StructuredFieldBareItem] /** A Structured Field Item: one bare item with its parameters. */ export interface StructuredFieldItem { readonly type: 'item' readonly value: StructuredFieldBareItem readonly parameters: ReadonlyArray } /** A Structured Field Inner List: an ordered list of Items with its own parameters. */ export interface StructuredFieldInnerList { readonly type: 'inner-list' readonly value: ReadonlyArray readonly parameters: ReadonlyArray } /** A member of a Structured Field List or Dictionary. */ export type StructuredFieldMember = StructuredFieldItem | StructuredFieldInnerList /** * A Structured Field Dictionary as ordered entries. * * Ordered rather than a `Map`, because RFC 9651 defines Dictionaries as ordered and both the * serialization and, for signed fields, the signature depend on that order. */ export type StructuredFieldDictionary = ReadonlyArray< readonly [name: string, value: StructuredFieldMember] > /** A Structured Field List. */ export type StructuredFieldList = ReadonlyArray /** A complete Structured Field value of one of the three top-level types. */ export type StructuredFieldValue = StructuredFieldDictionary | StructuredFieldList | StructuredFieldItem /** * A signature metadata parameter input. * * `Date` values are converted to integer UNIX timestamps. `false` is useful only for the `created` * parameter, where it explicitly disables the default creation timestamp. */ export type SignatureParameterInput = SignatureParameterValue | Date | undefined /** An ordered signature metadata parameter. */ export type SignatureParameter = readonly [name: string, value: SignatureParameterInput] /** * Ordered parameters are recommended because their order is covered by the signature. Object * property insertion order is preserved when a record is supplied. */ export type SignatureParameters = ReadonlyArray | Readonly> /** A value supported by an HTTP message component parameter. */ export type ComponentParameterValue = string | boolean /** An ordered HTTP message component parameter. */ export type ComponentParameter = readonly [name: string, value: ComponentParameterValue] /** * Ordered parameters are recommended because their serialization order is covered by the signature. * Object property insertion order is preserved when a record is supplied. */ export type ComponentParameters = ReadonlyArray | Readonly> /** A parameterized HTTP message component identifier. */ export interface ParameterizedComponent { readonly name: string readonly parameters?: ComponentParameters } /** * An HTTP message component identifier. * * A string is shorthand for an identifier without parameters. */ export type ComponentIdentifier = string | ParameterizedComponent /** A normalized HTTP message component identifier with ordered parameters. */ export interface MessageComponent { readonly name: string readonly parameters: ReadonlyArray } /** Options shared by signature-base creation, signing, and verification. */ export interface SignatureContext { /** The exact request that triggered a response. Required when a response signature uses `;req`. */ readonly request?: SignableRequest /** Structured Field top-level types, indexed by lowercase HTTP field name. */ readonly structuredFields?: Readonly> } /** Target-message context supplied to a verifier factory. */ export interface VerificationContext { /** * The target message carrying the signature. * * This is an immutable, package-owned snapshot captured at the start of verification. Field names * are lowercase and each value is the ordered list of occurrences used to construct the signature * base. Every verification callback observes the same snapshot values. */ readonly message: MessageSnapshot /** The related-request snapshot, when response/request binding is in use. */ readonly request?: RequestSnapshot } /** Authenticated context supplied to additional application policy. */ export interface VerifiedSignatureContext extends VerificationContext { /** The algorithm selected by the verifier factory. */ readonly algorithm: string } /** * HTTP fields supplied to a reading operation. * * A Fetch `Headers` is used as it is. A plain record is what a server framework typically hands a * handler, so its own field object can be passed straight through. An array value is one field with * repeated occurrences, which RFC 9421 combines rather than concatenating with a bare comma. */ export type HeadersInput = Headers | Readonly | undefined>> /** * Immutable HTTP field occurrences indexed by lowercase field name, in their received order. * * A plain message descriptor preserves the occurrence boundaries it supplies. Fetch `Headers` * usually exposes only a combined value, except where the runtime provides `getSetCookie()`. */ export type FieldOccurrences = Readonly>> /** * A request this package can read components from. * * A Fetch `Request` is the expected input, and is what the Fetch wrappers, {@link sign}, * {@link appendSignature}, {@link signRequested}, and {@link appendAcceptSignature} require. The * second member exists for a server that never constructs one and holds only the values it read off * an incoming request. Such a caller uses {@link createSignature} and attaches the returned field * values itself. */ export type SignableRequest = | Request | { readonly method: string readonly url: string readonly headers: HeadersInput /** Trailer occurrences, when the transport exposes them. */ readonly trailers?: HeadersInput } /** * A response this package can read components from. * * A Fetch `Response` is the expected input. See {@link SignableRequest} for when the second member * is useful. */ export type SignableResponse = | Response | { readonly status: number readonly headers: HeadersInput /** Trailer occurrences, when the transport exposes them. */ readonly trailers?: HeadersInput } /** An immutable request snapshot supplied to verification callbacks. */ export interface RequestSnapshot { readonly method: string readonly url: string readonly headers: FieldOccurrences readonly trailers: FieldOccurrences } /** An immutable response snapshot supplied to verification callbacks. */ export interface ResponseSnapshot { readonly status: number readonly headers: FieldOccurrences readonly trailers: FieldOccurrences } /** A package-owned request or response snapshot. */ export type MessageSnapshot = RequestSnapshot | ResponseSnapshot /** A parsed HTTP message signature. */ export interface MessageSignature { readonly label: string readonly components: ReadonlyArray readonly parameters: ReadonlyArray readonly signature: Uint8Array } /** The result of creating one signature, ready to be added to the corresponding HTTP fields. */ export interface SignatureFields extends MessageSignature { /** A one-member `Signature-Input` Structured Field Dictionary. */ readonly signatureInput: string /** A one-member `Signature` Structured Field Dictionary. */ readonly signatureField: string } /** * A signer implementation returned by a {@link SignerFactory}. * * `sign()` may return the signature bytes directly or a Promise of them, so a synchronous * cryptographic library needs no wrapper. Web Cryptography is asynchronous, so every signer this * package builds returns a Promise. */ export interface Signer { /** The algorithm selected by configuration or key metadata. */ readonly alg: string sign(data: Uint8Array): Uint8Array | Promise } /** A synchronous factory returning a signer implementation. */ export type SignerFactory = () => Readonly /** * A verifier implementation returned by a {@link VerifierFactory}. * * `verify()` may return the result directly or a Promise of it, so a synchronous cryptographic * library needs no wrapper. Web Cryptography is asynchronous, so every verifier this package builds * returns a Promise. */ export interface Verifier { /** The algorithm selected by configuration or key metadata. */ readonly alg: string verify( data: Uint8Array, signature: Uint8Array, ): boolean | Promise } /** * A factory that selects trusted verification key material and an algorithm. * * The factory is the application's key-resolution and trust-policy boundary. It MUST reject unknown * or inappropriate key identifiers and algorithms instead of returning a verifier for them. * * It receives the parsed signature before any cryptography runs, so selection can depend on * `keyid`, `alg`, the covered component list, or the message itself. Use * {@link getSignatureParameter} to read a metadata parameter. * * It may return a Promise, so a key that has to be fetched or refreshed on rotation can be awaited * here. A `keyid` is unauthenticated at this point, so resolve it through trusted configuration and * never treat it as a location to fetch. The signature base is rebuilt after the factory settles, * so a message that changes while a key is being fetched is rejected rather than verified. * * Throw {@link VerificationError} with `unknown_key` or `algorithm_unsupported` to expose either * result as a stable error code. Other factory exceptions are reported as `verification_failed` * with the original exception as their `cause`. */ export type VerifierFactory = ( signature: Readonly, context: Readonly, ) => Readonly | Promise> /** * A {@link VerifierFactory} that resolves its verifier without suspending. * * Every factory this package returns is synchronous, and says so, so that composing one keeps * working without an `await`. It remains assignable to {@link VerifierFactory}. */ export type SynchronousVerifierFactory = ( signature: Readonly, context: Readonly, ) => Readonly /** Sender options. */ export interface SignOptions extends SignatureContext { readonly signer: SignerFactory readonly components: ReadonlyArray readonly parameters?: SignatureParameters readonly label?: string /** Injectable clock used for the default `created` parameter. */ readonly now?: number | Date } /** Explicit application policy required before a cryptographically valid signature is accepted. */ export interface VerificationPolicy { /** Every listed component identifier must be covered by the signature. */ readonly requiredComponents: ReadonlyArray /** Every listed metadata parameter must be present. */ readonly requiredParameters: ReadonlyArray /** Non-empty allowlist matched against the algorithm selected by the verifier factory. */ readonly algorithms: ReadonlyArray /** Maximum signature age in seconds. Requires a `created` parameter. */ readonly maxAge?: number /** Permitted timestamp skew in seconds. Defaults to zero. */ readonly clockSkew?: number /** Injectable verification clock. */ readonly now?: number | Date /** * Additional application policy, such as nonce uniqueness, expected tags, field semantics, and * key/message authorization. */ validate?( signature: Readonly, context: Readonly, ): void | Promise } /** Stable machine-readable reasons why HTTP message signature verification failed. */ export type VerificationErrorCode = | 'signature_missing' | 'signature_malformed' | 'policy_rejected' | 'signature_time_invalid' | 'unknown_key' | 'algorithm_unsupported' | 'signature_mismatch' | 'verification_failed' /** * An HTTP message signature verification failure. * * Branch on {@link code}, not `message`, which is diagnostic and may change. Failures originating in * a verifier factory, verifier implementation, or application policy preserve the original * exception as `cause`. * * A verifier factory may throw this with `unknown_key` when a claimed key cannot be resolved, or * with `algorithm_unsupported` when it cannot verify the selected algorithm. Other factory * exceptions become `verification_failed`, so an unavailable key service is not mistaken for an * unknown key. * * @group Recipient */ export class VerificationError extends Error { readonly code: VerificationErrorCode constructor(code: VerificationErrorCode, message: string, options?: ErrorOptions) { super(message, options) this.name = 'VerificationError' this.code = code } } /** Recipient options. */ export interface VerifyOptions extends SignatureContext { readonly verifier: VerifierFactory readonly policy: VerificationPolicy /** * The signature label to verify. Required when the message contains more than one signature. * Labels are not signed and MUST NOT be assigned application semantics. */ readonly label?: string } /** A successfully verified signature. */ export interface VerifiedSignature extends MessageSignature { readonly algorithm: string } /** Options for direct signature-base creation. */ export interface SignatureBaseOptions extends SignatureContext { readonly components: ReadonlyArray readonly parameters?: SignatureParameters } /** Options for serializing a signature that was produced elsewhere into its two HTTP fields. */ export interface SignatureFieldsOptions { /** The signature over the corresponding signature base. */ readonly signature: Uint8Array readonly components: ReadonlyArray readonly parameters?: SignatureParameters readonly label?: string } type SfBareItem = | { readonly kind: 'integer'; readonly value: number } | { readonly kind: 'decimal'; readonly value: number } | { readonly kind: 'string'; readonly value: string } | { readonly kind: 'token'; readonly value: string } | { readonly kind: 'binary'; readonly value: Uint8Array } | { readonly kind: 'boolean'; readonly value: boolean } | { readonly kind: 'date'; readonly value: number } | { readonly kind: 'display-string'; readonly value: string } type SfParameter = [name: string, value: SfBareItem] type SfParameters = SfParameter[] interface SfItem { readonly kind: 'item' readonly value: SfBareItem readonly parameters: SfParameters } interface SfInnerList { readonly kind: 'inner-list' readonly value: SfItem[] readonly parameters: SfParameters } type SfMember = SfItem | SfInnerList type SfList = SfMember[] type SfDictionaryEntry = [name: string, value: SfMember] type SfDictionary = SfDictionaryEntry[] type SfTopLevel = SfDictionary | SfList | SfItem interface ParseState { readonly input: string index: number readonly duplicateKeys: string[] } /** * Throws the error used for an input, syntax, or policy rejection in this module. * * The `never` return type lets call sites use it as an expression, so a validation branch can both * reject and satisfy a function's declared return type. */ function fail(message: string): never { throw new TypeError(message) } /** Throws a deterministic HTTP message signature verification failure. */ function verificationFail(code: VerificationErrorCode, message: string): never { throw new VerificationError(code, message) } /** Wraps an application or provider exception as the verification stage that rejected it. */ function verificationError( code: VerificationErrorCode, cause: unknown, message?: string, ): VerificationError { return new VerificationError( code, message ?? (cause instanceof Error ? cause.message : String(cause)), { cause }, ) } type AlgorithmKeyType = 'private' | 'public' type SignatureKeyUsage = 'sign' | 'verify' type WebCryptoSignatureAlgorithm = AlgorithmIdentifier | EcdsaParams | RsaPssParams type WebCryptoKeyGenerationAlgorithm = AlgorithmIdentifier | EcKeyGenParams | RsaHashedKeyGenParams interface AlgorithmKeyExpectation { readonly identifier: string readonly type: AlgorithmKeyType readonly usage: SignatureKeyUsage readonly algorithm: string readonly namedCurve?: string readonly hash?: string } /** * Validates the optional `extractable` argument of a key-pair generator and applies its non- * extractable default. */ function resolveExtractableOption(extractable: boolean | undefined): boolean { if (extractable === undefined) { return false } if (typeof extractable !== 'boolean') { fail('"extractable" must be a boolean') } return extractable } /** * Validates the optional `modulusLength` argument of an RSA key-pair generator and applies its * 2048-bit default. * * Which lengths are actually supported is left to the Web Cryptography implementation, which * rejects the ones it cannot generate. */ function resolveModulusLengthOption(modulusLength: number | undefined): number { if (modulusLength === undefined) { return 2048 } if (typeof modulusLength !== 'number' || !Number.isInteger(modulusLength) || modulusLength <= 0) { fail('"modulusLength" must be a positive integer') } return modulusLength } /** * Reads a property from a value that is not known to be an object, returning `undefined` instead of * throwing when it is not. * * Used to inspect `CryptoKey` objects that may come from a foreign implementation. */ function readProperty(value: unknown, property: string): unknown { if (value === null || typeof value !== 'object') { return undefined } return (value as Record)[property] } /** * Reports whether a `CryptoKey` matches the type, usage, Web Cryptography algorithm, named curve, * and digest required by one RFC 9421 algorithm identifier. * * The digest is part of the identifier for the RSA algorithms, and a `CryptoKey` binds it at import * or generation time. An RSA-PSS key created for SHA-256 is therefore rejected by the * `rsa-pss-sha512` factories rather than used to produce a signature that names a digest it was not * computed with. */ function isAlgorithmKey(key: CryptoKey, expected: AlgorithmKeyExpectation): boolean { if (key === null || typeof key !== 'object') { return false } const algorithm = readProperty(key, 'algorithm') const usages = readProperty(key, 'usages') if ( readProperty(key, 'type') !== expected.type || !Array.isArray(usages) || !usages.includes(expected.usage) || readProperty(algorithm, 'name') !== expected.algorithm ) { return false } if ( expected.namedCurve !== undefined && readProperty(algorithm, 'namedCurve') !== expected.namedCurve ) { return false } return ( expected.hash === undefined || readProperty(readProperty(algorithm, 'hash'), 'name') === expected.hash ) } /** * Rejects a `CryptoKey` that does not match the expectation for one RFC 9421 algorithm identifier, * naming the identifier, key type, and usage that were required. */ function assertAlgorithmKey(key: CryptoKey, expected: AlgorithmKeyExpectation): void { if (!isAlgorithmKey(key, expected)) { fail( `"key" must be Web Cryptography's ${expected.type} CryptoKey for "${expected.identifier}" with "${expected.usage}" usage`, ) } } /** * Builds a fixed-key {@link SignerFactory} that signs the signature base with `crypto.subtle.sign`. * * The key is checked once, when the factory is created, so that a mismatched key is reported before * any message is signed. */ function createWebCryptoSignerFactory( key: CryptoKey, expected: AlgorithmKeyExpectation, operation: WebCryptoSignatureAlgorithm, ): SignerFactory { assertAlgorithmKey(key, expected) return () => ({ alg: expected.identifier, async sign(data) { return new Uint8Array(await globalThis.crypto.subtle.sign(operation, key, data)) }, }) } /** * Builds a fixed-key {@link VerifierFactory} that verifies the signature base with * `crypto.subtle.verify`. * * The key is checked once, when the factory is created, so that a mismatched key is reported before * any message is verified. */ function createWebCryptoVerifierFactory( key: CryptoKey, expected: AlgorithmKeyExpectation, operation: WebCryptoSignatureAlgorithm, ): SynchronousVerifierFactory { assertAlgorithmKey(key, expected) return () => ({ alg: expected.identifier, async verify(data, signature) { return globalThis.crypto.subtle.verify(operation, key, signature, data) }, }) } /** Generates a Web Cryptography key pair restricted to the `sign` and `verify` usages. */ async function generateWebCryptoKeyPair( algorithm: WebCryptoKeyGenerationAlgorithm, extractable: boolean | undefined, ): Promise { return (await globalThis.crypto.subtle.generateKey( algorithm, resolveExtractableOption(extractable), ['sign', 'verify'], )) as CryptoKeyPair } /** * Generates an ECDSA P-256 key pair for the RFC 9421 `ecdsa-p256-sha256` algorithm. * * The generated public key is represented by Web Cryptography's `CryptoKey` and is always * extractable. * * @example * * Generate a pair and turn it into the sender and recipient providers. * * ```ts * const { privateKey, publicKey } = await FetchSig.generateEcdsaP256Sha256KeyPair() * * const signer = FetchSig.ecdsaP256Sha256Signer(privateKey) * const verifier = FetchSig.ecdsaP256Sha256Verifier(publicKey) * * // Pass true only when the private key has to leave the process. * const portable = await FetchSig.generateEcdsaP256Sha256KeyPair(true) * const pkcs8 = await crypto.subtle.exportKey('pkcs8', portable.privateKey) * ``` * * @param extractable - Whether the private key can be exported. Defaults to `false`. * * @returns A randomly generated signing and verification key pair. * @group Cryptographic Algorithms */ export async function generateEcdsaP256Sha256KeyPair( extractable?: boolean, ): Promise { return generateWebCryptoKeyPair({ name: 'ECDSA', namedCurve: 'P-256' }, extractable) } /** * Creates a fixed-key signer factory backed by Web Cryptography for RFC 9421 `ecdsa-p256-sha256`. * * Signatures use the RFC-required 64-byte raw `r || s` representation. * * @param key - Web Cryptography's `CryptoKey` for an ECDSA P-256 private key with `sign` usage. * @group Cryptographic Algorithms */ export function ecdsaP256Sha256Signer(key: CryptoKey): SignerFactory { return createWebCryptoSignerFactory( key, { identifier: 'ecdsa-p256-sha256', type: 'private', usage: 'sign', algorithm: 'ECDSA', namedCurve: 'P-256', }, { name: 'ECDSA', hash: 'SHA-256' }, ) } /** * Creates a fixed-key verifier factory backed by Web Cryptography for RFC 9421 `ecdsa-p256-sha256`. * * Signatures use the RFC-required 64-byte raw `r || s` representation. This fixed-key factory does * not perform `keyid` lookup or authorization. Select it from trusted application configuration * when more than one verification key can be used. * * @param key - Web Cryptography's `CryptoKey` for an ECDSA P-256 public key with `verify` usage. * @group Cryptographic Algorithms */ export function ecdsaP256Sha256Verifier(key: CryptoKey): SynchronousVerifierFactory { return createWebCryptoVerifierFactory( key, { identifier: 'ecdsa-p256-sha256', type: 'public', usage: 'verify', algorithm: 'ECDSA', namedCurve: 'P-256', }, { name: 'ECDSA', hash: 'SHA-256' }, ) } /** * Generates an ECDSA P-384 key pair for the RFC 9421 `ecdsa-p384-sha384` algorithm. * * The generated public key is represented by Web Cryptography's `CryptoKey` and is always * extractable. * * @example * * A complete P-384 round trip, signing then verifying the same request. * * ```ts * const { privateKey, publicKey } = await FetchSig.generateEcdsaP384Sha384KeyPair() * * const signed = await FetchSig.sign(new Request('https://api.example/orders'), { * signer: FetchSig.ecdsaP384Sha384Signer(privateKey), * components: ['@method', '@authority', '@path'], * parameters: [['alg', 'ecdsa-p384-sha384']], * }) * * const verified = await FetchSig.verify(signed, { * verifier: FetchSig.ecdsaP384Sha384Verifier(publicKey), * policy: { * requiredComponents: ['@method', '@authority', '@path'], * requiredParameters: ['created', 'alg'], * algorithms: ['ecdsa-p384-sha384'], * maxAge: 60, * }, * }) * * // sig1 ecdsa-p384-sha384 * console.log(verified.label, verified.algorithm) * ``` * * @param extractable - Whether the private key can be exported. Defaults to `false`. * * @returns A randomly generated signing and verification key pair. * @group Cryptographic Algorithms */ export async function generateEcdsaP384Sha384KeyPair( extractable?: boolean, ): Promise { return generateWebCryptoKeyPair({ name: 'ECDSA', namedCurve: 'P-384' }, extractable) } /** * Creates a fixed-key signer factory backed by Web Cryptography for RFC 9421 `ecdsa-p384-sha384`. * * Signatures use the RFC-required 96-byte raw `r || s` representation. * * @param key - Web Cryptography's `CryptoKey` for an ECDSA P-384 private key with `sign` usage. * @group Cryptographic Algorithms */ export function ecdsaP384Sha384Signer(key: CryptoKey): SignerFactory { return createWebCryptoSignerFactory( key, { identifier: 'ecdsa-p384-sha384', type: 'private', usage: 'sign', algorithm: 'ECDSA', namedCurve: 'P-384', }, { name: 'ECDSA', hash: 'SHA-384' }, ) } /** * Creates a fixed-key verifier factory backed by Web Cryptography for RFC 9421 `ecdsa-p384-sha384`. * * Signatures use the RFC-required 96-byte raw `r || s` representation. This fixed-key factory does * not perform `keyid` lookup or authorization. Select it from trusted application configuration * when more than one verification key can be used. * * @param key - Web Cryptography's `CryptoKey` for an ECDSA P-384 public key with `verify` usage. * @group Cryptographic Algorithms */ export function ecdsaP384Sha384Verifier(key: CryptoKey): SynchronousVerifierFactory { return createWebCryptoVerifierFactory( key, { identifier: 'ecdsa-p384-sha384', type: 'public', usage: 'verify', algorithm: 'ECDSA', namedCurve: 'P-384', }, { name: 'ECDSA', hash: 'SHA-384' }, ) } /** * Generates an Ed25519 key pair for the RFC 9421 `ed25519` algorithm. * * The generated public key is represented by Web Cryptography's `CryptoKey` and is always * extractable. * * @example * * Publish the public key in a format a peer can import, and keep the private key non-extractable. * * ```ts * const { privateKey, publicKey } = await FetchSig.generateEd25519KeyPair() * * const signer = FetchSig.ed25519Signer(privateKey) * const jwk = await crypto.subtle.exportKey('jwk', publicKey) * * // { kty: 'OKP', crv: 'Ed25519', x: '…' } * console.log(jwk) * ``` * * @param extractable - Whether the private key can be exported. Defaults to `false`. * * @returns A randomly generated signing and verification key pair. * @group Cryptographic Algorithms */ export async function generateEd25519KeyPair(extractable?: boolean): Promise { return generateWebCryptoKeyPair('Ed25519', extractable) } /** * Creates a fixed-key signer factory backed by Web Cryptography for RFC 9421 `ed25519`. * * The message is signed directly with Ed25519, without an external pre-hash. * * @param key - Web Cryptography's `CryptoKey` for an Ed25519 private key with `sign` usage. * @group Cryptographic Algorithms */ export function ed25519Signer(key: CryptoKey): SignerFactory { return createWebCryptoSignerFactory( key, { identifier: 'ed25519', type: 'private', usage: 'sign', algorithm: 'Ed25519' }, 'Ed25519', ) } /** * Creates a fixed-key verifier factory backed by Web Cryptography for RFC 9421 `ed25519`. * * The message is verified directly with Ed25519, without an external pre-hash. This fixed-key * factory does not perform `keyid` lookup or authorization. Select it from trusted application * configuration when more than one verification key can be used. * * @example * * Compose the fixed-key factory into an application factory that selects a trusted key by `keyid`. * This is the shape to reach for whenever more than one key can sign. * * ```ts * declare const publicKeys: ReadonlyMap * * const verifier: FetchSig.VerifierFactory = (signature, context) => { * const keyid = FetchSig.getSignatureParameter(signature, 'keyid') * if (typeof keyid !== 'string') { * throw new FetchSig.VerificationError('unknown_key', 'A key identifier is required') * } * * const publicKey = publicKeys.get(keyid) * if (publicKey === undefined) { * throw new FetchSig.VerificationError('unknown_key', 'Unknown signing key') * } * * return FetchSig.ed25519Verifier(publicKey)(signature, context) * } * ``` * * @param key - Web Cryptography's `CryptoKey` for an Ed25519 public key with `verify` usage. * @group Cryptographic Algorithms */ export function ed25519Verifier(key: CryptoKey): SynchronousVerifierFactory { return createWebCryptoVerifierFactory( key, { identifier: 'ed25519', type: 'public', usage: 'verify', algorithm: 'Ed25519' }, 'Ed25519', ) } /** * Generates an RSA key pair for the RFC 9421 `rsa-pss-sha512` algorithm. * * The generated public key is represented by Web Cryptography's `CryptoKey` and is always * extractable. RSA keys usually come from existing key management rather than from this generator, * and {@link rsaPssSha512Signer} and {@link rsaPssSha512Verifier} accept an RSA-PSS key of any * modulus length. * * SHA-512 with a 64-byte salt needs at least a 1040-bit modulus to encode a signature at all, so a * shorter key fails when it is used rather than when it is generated. * * @example * * Generate a pair and turn it into the sender and recipient providers. * * ```ts * const { privateKey, publicKey } = await FetchSig.generateRsaPssSha512KeyPair() * * const signer = FetchSig.rsaPssSha512Signer(privateKey) * const verifier = FetchSig.rsaPssSha512Verifier(publicKey) * * // A longer modulus, when the surrounding key policy calls for one. * const strong = await FetchSig.generateRsaPssSha512KeyPair(false, 4096) * ``` * * @param extractable - Whether the private key can be exported. Defaults to `false`. * @param modulusLength - Modulus length in bits. Defaults to `2048`. * * @returns A randomly generated signing and verification key pair. * @group Cryptographic Algorithms */ export async function generateRsaPssSha512KeyPair( extractable?: boolean, modulusLength?: number, ): Promise { return generateWebCryptoKeyPair( { name: 'RSA-PSS', modulusLength: resolveModulusLengthOption(modulusLength), publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-512', }, extractable, ) } /** * Creates a fixed-key signer factory backed by Web Cryptography for RFC 9421 `rsa-pss-sha512`. * * Signatures use MGF1 with SHA-512 and the RFC-required 64-byte salt. The salt length is not * carried by the key, so a provider that leaves it at another value produces signatures no * conforming recipient accepts. * * @param key - Web Cryptography's `CryptoKey` for an RSA-PSS private key with SHA-512 and `sign` * usage. * @group Cryptographic Algorithms */ export function rsaPssSha512Signer(key: CryptoKey): SignerFactory { return createWebCryptoSignerFactory( key, { identifier: 'rsa-pss-sha512', type: 'private', usage: 'sign', algorithm: 'RSA-PSS', hash: 'SHA-512', }, { name: 'RSA-PSS', saltLength: 64 }, ) } /** * Creates a fixed-key verifier factory backed by Web Cryptography for RFC 9421 `rsa-pss-sha512`. * * Signatures use MGF1 with SHA-512 and the RFC-required 64-byte salt. This fixed-key factory does * not perform `keyid` lookup or authorization. Select it from trusted application configuration * when more than one verification key can be used. * * @param key - Web Cryptography's `CryptoKey` for an RSA-PSS public key with SHA-512 and `verify` * usage. * @group Cryptographic Algorithms */ export function rsaPssSha512Verifier(key: CryptoKey): SynchronousVerifierFactory { return createWebCryptoVerifierFactory( key, { identifier: 'rsa-pss-sha512', type: 'public', usage: 'verify', algorithm: 'RSA-PSS', hash: 'SHA-512', }, { name: 'RSA-PSS', saltLength: 64 }, ) } /** * Generates an RSA key pair for the RFC 9421 `rsa-v1_5-sha256` algorithm. * * The generated public key is represented by Web Cryptography's `CryptoKey` and is always * extractable. RSA keys usually come from existing key management rather than from this generator, * and {@link rsaV1_5Sha256Signer} and {@link rsaV1_5Sha256Verifier} accept an RSASSA-PKCS1-v1_5 key * of any modulus length. * * Prefer `rsa-pss-sha512` or `ed25519` for a new design. This algorithm is provided for peers that * require PKCS#1 v1.5, which RFC 9421 describes as the weaker RSA option. * * @param extractable - Whether the private key can be exported. Defaults to `false`. * @param modulusLength - Modulus length in bits. Defaults to `2048`. * * @returns A randomly generated signing and verification key pair. * @group Cryptographic Algorithms */ export async function generateRsaV1_5Sha256KeyPair( extractable?: boolean, modulusLength?: number, ): Promise { return generateWebCryptoKeyPair( { name: 'RSASSA-PKCS1-v1_5', modulusLength: resolveModulusLengthOption(modulusLength), publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256', }, extractable, ) } /** * Creates a fixed-key signer factory backed by Web Cryptography for RFC 9421 `rsa-v1_5-sha256`. * * Prefer {@link rsaPssSha512Signer} or {@link ed25519Signer} for a new design. This algorithm is * provided for peers that require PKCS#1 v1.5, which RFC 9421 describes as the weaker RSA option. * * @param key - Web Cryptography's `CryptoKey` for an RSASSA-PKCS1-v1_5 private key with SHA-256 and * `sign` usage. * @group Cryptographic Algorithms */ export function rsaV1_5Sha256Signer(key: CryptoKey): SignerFactory { return createWebCryptoSignerFactory( key, { identifier: 'rsa-v1_5-sha256', type: 'private', usage: 'sign', algorithm: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256', }, 'RSASSA-PKCS1-v1_5', ) } /** * Creates a fixed-key verifier factory backed by Web Cryptography for RFC 9421 `rsa-v1_5-sha256`. * * This fixed-key factory does not perform `keyid` lookup or authorization. Select it from trusted * application configuration when more than one verification key can be used. * * Accept this algorithm only for peers that require PKCS#1 v1.5, and keep it out of the policy * allowlist everywhere else. RFC 9421 describes it as the weaker RSA option and warns about * {@link https://www.rfc-editor.org/info/rfc9421/#section-7.3.6 | algorithm downgrade attacks}. * * @param key - Web Cryptography's `CryptoKey` for an RSASSA-PKCS1-v1_5 public key with SHA-256 and * `verify` usage. * @group Cryptographic Algorithms */ export function rsaV1_5Sha256Verifier(key: CryptoKey): SynchronousVerifierFactory { return createWebCryptoVerifierFactory( key, { identifier: 'rsa-v1_5-sha256', type: 'public', usage: 'verify', algorithm: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256', }, 'RSASSA-PKCS1-v1_5', ) } /** * Reports whether a signable message is a request rather than a response. * * Only requests carry a method, and Fetch exposes it as a string. */ function isRequest(message: SignableRequest | SignableResponse): message is SignableRequest { return typeof (message as Request).method === 'string' } /** * Reports whether a value implements the mutating and reading operations of `Headers`. * * Used to distinguish the `Headers` overload of {@link appendSignature} from its message overloads * without depending on a particular runtime's class identity. */ function isHeaders(value: unknown): value is Headers { return ( value !== null && typeof value === 'object' && typeof (value as Headers).append === 'function' && typeof (value as Headers).delete === 'function' && typeof (value as Headers).get === 'function' && typeof (value as Headers).has === 'function' && typeof (value as Headers).set === 'function' ) } /** * Reports whether a value is a `Date`, including one created in another realm. * * `Date.prototype.getTime` reads an internal slot and throws for anything that is not a real * `Date`, so this cannot be defeated by a look-alike object. `Object.prototype.toString` would be, * because any object can claim `Symbol.toStringTag` of `"Date"`. */ function isDate(value: unknown): value is Date { try { Date.prototype.getTime.call(value as Date) return true } catch { return false } } /** * The `%TypedArray%.prototype[Symbol.toStringTag]` getter, which reports the name of a typed array * from an internal slot and returns `undefined` for every other value. */ const typedArrayName = /* @__PURE__ */ Object.getOwnPropertyDescriptor( /* @__PURE__ */ Object.getPrototypeOf(Uint8Array.prototype), Symbol.toStringTag, )!.get! as (this: unknown) => string | undefined /** * Reports whether a value is a `Uint8Array`, including one created in another realm. * * The typed array name is read through the built-in getter rather than with * `Object.prototype.toString`, which any object can spoof through `Symbol.toStringTag`. Spoofing it * mattered: a `DataView` or a `Float64Array` labelled as a `Uint8Array` used to be copied by `new * Uint8Array(value)` into a silently wrong Byte Sequence instead of being rejected. */ function isUint8Array(value: unknown): value is Uint8Array { return typedArrayName.call(value) === 'Uint8Array' } /** * Rejects a value that is not usable as a {@link SignableRequest} or {@link SignableResponse}. * * Structural rather than instance-based, so that messages from a runtime's own Fetch * implementation, a test double, a transport integration, or a plain descriptor built by a server * that never constructs a `Request` are all accepted. */ function assertMessage(message: unknown): asserts message is SignableRequest | SignableResponse { const candidate = message as (Request | Response) | null if ( candidate === null || typeof candidate !== 'object' || candidate.headers === null || typeof candidate.headers !== 'object' ) { fail('"message" must be a Request, Response, or plain message descriptor') } if (isRequest(candidate)) { // Every request-targeted derived component is read from the target URI, so a request without a // usable "url" is rejected here instead of failing later with a less specific error. if (typeof candidate.url !== 'string') { fail('"message" must be a Request, Response, or plain message descriptor') } return } if (typeof (candidate as Response).status !== 'number') { fail('"message" must be a Request, Response, or plain message descriptor') } } /** * Rejects the shared signature context options that are only consulted later, while deriving * individual components, so that a malformed option is reported by every entry point instead of * only by the messages that happen to use it. */ function assertSignatureContext(context: Readonly): void { if ( context.structuredFields !== undefined && (context.structuredFields === null || typeof context.structuredFields !== 'object') ) { fail('"structuredFields" must be an object') } } /** * Rejects a value that is not a Structured Field key, which is the syntax RFC 9421 requires for * signature labels, signature metadata parameter names, and component parameter names. */ function assertSfKey(value: string, description: string): void { if (typeof value !== 'string' || !SF_KEY.test(value)) { fail(`${description} must be a Structured Field key`) } } /** Rejects a string that contains any non-ASCII character. */ function assertAscii(value: string, description: string): void { if (!ASCII.test(value)) { fail(`${description} must contain only ASCII characters`) } } /** * Copies bytes into a newly allocated `Uint8Array` so that neither side of an API boundary can * observe or modify the other's buffer. */ function cloneBytes(value: Uint8Array): Uint8Array { return new Uint8Array(value) } interface OccurrenceKnowledge { readonly headers: ReadonlySet readonly trailers: ReadonlySet } /** Exact-occurrence metadata kept out of the public immutable snapshot. */ const occurrenceKnowledge = new WeakMap() interface CapturedOccurrences { readonly fields: FieldOccurrences readonly exact: ReadonlySet } /** Captures and freezes one HTTP field section, retaining descriptor occurrence boundaries. */ function captureOccurrences(input: HeadersInput | undefined): CapturedOccurrences { const values = new Map() const exact = new Set() if (input !== undefined && isHeaders(input)) { for (const [name, value] of input) { values.set(name, [value]) } const getSetCookie = (input as Headers & { getSetCookie?: () => string[] }).getSetCookie if (typeof getSetCookie === 'function' && input.has('set-cookie')) { const occurrences = getSetCookie.call(input) if (occurrences.length !== 0) { values.set('set-cookie', [...occurrences]) exact.add('set-cookie') } } } else if (input !== undefined) { const validation = new Headers() for (const [inputName, inputValue] of Object.entries(input)) { if (inputValue === undefined) { continue } // Validate the field name without forcing explicit raw occurrences through Fetch's value // parser. A descriptor is also how an integration supplies obs-fold and non-ASCII octets that // Fetch cannot represent; component derivation validates and canonicalizes those values. validation.append(inputName, '') const name = inputName.toLowerCase() exact.add(name) const occurrences = Array.isArray(inputValue) ? inputValue : [inputValue] for (const occurrence of occurrences) { if (typeof occurrence !== 'string') { fail('HTTP field occurrences must be strings') } const existing = values.get(name) if (existing === undefined) { values.set(name, [occurrence]) } else { existing.push(occurrence) } } } } const fields = Object.create(null) as Record> for (const [name, occurrences] of values) { Object.defineProperty(fields, name, { value: Object.freeze([...occurrences]), enumerable: true, configurable: false, writable: false, }) } return { fields: Object.freeze(fields), exact } } /** Creates the immutable message value used for one complete signing or verification operation. */ function captureMessage(message: SignableRequest | SignableResponse): MessageSnapshot { assertMessage(message) const headers = captureOccurrences(message.headers) const trailers = captureOccurrences((message as { readonly trailers?: HeadersInput }).trailers) const snapshot: MessageSnapshot = isRequest(message) ? Object.freeze({ method: message.method, url: message.url, headers: headers.fields, trailers: trailers.fields, }) : Object.freeze({ status: message.status, headers: headers.fields, trailers: trailers.fields }) occurrenceKnowledge.set(snapshot, { headers: headers.exact, trailers: trailers.exact }) return snapshot } interface MessageBinding { readonly source: SignableRequest | SignableResponse readonly snapshot: MessageSnapshot readonly requestSource?: SignableRequest readonly requestSnapshot?: RequestSnapshot } interface CapturedSignatureOperation { readonly message: MessageSnapshot readonly context: SignatureContext readonly binding: MessageBinding } /** Captures the target, related request, and structured-field configuration once at invocation. */ function captureSignatureOperation( message: SignableRequest | SignableResponse, context: SignatureContext, ): CapturedSignatureOperation { const snapshot = captureMessage(message) let requestSource: SignableRequest | undefined let requestSnapshot: RequestSnapshot | undefined if (context.request !== undefined) { assertMessage(context.request) if (!isRequest(context.request)) { fail('"request" must be the related Request') } requestSource = context.request requestSnapshot = captureMessage(requestSource) as RequestSnapshot } return { message: snapshot, context: Object.freeze({ request: requestSnapshot, structuredFields: snapshotStructuredFields(context.structuredFields), }), binding: { source: message, snapshot, requestSource, requestSnapshot }, } } /** Reports whether two immutable field occurrence records carry the same values. */ function occurrencesEqual(left: FieldOccurrences, right: FieldOccurrences): boolean { const leftNames = Object.keys(left) const rightNames = Object.keys(right) return ( leftNames.length === rightNames.length && leftNames.every((name) => { const leftValues = left[name]! const rightValues = right[name] return ( rightValues !== undefined && leftValues.length === rightValues.length && leftValues.every((value, index) => Object.is(value, rightValues[index])) ) }) ) } /** Reports whether two sets contain the same field names. */ function fieldNameSetsEqual(left: ReadonlySet, right: ReadonlySet): boolean { return left.size === right.size && [...left].every((name) => right.has(name)) } /** Reports whether two snapshots expose the same exact field-occurrence boundaries. */ function occurrenceKnowledgeEqual(left: MessageSnapshot, right: MessageSnapshot): boolean { const leftKnowledge = occurrenceKnowledge.get(left)! const rightKnowledge = occurrenceKnowledge.get(right)! return ( fieldNameSetsEqual(leftKnowledge.headers, rightKnowledge.headers) && fieldNameSetsEqual(leftKnowledge.trailers, rightKnowledge.trailers) ) } /** Reports whether two snapshots describe the same message kind and routing properties. */ function snapshotPropertiesEqual(left: MessageSnapshot, right: MessageSnapshot): boolean { if (isRequest(left)) { return isRequest(right) && left.method === right.method && left.url === right.url } return !isRequest(right) && Object.is(left.status, right.status) } /** Rejects a source that no longer equals the snapshot every callback observed. */ function assertBindingUnchanged( binding: MessageBinding, operation: 'signing' | 'verification', ): void { let current: MessageSnapshot let currentRequest: RequestSnapshot | undefined try { current = captureMessage(binding.source) currentRequest = binding.requestSource === undefined ? undefined : (captureMessage(binding.requestSource) as RequestSnapshot) } catch (cause) { throw new Error(`HTTP message context changed during signature ${operation}`, { cause }) } if ( !snapshotPropertiesEqual(binding.snapshot, current) || (binding.requestSnapshot !== undefined && !snapshotPropertiesEqual(binding.requestSnapshot, currentRequest!)) ) { throw new Error(`HTTP message context changed during signature ${operation}`) } if ( !occurrencesEqual(binding.snapshot.headers, current.headers) || (binding.requestSnapshot !== undefined && !occurrencesEqual(binding.requestSnapshot.headers, currentRequest!.headers)) ) { throw new Error(`HTTP message headers changed during signature ${operation}`) } if ( !occurrencesEqual(binding.snapshot.trailers, current.trailers) || (binding.requestSnapshot !== undefined && !occurrencesEqual(binding.requestSnapshot.trailers, currentRequest!.trailers)) ) { throw new Error(`HTTP message trailers changed during signature ${operation}`) } if ( !occurrenceKnowledgeEqual(binding.snapshot, current) || (binding.requestSnapshot !== undefined && !occurrenceKnowledgeEqual(binding.requestSnapshot, currentRequest!)) ) { throw new Error(`HTTP message field occurrences changed during signature ${operation}`) } } /** Reports a message mutation during verification as an operational verification failure. */ function assertVerificationBindingUnchanged(binding: MessageBinding): void { try { assertBindingUnchanged(binding, 'verification') } catch (cause) { throw verificationError('verification_failed', cause) } } /** * Reports whether two byte sequences have the same length and contents. * * The comparison is not short-circuiting, but it is only used on public Structured Field values and * never on secrets. */ function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { if (left.byteLength !== right.byteLength) { return false } let different = 0 for (let i = 0; i < left.byteLength; i++) { different |= left[i]! ^ right[i]! } return different === 0 } /** * Encodes bytes as padded standard base64, the form RFC 9651 requires when serializing a Structured * Field Byte Sequence. * * `Uint8Array.prototype.toBase64()` defaults to exactly that form. Runtimes that do not implement * it fall back to `btoa()`, which takes a string of code units below `U+0100`. The bytes are spread * into that string in small chunks, because engines cap how many arguments a call may spread and * the cap differs between them. * * The method is looked up on the value rather than on `Uint8Array.prototype`, so a typed array from * a realm that does not implement it still takes the fallback. */ function base64Encode(value: Uint8Array): string { if (typeof value.toBase64 === 'function') { return value.toBase64() } let binary = '' const chunk = 0x1000 for (let i = 0; i < value.byteLength; i += chunk) { binary += String.fromCharCode(...value.subarray(i, i + chunk)) } return btoa(binary) } /** * Decodes the contents of a Structured Field Byte Sequence. * * The alphabet and length are checked before decoding, because both decoders skip ASCII whitespace * and RFC 9651 does not: `:AQ I=:` has to fail. * * RFC 9651 lets a parser synthesize missing padding, so unpadded input is accepted. * `Uint8Array.fromBase64()` does that under its default `lastChunkHandling` of `"loose"`, and the * `atob()` fallback is handed the padding explicitly. `atob()` is forgiving-base64 decoding and * would synthesize it anyway, so that is belt and braces: it keeps the RFC 9651 requirement met * without depending on a second specification for it. * * Both decoders ignore non-zero padding bits, which RFC 9651 permits because not every base64 * implementation is able to reject them. */ function base64Decode(value: string): Uint8Array { if ( !/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 === 1 || (value.includes('=') && value.length % 4 !== 0) ) { fail('Invalid Structured Field Byte Sequence') } try { if (typeof Uint8Array.fromBase64 === 'function') { return Uint8Array.fromBase64(value) } const binary = atob(value + '='.repeat((4 - (value.length % 4)) % 4)) const output = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i++) { output[i] = binary.charCodeAt(i) } return output } catch (cause) { // Reported as a SyntaxError by the runtime. Every other parse failure here is a TypeError. throw new TypeError('Invalid Structured Field Byte Sequence', { cause }) } } /** Advances past any run of spaces, per the leading-whitespace handling of RFC 9651. */ function skipSp(state: ParseState): void { while (state.input[state.index] === ' ') { state.index++ } } /** * Advances past any run of optional whitespace, which RFC 9651 defines as spaces and horizontal * tabs. */ function skipOws(state: ParseState): void { while (state.input[state.index] === ' ' || state.input[state.index] === '\t') { state.index++ } } /** Parses an RFC 9651 key, used for Dictionary member names and parameter names. */ function parseKey(state: ParseState): string { const start = state.index const first = state.input[state.index] if (first === undefined || !/[a-z*]/.test(first)) { fail('Invalid Structured Field key') } state.index++ while (state.index < state.input.length && /[a-z0-9_.*-]/.test(state.input[state.index]!)) { state.index++ } return state.input.slice(start, state.index) } /** * Inserts or replaces an entry in an ordered map, keeping the position of an existing key. * * RFC 9651 Dictionaries and Parameters are ordered maps in which a repeated key overwrites the * earlier value in place. When `duplicateKeys` is supplied, the repeated key is recorded so that a * caller such as `Signature-Input` parsing can reject the input outright. */ function setOrderedEntry( entries: T[], positions: Map, entry: T, duplicateKeys?: string[], ): void { const position = positions.get(entry[0]) if (position === undefined) { positions.set(entry[0], entries.length) entries.push(entry) } else { duplicateKeys?.push(entry[0]) entries[position] = entry } } /** Parses an RFC 9651 Integer or Decimal, enforcing the digit limits that bound each type. */ function parseNumber(state: ParseState): SfBareItem { let sign = 1 if (state.input[state.index] === '-') { sign = -1 state.index++ } const start = state.index while (/[0-9]/.test(state.input[state.index] ?? '')) { state.index++ } const integerDigits = state.index - start if (integerDigits === 0) { fail('Invalid Structured Field number') } if (state.input[state.index] === '.') { if (integerDigits > 12) { fail('Structured Field Decimal is out of range') } state.index++ const fractionStart = state.index while (/[0-9]/.test(state.input[state.index] ?? '')) { state.index++ } const fractionDigits = state.index - fractionStart if (fractionDigits === 0 || fractionDigits > 3) { fail('Invalid Structured Field Decimal') } const value = Number(state.input.slice(start, state.index)) * sign return { kind: 'decimal', value } } if (integerDigits > 15) { fail('Structured Field Integer is out of range') } const value = Number(state.input.slice(start, state.index)) * sign return { kind: 'integer', value } } /** Parses an RFC 9651 String, resolving the two permitted escape sequences. */ function parseString(state: ParseState): SfBareItem { if (state.input[state.index] !== '"') { fail('Invalid Structured Field String') } state.index++ let value = '' while (state.index < state.input.length) { const character = state.input[state.index++]! if (character === '\\') { const escaped = state.input[state.index++] if (escaped !== '"' && escaped !== '\\') { fail('Invalid escape in Structured Field String') } value += escaped } else if (character === '"') { return { kind: 'string', value } } else if (!PRINTABLE_ASCII.test(character)) { fail('Invalid character in Structured Field String') } else { value += character } } return fail('Unterminated Structured Field String') } /** Parses an RFC 9651 Token. */ function parseToken(state: ParseState): SfBareItem { const start = state.index const first = state.input[state.index] if (first === undefined || !/[A-Za-z*]/.test(first)) { fail('Invalid Structured Field Token') } state.index++ while ( state.index < state.input.length && /[!#$%&'*+\-.^_`|~A-Za-z0-9:/*]/.test(state.input[state.index]!) ) { state.index++ } return { kind: 'token', value: state.input.slice(start, state.index) } } /** Parses an RFC 9651 Byte Sequence, delimited by colons. */ function parseBinary(state: ParseState): SfBareItem { if (state.input[state.index] !== ':') { fail('Invalid Structured Field Byte Sequence') } state.index++ const end = state.input.indexOf(':', state.index) if (end === -1) { fail('Unterminated Structured Field Byte Sequence') } const encoded = state.input.slice(state.index, end) state.index = end + 1 return { kind: 'binary', value: base64Decode(encoded) } } /** Parses an RFC 9651 Boolean. */ function parseBoolean(state: ParseState): SfBareItem { if (state.input[state.index] !== '?') { fail('Invalid Structured Field Boolean') } const value = state.input[state.index + 1] if (value !== '0' && value !== '1') { fail('Invalid Structured Field Boolean') } state.index += 2 return { kind: 'boolean', value: value === '1' } } /** Parses an RFC 9651 Date, which is an Integer of UNIX seconds prefixed with `@`. */ function parseDate(state: ParseState): SfBareItem { if (state.input[state.index] !== '@') { fail('Invalid Structured Field Date') } state.index++ const value = parseNumber(state) if (value.kind !== 'integer') { fail('Structured Field Date must contain an Integer') } return { kind: 'date', value: value.value } } /** * Parses an RFC 9651 Display String, percent-decoding the escaped octets and decoding the result as * UTF-8. */ function parseDisplayString(state: ParseState): SfBareItem { if (state.input[state.index] !== '%' || state.input[state.index + 1] !== '"') { fail('Invalid Structured Field Display String') } state.index += 2 const bytes: number[] = [] while (state.index < state.input.length) { const character = state.input[state.index++]! if (character === '"') { try { return { kind: 'display-string', value: decoder.decode(new Uint8Array(bytes)) } } catch (cause) { throw new TypeError('Invalid UTF-8 in Structured Field Display String', { cause }) } } if (!PRINTABLE_ASCII.test(character)) { fail('Invalid character in Structured Field Display String') } if (character === '%') { const encoded = state.input.slice(state.index, state.index + 2) if (!/^[0-9a-f]{2}$/.test(encoded)) { fail('Invalid percent encoding in Structured Field Display String') } bytes.push(Number.parseInt(encoded, 16)) state.index += 2 } else { bytes.push(character.charCodeAt(0)) } } return fail('Unterminated Structured Field Display String') } /** Parses any RFC 9651 bare item by dispatching on its first character. */ function parseBareItem(state: ParseState): SfBareItem { const character = state.input[state.index] if (character === '-' || /[0-9]/.test(character ?? '')) { return parseNumber(state) } if (character === '"') { return parseString(state) } if (/[A-Za-z*]/.test(character ?? '')) { return parseToken(state) } if (character === ':') { return parseBinary(state) } if (character === '?') { return parseBoolean(state) } if (character === '@' || character === '%') { return character === '@' ? parseDate(state) : parseDisplayString(state) } return fail('Unrecognized Structured Field item') } /** Parses a trailing RFC 9651 parameter list, defaulting a parameter with no value to Boolean true. */ function parseParameters(state: ParseState): SfParameters { const parameters: SfParameters = [] const positions = new Map() while (state.input[state.index] === ';') { state.index++ skipSp(state) const name = parseKey(state) let value: SfBareItem = { kind: 'boolean', value: true } if (state.input[state.index] === '=') { state.index++ value = parseBareItem(state) } setOrderedEntry(parameters, positions, [name, value]) } return parameters } /** Parses an RFC 9651 Item, which is a bare item with its parameters. */ function parseItem(state: ParseState): SfItem { return { kind: 'item', value: parseBareItem(state), parameters: parseParameters(state) } } /** * Parses an RFC 9651 Inner List, which is the form the `Signature-Input` and `Accept-Signature` * members take. */ function parseInnerList(state: ParseState): SfInnerList { if (state.input[state.index] !== '(') { fail('Invalid Structured Field Inner List') } state.index++ const value: SfItem[] = [] while (state.index < state.input.length) { skipSp(state) if (state.input[state.index] === ')') { state.index++ return { kind: 'inner-list', value, parameters: parseParameters(state) } } value.push(parseItem(state)) const next = state.input[state.index] if (next !== ' ' && next !== ')') { fail('Invalid delimiter in Structured Field Inner List') } } return fail('Unterminated Structured Field Inner List') } /** Parses one member of an RFC 9651 List or Dictionary, which is either an Inner List or an Item. */ function parseMember(state: ParseState): SfMember { return state.input[state.index] === '(' ? parseInnerList(state) : parseItem(state) } /** Parses an RFC 9651 List. */ function parseList(state: ParseState): SfList { const output: SfList = [] while (state.index < state.input.length) { output.push(parseMember(state)) skipOws(state) if (state.index === state.input.length) { return output } if (state.input[state.index] !== ',') { fail('Invalid Structured Field List delimiter') } state.index++ skipOws(state) if (state.index === state.input.length) { fail('Structured Field List has a trailing comma') } } return output } /** Parses an RFC 9651 Dictionary, defaulting a member with no value to Boolean true. */ function parseDictionary(state: ParseState): SfDictionary { const output: SfDictionary = [] const positions = new Map() while (state.index < state.input.length) { const name = parseKey(state) let value: SfMember if (state.input[state.index] === '=') { state.index++ value = parseMember(state) } else { value = { kind: 'item', value: { kind: 'boolean', value: true }, parameters: parseParameters(state), } } setOrderedEntry(output, positions, [name, value], state.duplicateKeys) skipOws(state) if (state.index === state.input.length) { return output } if (state.input[state.index] !== ',') { fail('Invalid Structured Field Dictionary delimiter') } state.index++ skipOws(state) if (state.index === state.input.length) { fail('Structured Field Dictionary has a trailing comma') } } return output } /** * Parses a complete HTTP field value as the given Structured Field top-level type. * * Trailing content is rejected, as required by RFC 9651. Setting `rejectDuplicateKeys` additionally * rejects a Dictionary that repeats a key, which the `Signature`, `Signature-Input`, and * `Accept-Signature` fields must not do because a repeated label would silently discard a * signature. */ function parseSfTopLevel( input: string, type: StructuredFieldType, rejectDuplicateKeys = false, ): SfTopLevel { assertAscii(input, 'Structured Field value') const state: ParseState = { input, index: 0, duplicateKeys: [] } skipSp(state) let output: SfTopLevel switch (type) { case 'dictionary': output = parseDictionary(state) break case 'list': output = parseList(state) break case 'item': output = parseItem(state) break } skipSp(state) if (state.index !== state.input.length) { fail('Unexpected data after Structured Field value') } if (rejectDuplicateKeys && state.duplicateKeys.length !== 0) { fail(`Duplicate Structured Field Dictionary key "${state.duplicateKeys[0]}"`) } return output } /** Serializes an RFC 9651 key, rejecting a value that is not one. */ function serializeKey(value: string): string { assertSfKey(value, 'Structured Field key') return value } /** Serializes an RFC 9651 String, escaping backslashes and double quotes. */ function serializeString(value: string): string { if (!PRINTABLE_ASCII.test(value)) { fail('Structured Field String must contain only printable ASCII characters') } return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"` } /** * Rejects a string containing an unpaired UTF-16 surrogate. * * RFC 9651 Display Strings hold Unicode scalar values, and an unpaired surrogate cannot be encoded * as UTF-8. */ function assertUnicodeScalarValues(value: string): void { for (let index = 0; index < value.length; index++) { const code = value.charCodeAt(index) if (code >= 0xd800 && code <= 0xdbff) { const next = value.charCodeAt(index + 1) if (!Number.isInteger(next) || next < 0xdc00 || next > 0xdfff) { fail('Structured Field Display String contains an unpaired surrogate') } index++ } else if (code >= 0xdc00 && code <= 0xdfff) { fail('Structured Field Display String contains an unpaired surrogate') } } } /** * Serializes an RFC 9651 Display String, UTF-8 encoding the value and percent-escaping every octet * that is not safe printable ASCII. */ function serializeDisplayString(value: string): string { assertUnicodeScalarValues(value) let output = '%"' for (const byte of encoder.encode(value)) { if (byte === 0x22 || byte === 0x25 || byte <= 0x1f || byte >= 0x7f) { output += `%${byte.toString(16).padStart(2, '0')}` } else { output += String.fromCharCode(byte) } } return `${output}"` } /** * Serializes an RFC 9651 Decimal. * * The value is scaled to thousandths using exact integer arithmetic and rounded half to even, as * the RFC requires, because binary floating point cannot represent every decimal exactly. Values * that cannot be represented within the RFC's range are rejected. */ function serializeDecimal(value: number): string { if (!Number.isFinite(value)) { fail('Structured Field Decimal must be finite') } const absolute = Math.abs(value) const [mantissa, exponentInput] = absolute.toString().toLowerCase().split('e') const exponent = exponentInput === undefined ? 0 : Number(exponentInput) const point = mantissa!.indexOf('.') const fractionDigits = point === -1 ? 0 : mantissa!.length - point - 1 const digits = point === -1 ? mantissa! : mantissa!.slice(0, point) + mantissa!.slice(point + 1) let numerator = BigInt(digits) const power = exponent - fractionDigits + 3 let scaled: bigint if (power >= 0) { scaled = numerator * 10n ** BigInt(power) } else { const denominator = 10n ** BigInt(-power) scaled = numerator / denominator const remainder = numerator % denominator const comparison = remainder * 2n - denominator if (comparison > 0n || (comparison === 0n && scaled % 2n === 1n)) { scaled++ } } if (scaled > 999_999_999_999_999n) { fail('Structured Field Decimal is out of range') } const integer = scaled / 1000n const remainder = (scaled % 1000n).toString().padStart(3, '0').replace(/0+$/, '') let output = `${integer}.${remainder || '0'}` if (value < 0 && scaled !== 0n) { output = `-${output}` } return output } /** Serializes any RFC 9651 bare item, enforcing the range limits of Integers and Dates. */ function serializeBareItem(item: SfBareItem): string { switch (item.kind) { case 'integer': if (!Number.isSafeInteger(item.value) || Math.abs(item.value) > 999_999_999_999_999) { fail('Structured Field Integer is out of range') } return Object.is(item.value, -0) ? '0' : String(item.value) case 'decimal': return serializeDecimal(item.value) case 'string': return serializeString(item.value) case 'token': if (!SF_TOKEN.test(item.value)) { fail('Invalid Structured Field Token') } return item.value case 'binary': return `:${base64Encode(item.value)}:` case 'boolean': return item.value ? '?1' : '?0' case 'date': if (!Number.isSafeInteger(item.value) || Math.abs(item.value) > 999_999_999_999_999) { fail('Structured Field Date is out of range') } return `@${Object.is(item.value, -0) ? '0' : String(item.value)}` case 'display-string': return serializeDisplayString(item.value) } } /** Serializes an RFC 9651 parameter list, omitting the value of a parameter that is Boolean true. */ function serializeParameters(parameters: SfParameters): string { let output = '' for (const [name, value] of parameters) { output += `;${serializeKey(name)}` if (value.kind !== 'boolean' || !value.value) { output += `=${serializeBareItem(value)}` } } return output } /** Serializes an RFC 9651 Item, which is a bare item followed by its parameters. */ function serializeItem(item: SfItem): string { return serializeBareItem(item.value) + serializeParameters(item.parameters) } /** Serializes an RFC 9651 Inner List, which is the form of a `Signature-Input` member value. */ function serializeInnerList(value: SfInnerList): string { return `(${value.value.map(serializeItem).join(' ')})${serializeParameters(value.parameters)}` } /** Serializes one member of an RFC 9651 List or Dictionary. */ function serializeMember(value: SfMember): string { return value.kind === 'inner-list' ? serializeInnerList(value) : serializeItem(value) } /** Serializes an RFC 9651 List. */ function serializeList(value: SfList): string { return value.map(serializeMember).join(', ') } /** Serializes an RFC 9651 Dictionary, omitting the value of a member that is Boolean true. */ function serializeDictionary(value: SfDictionary): string { return value .map(([name, member]) => { const key = serializeKey(name) if (member.kind === 'item' && member.value.kind === 'boolean' && member.value.value) { return key + serializeParameters(member.parameters) } return `${key}=${serializeMember(member)}` }) .join(', ') } /** * Serializes a parsed Structured Field back to its strict RFC 9651 form. * * This is the "re-serialization" that the `sf` component parameter of RFC 9421 requires, which * normalizes internal whitespace and item representation. */ function serializeSfTopLevel(value: SfTopLevel, type: StructuredFieldType): string { switch (type) { case 'dictionary': return serializeDictionary(value as SfDictionary) case 'list': return serializeList(value as SfList) case 'item': return serializeItem(value as SfItem) } } /** * Creates a validated Structured Field Token, for use as an extension signature metadata parameter * value. * * Plain JavaScript strings are Structured Field Strings, so this wrapper is how a value is marked * as a Token instead. * * @example * * The wrapper is the difference between a quoted String and a bare Token on the wire. * * ```ts * const base = FetchSig.createSignatureBase(new Request('https://api.example/orders'), { * components: ['@method'], * parameters: [ * ['as-string', 'example/value'], * ['as-token', FetchSig.token('example/value')], * ], * }) * * // "@signature-params": ("@method");as-string="example/value";as-token=example/value * console.log(base) * ``` * * @group Structured Fields */ export function token(value: string): StructuredFieldToken { if (typeof value !== 'string' || !SF_TOKEN.test(value)) { fail('"value" must be a Structured Field Token') } return { type: 'token', value } } /** * Creates a validated Structured Field Decimal. * * Use this wrapper when an integral value must retain its Decimal type, such as `decimal(1)` for * the serialized value `1.0`. * * @example * * A plain integral number is an Integer, and the wrapper keeps it a Decimal. Values are rounded to * three fraction digits, half to even, as RFC 9651 requires. * * ```ts * const base = FetchSig.createSignatureBase(new Request('https://api.example/orders'), { * components: ['@method'], * parameters: [ * ['as-integer', 1], * ['as-decimal', FetchSig.decimal(1)], * ['rounded', FetchSig.decimal(1.23456)], * ], * }) * * // "@signature-params": ("@method");as-integer=1;as-decimal=1.0;rounded=1.235 * console.log(base) * ``` * * @group Structured Fields */ export function decimal(value: number): StructuredFieldDecimal { return { type: 'decimal', value: Number(serializeDecimal(value)) } } /** * Creates a validated Structured Field Date. * * Numbers are interpreted as integer UNIX seconds. JavaScript `Date` values are rounded down to * whole UNIX seconds. A JavaScript `Date` passed directly as a signature parameter is an RFC 9421 * Integer timestamp. Wrap it with `date()` to select a Structured Field Date and serialize it with * the `@` prefix. * * @example * * The same instant, as the Integer form RFC 9421 defines for `created` and `expires`, and as a * Structured Field Date. Only use the Date type for extension parameters. * * ```ts * const instant = new Date(1_659_578_233_000) * * const base = FetchSig.createSignatureBase(new Request('https://api.example/orders'), { * components: ['@method'], * parameters: [ * ['created', instant], * ['example-date', FetchSig.date(instant)], * ], * }) * * // "@signature-params": ("@method");created=1659578233;example-date=@1659578233 * console.log(base) * ``` * * @group Structured Fields */ export function date(value: number | Date): StructuredFieldDate { let seconds: number if (isDate(value)) { seconds = Math.floor(Date.prototype.getTime.call(value) / 1000) } else if (typeof value === 'number') { seconds = value } else { return fail('"value" must be a number of UNIX seconds or a Date') } if (!Number.isSafeInteger(seconds) || Math.abs(seconds) > 999_999_999_999_999) { fail('Structured Field Date is out of range') } return { type: 'date', value: Object.is(seconds, -0) ? 0 : seconds } } /** * Creates a validated Structured Field Display String. * * The value must contain only Unicode scalar values. Serialization UTF-8 encodes characters that * are not safe ASCII and represents their bytes using lowercase percent encoding. Display Strings * are intended for text shown to users. Use a regular Structured Field String when Unicode display * text is not required. * * @example * * A Structured Field String cannot carry non-ASCII text at all, so Unicode needs this wrapper. * * ```ts * const base = FetchSig.createSignatureBase(new Request('https://api.example/orders'), { * components: ['@method'], * parameters: [['example-display', FetchSig.displayString('snowman ☃')]], * }) * * // "@signature-params": ("@method");example-display=%"snowman %e2%98%83" * console.log(base) * ``` * * @group Structured Fields */ export function displayString(value: string): StructuredFieldDisplayString { if (typeof value !== 'string') { fail('"value" must be a string') } serializeDisplayString(value) return { type: 'display-string', value } } /** Converts one parsed Structured Field Item into its public representation. */ function structuredFieldItemFromSf(item: SfItem): StructuredFieldItem { return { type: 'item', value: signatureParameterValueFromSfBareItem(item.value), parameters: structuredFieldParametersFromSf(item.parameters), } } /** Converts parsed Structured Field parameters into their public representation. */ function structuredFieldParametersFromSf(parameters: SfParameters): StructuredFieldParameter[] { return parameters.map(([name, value]) => [name, signatureParameterValueFromSfBareItem(value)]) } /** Converts one parsed List or Dictionary member into its public representation. */ function structuredFieldMemberFromSf(member: SfMember): StructuredFieldMember { if (member.kind === 'item') { return structuredFieldItemFromSf(member) } return { type: 'inner-list', value: member.value.map(structuredFieldItemFromSf), parameters: structuredFieldParametersFromSf(member.parameters), } } /** * Converts application-supplied Structured Field parameters into their internal representation. * * Names are validated as RFC 9651 keys, and duplicates are rejected rather than silently collapsed * during serialization. */ function sfParametersFromStructuredField( parameters: ReadonlyArray | undefined, path: string, ): SfParameters { if (parameters === undefined) { return [] } if (!Array.isArray(parameters)) { fail(`${path} parameters must be an array`) } const output: SfParameters = [] const seen = new Set() for (const entry of parameters) { if (!Array.isArray(entry) || entry.length !== 2) { fail(`${path} parameters must be [name, value] entries`) } const [name, value] = entry as [string, StructuredFieldBareItem] assertSfKey(name, `${path} parameter name`) if (seen.has(name)) { fail(`Duplicate ${path} parameter "${name}"`) } seen.add(name) const bare = sfBareItemFromSignatureParameter(`${path} parameter "${name}"`, value) if (bare === undefined) { fail(`${path} parameter "${name}" has an unsupported value`) } output.push([name, bare]) } return output } /** Converts one application-supplied Structured Field Item into its internal representation. */ function sfItemFromStructuredField(item: StructuredFieldItem, path: string): SfItem { if (item === null || typeof item !== 'object' || item.type !== 'item') { fail(`${path} must be a Structured Field Item`) } const value = sfBareItemFromSignatureParameter(path, item.value) if (value === undefined) { fail(`${path} has an unsupported value`) } return { kind: 'item', value, parameters: sfParametersFromStructuredField(item.parameters, path) } } /** Converts one application-supplied List or Dictionary member into its internal representation. */ function sfMemberFromStructuredField(member: StructuredFieldMember, path: string): SfMember { if (member === null || typeof member !== 'object') { fail(`${path} must be a Structured Field Item or Inner List`) } if (member.type === 'inner-list') { if (!Array.isArray(member.value)) { fail(`${path} Inner List value must be an array`) } return { kind: 'inner-list', value: member.value.map((entry, index) => sfItemFromStructuredField(entry, `${path} member ${index}`), ), parameters: sfParametersFromStructuredField(member.parameters, path), } } return sfItemFromStructuredField(member as StructuredFieldItem, path) } /** * Parses an HTTP field value as one of the three RFC 9651 top-level Structured Field types. * * The whole value must parse, so trailing content is rejected rather than ignored. A Dictionary * that repeats a key keeps the last occurrence, as RFC 9651 requires. * * Values come back in the same model {@link MessageSignature} parameters use: plain JavaScript * values for the unambiguous types, and wrappers for Token, Decimal, Date, and Display String. See * {@link StructuredFieldBareItem}. * * @example * * Reading a Dictionary field whose members are Strings with parameters. * * ```ts * const dictionary = FetchSig.parseStructuredField( * 'sig1="https://agent.example";type=directory, sig2="https://other.example"', * 'dictionary', * ) * * for (const [label, member] of dictionary) { * if (member.type !== 'item' || typeof member.value !== 'string') { * throw new Error(`${label} must be a String`) * } * const type = member.parameters.find(([name]) => name === 'type')?.[1] * // sig1 https://agent.example { type: 'token', value: 'directory' } * console.log(label, member.value, type) * } * ``` * * @param value - The complete HTTP field value. * @param type - The top-level type the field is defined to use. * @group Structured Fields */ export function parseStructuredField(value: string, type: 'dictionary'): StructuredFieldDictionary /** Parses a field value as a Structured Field List. */ export function parseStructuredField(value: string, type: 'list'): StructuredFieldList /** Parses a field value as a Structured Field Item. */ export function parseStructuredField(value: string, type: 'item'): StructuredFieldItem /** Parses a field value whose top-level type is not known statically. */ export function parseStructuredField(value: string, type: StructuredFieldType): StructuredFieldValue export function parseStructuredField( value: string, type: StructuredFieldType, ): StructuredFieldValue { if (typeof value !== 'string') { fail('"value" must be a string') } assertStructuredFieldType(type) const parsed = parseSfTopLevel(value, type) switch (type) { case 'dictionary': return (parsed as SfDictionary).map(([name, member]) => [ name, structuredFieldMemberFromSf(member), ]) case 'list': return (parsed as SfList).map(structuredFieldMemberFromSf) case 'item': return structuredFieldItemFromSf(parsed as SfItem) } } /** * Serializes a Structured Field value into an HTTP field value. * * Every key, Token, Decimal, Date, and Display String is validated, so a value this rejects is one * no conforming recipient would have accepted. * * @example * * ```ts * const field = FetchSig.serializeStructuredField( * [ * [ * 'sig1', * { * type: 'item', * value: 'https://agent.example', * parameters: [['type', FetchSig.token('directory')]], * }, * ], * ], * 'dictionary', * ) * * // sig1="https://agent.example";type=directory * console.log(field) * ``` * * @param value - The value to serialize, in the shape {@link parseStructuredField} returns. * @param type - The top-level type the field is defined to use. * @group Structured Fields */ export function serializeStructuredField( value: StructuredFieldDictionary, type: 'dictionary', ): string /** Serializes a Structured Field List. */ export function serializeStructuredField(value: StructuredFieldList, type: 'list'): string /** Serializes a Structured Field Item. */ export function serializeStructuredField(value: StructuredFieldItem, type: 'item'): string /** Serializes a value whose top-level type is not known statically. */ export function serializeStructuredField( value: StructuredFieldValue, type: StructuredFieldType, ): string export function serializeStructuredField( value: StructuredFieldValue, type: StructuredFieldType, ): string { assertStructuredFieldType(type) if (type === 'item') { return serializeSfTopLevel( sfItemFromStructuredField(value as StructuredFieldItem, 'Item'), type, ) } if (!Array.isArray(value)) { fail(`A Structured Field ${type} must be an array`) } if (type === 'list') { return serializeSfTopLevel( (value as StructuredFieldList).map((member, index) => sfMemberFromStructuredField(member, `List member ${index}`), ), type, ) } const entries: SfDictionary = [] const seen = new Set() for (const entry of value as StructuredFieldDictionary) { if (!Array.isArray(entry) || entry.length !== 2) { fail('A Structured Field Dictionary must contain [name, value] entries') } const [name, member] = entry as [string, StructuredFieldMember] assertSfKey(name, 'Structured Field Dictionary key') if (seen.has(name)) { fail(`Duplicate Structured Field Dictionary key "${name}"`) } seen.add(name) entries.push([name, sfMemberFromStructuredField(member, `Dictionary member "${name}"`)]) } return serializeSfTopLevel(entries, type) } /** Rejects a top-level Structured Field type that is not one of the three RFC 9651 defines. */ function assertStructuredFieldType(type: StructuredFieldType): void { if (type !== 'dictionary' && type !== 'list' && type !== 'item') { fail('"type" must be "dictionary", "list", or "item"') } } /** * Creates a component identifier while preserving the supplied parameter order. * * HTTP field names are normalized to lowercase. Derived component names are case-sensitive. * * @example * * A plain string is shorthand for an identifier with no parameters, so `component()` is only needed * when a component carries parameters. * * ```ts * const request = new Request('https://api.example/orders?page=2', { * headers: { 'example-dictionary': 'a=1, member="two"' }, * }) * * const base = FetchSig.createSignatureBase(request, { * components: [ * '@method', * FetchSig.component('@query-param', [['name', 'page']]), * FetchSig.component('Example-Dictionary', [['key', 'member']]), * ], * }) * * // "@method": GET * // "@query-param";name="page": 2 * // "example-dictionary";key="member": "two" * // "@signature-params": ("@method" "@query-param";name="page" "example-dictionary";key="member") * console.log(base) * ``` * * @example * * Parameters combine, and their order is covered by the signature. Pass ordered tuples whenever * another implementation has to reproduce the exact serialization. An object is also accepted and * keeps its property insertion order. * * ```ts * const request = new Request('https://api.example/orders', { * headers: { 'example-dictionary': 'a=1, member="two"' }, * }) * const response = new Response('', { status: 200 }) * * const base = FetchSig.createSignatureBase(response, { * request, * components: [ * '@status', * FetchSig.component('example-dictionary', [ * ['key', 'member'], * ['req', true], * ]), * ], * }) * * // "@status": 200 * // "example-dictionary";key="member";req: "two" * // "@signature-params": ("@status" "example-dictionary";key="member";req) * console.log(base) * ``` * * @group Components */ export function component( name: string, parameters: ComponentParameters = [], ): ParameterizedComponent { if (typeof name !== 'string') { fail('"name" must be a string') } if (!Array.isArray(parameters)) { assertConfigurationObject(parameters, '"parameters"') } return { name: name.startsWith('@') ? name : name.toLowerCase(), parameters } } /** * Reports whether a list of component identifiers contains one particular identifier. * * Both sides are normalized first, so a string and the equivalent {@link component} call match, HTTP * field names compare case-insensitively, and component parameters are compared as an unordered * set. The complete identifier has to match: `"@authority"` and `FetchSig.component('@authority', * {req: true})` are different components, and only the exact one is found. * * The list is not required to be a valid covered component list, so an identifier that arrived on * the wire returns `false` rather than throwing. The identifier being looked for comes from the * application and is validated. * * Use this in a {@link VerificationPolicy.validate} callback for a coverage rule * {@link VerificationPolicy.requiredComponents} cannot express, such as requiring one of two * components or requiring a component only when the message carries a particular field. Comparing * names alone would treat `"@authority"` and `"@authority";req` as the same component. * * @example * * A rule that `requiredComponents` cannot express: the signature must bind the request target * through either `@authority` or `@target-uri`. * * ```ts * declare const request: Request * declare const verifier: FetchSig.VerifierFactory * * await FetchSig.verify(request, { * verifier, * policy: { * requiredComponents: ['@method', '@path'], * requiredParameters: ['created', 'keyid'], * algorithms: ['ed25519'], * validate(signature) { * const covered = signature.components * if ( * !FetchSig.includesComponent(covered, '@authority') && * !FetchSig.includesComponent(covered, '@target-uri') * ) { * throw new Error('The signature must cover @authority or @target-uri') * } * }, * }, * }) * ``` * * @example * * A conditional rule: whenever the message carries a `signature-agent` field, the signature has to * cover it, so that the field cannot be added or changed in transit. * * ```ts * declare const signature: FetchSig.MessageSignature * declare const message: Request * * if ( * message.headers.has('signature-agent') && * !FetchSig.includesComponent(signature.components, 'signature-agent') * ) { * throw new Error('An unsigned signature-agent field is not accepted') * } * ``` * * @param components - Identifiers to search, such as {@link MessageSignature.components} or a * covered component list an application is about to sign. * @param identifier - The identifier to look for. * @group Components */ export function includesComponent( components: ReadonlyArray, identifier: ComponentIdentifier, ): boolean { if (!Array.isArray(components)) { fail('"components" must be an array') } const wanted = toMessageComponent(identifier) validateComponentParameters(wanted) return components.some((candidate) => sameComponent(wanted, toMessageComponent(candidate))) } /** * Returns every component identifier in a list that resolves to one field or derived component * name, whatever parameters it carries. * * This answers "is this field covered at all", which {@link includesComponent} deliberately does * not: that function matches the complete identifier, so it does not find `"example-dict";key="a"` * when asked for `"example-dict"`. The identifiers come back so that a caller can see how the field * was covered rather than only that it was. * * Reading the parameters matters, because covering a field is not one thing: * * - `key` covers **one member** of a Structured Field Dictionary. The other members of that field are * not covered, so a peer can add, remove, or change them without breaking the signature. * - `req` covers the value from the related request rather than from the response. * - `bs` and `tr` change which bytes and which section the value is taken from. * * A coverage rule that treats any match as "the field is protected" is therefore weaker than it * reads. Decide from the parameters whether the match is the one the rule meant. * * The list is not required to be a valid covered component list, so an identifier that arrived on * the wire is matched rather than rejected. The name comes from the application and is validated. * * @example * * A conditional coverage rule, written so that a keyed identifier does not silently satisfy a rule * about the whole field. * * ```ts * declare const signature: FetchSig.MessageSignature * declare const message: Request * * if (message.headers.has('signature-agent')) { * const covered = FetchSig.findComponents(signature.components, 'signature-agent') * if (covered.length === 0) { * throw new Error('An unsigned signature-agent field is not accepted') * } * // Accept a single dictionary member only when the rule is about that member. * if (covered.every(({ parameters }) => parameters.some(([name]) => name === 'key'))) { * throw new Error('signature-agent must be covered as a whole field') * } * } * ``` * * @param components - Identifiers to search, such as {@link MessageSignature.components} or a * covered component list an application is about to sign. * @param name - A field name, matched case-insensitively, or a case-sensitive derived component * name. * * @returns The matching identifiers in list order, normalized, or an empty array. * @group Components */ export function findComponents( components: ReadonlyArray, name: string, ): MessageComponent[] { if (!Array.isArray(components)) { fail('"components" must be an array') } if (typeof name !== 'string') { fail('"name" must be a string') } const wanted = toMessageComponent(name) validateComponentName(wanted) return components .map((candidate) => toMessageComponent(candidate)) .filter((candidate) => candidate.name === wanted.name) } /** * Rejects a package configuration record that is not a plain record of own, enumerable data * properties. * * Configuration is trusted application input, so this is a contract check rather than a defense * against a Proxy that lies to reflection. Messages, host objects, and provider implementations are * deliberately outside this contract; Fetch-wrapper initializers follow it. */ function assertConfigurationObject(value: T, description: string): asserts value is T & object { if (value === null || typeof value !== 'object') { fail(`${description} must be an object`) } const prototype = Object.getPrototypeOf(value) if (prototype !== Object.prototype && prototype !== null) { fail(`${description} must be a plain object`) } for (const name of Reflect.ownKeys(value)) { const descriptor = Object.getOwnPropertyDescriptor(value, name) if ( descriptor === undefined || !Object.hasOwn(descriptor, 'value') || descriptor.enumerable !== true ) { fail(`${description} must contain only enumerable data properties`) } } } /** Applies the ordinary-data contract to the record nested in shared signature options. */ function assertSignatureContextConfiguration(context: Readonly): void { if (context.structuredFields !== undefined) { assertConfigurationObject(context.structuredFields, '"structuredFields"') } } /** * Normalizes the two accepted parameter inputs, an ordered array of tuples or a plain object, into * an ordered array of entries. * * Object property insertion order is preserved because RFC 9421 covers the serialized parameter * order with the signature. */ function orderedParameterEntries( parameters: ReadonlyArray | Readonly> | undefined, ): Array<[string, T]> { if (parameters === undefined) { return [] } if (Array.isArray(parameters)) { return parameters.map((entry) => { if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string') { fail('Parameters must contain [name, value] tuples') } return [entry[0], entry[1]] }) } assertConfigurationObject(parameters, 'Parameters') return Object.entries(parameters) as Array<[string, T]> } /** * Converts one signature metadata parameter supplied by an application into a Structured Field bare * item. * * `Date` values become RFC 9421 Integer UNIX timestamps. `undefined` means the parameter is * omitted, which the caller signals by returning `undefined`. */ function sfBareItemFromSignatureParameter( name: string, input: SignatureParameterInput, ): SfBareItem | undefined { if (input === undefined) { return undefined } let value: SignatureParameterValue if (isDate(input)) { const time = Date.prototype.getTime.call(input) if (Number.isNaN(time)) { fail(`Signature parameter "${name}" must be a valid Date`) } value = Math.floor(time / 1000) } else { value = input } if (typeof value === 'string') { return { kind: 'string', value } } if (typeof value === 'number') { return Number.isInteger(value) ? { kind: 'integer', value } : { kind: 'decimal', value: Number(serializeDecimal(value)) } } if (typeof value === 'boolean') { return { kind: 'boolean', value } } if (isUint8Array(value)) { return { kind: 'binary', value: cloneBytes(value) } } if (value !== null && typeof value === 'object') { assertConfigurationObject(value, `Signature parameter "${name}"`) } if ( value !== null && typeof value === 'object' && value.type === 'decimal' && typeof value.value === 'number' ) { return { kind: 'decimal', value: Number(serializeDecimal(value.value)) } } if ( value !== null && typeof value === 'object' && value.type === 'token' && typeof value.value === 'string' ) { if (!SF_TOKEN.test(value.value)) { fail(`Signature parameter "${name}" contains an invalid Structured Field Token`) } return { kind: 'token', value: value.value } } if ( value !== null && typeof value === 'object' && value.type === 'date' && typeof value.value === 'number' ) { return { kind: 'date', value: date(value.value).value } } if ( value !== null && typeof value === 'object' && value.type === 'display-string' && typeof value.value === 'string' ) { return { kind: 'display-string', value: displayString(value.value).value } } return fail(`Signature parameter "${name}" has an unsupported value`) } /** * Converts one parsed Structured Field bare item into the public signature metadata parameter value * that is handed to applications. */ function signatureParameterValueFromSfBareItem(item: SfBareItem): SignatureParameterValue { switch (item.kind) { case 'integer': case 'string': case 'boolean': return item.value case 'decimal': return { type: 'decimal', value: item.value } case 'binary': return cloneBytes(item.value) case 'token': return { type: 'token', value: item.value } case 'date': return { type: 'date', value: item.value } case 'display-string': return { type: 'display-string', value: item.value } } } /** * Converts application-supplied signature metadata parameters into ordered Structured Field * parameters, adding `defaultCreated` when a creation timestamp was neither supplied nor explicitly * disabled. */ function normalizeSignatureParameters( parameters: SignatureParameters | undefined, defaultCreated: number | undefined, ): SfParameters { return normalizeSignatureParameterEntries(orderedParameterEntries(parameters), defaultCreated) } /** * Converts already-ordered signature metadata parameter entries into Structured Field parameters. * * Duplicate names are rejected, `['created', false]` suppresses the default creation timestamp, and * a default creation timestamp is inserted ahead of the supplied parameters. The resulting order is * covered by the signature, so it is fixed from this point on. */ function normalizeSignatureParameterEntries( entries: Array<[string, SignatureParameterInput]>, defaultCreated: number | undefined, ): SfParameters { const output: SfParameters = [] const seen = new Set() for (const [name] of entries) { assertSfKey(name, 'Signature parameter name') if (seen.has(name)) { fail(`Duplicate signature parameter "${name}"`) } seen.add(name) } const created = entries.find(([name]) => name === 'created') if ((created === undefined || created[1] === undefined) && defaultCreated !== undefined) { output.push(['created', { kind: 'integer', value: defaultCreated }]) } for (const [name, input] of entries) { if (name === 'created' && input === false) { continue } const value = sfBareItemFromSignatureParameter(name, input) if (value !== undefined) { output.push([name, value]) } } validateKnownSignatureParameters(output, false) return output } /** Returns the value of one Structured Field parameter, or `undefined` when it is absent. */ function findSfParameterValue(parameters: SfParameters, name: string): SfBareItem | undefined { return parameters.find(([candidate]) => candidate === name)?.[1] } /** * Enforces the value types RFC 9421 defines for its own signature metadata parameters, leaving * extension parameters to the application. * * In a signature, `created` and `expires` are Integers and `nonce`, `alg`, `keyid`, and `tag` are * Strings. In an `Accept-Signature` request, `created` and `expires` instead carry no value, * because the signer chooses the timestamps. */ function validateKnownSignatureParameters(parameters: SfParameters, requested: boolean): void { for (const [name, value] of parameters) { switch (name) { case 'created': case 'expires': if (requested) { if (value.kind !== 'boolean' || !value.value) { fail(`Requested signature parameter "${name}" must be a bare Boolean true`) } } else if (value.kind !== 'integer') { fail(`Signature parameter "${name}" must be an Integer`) } break case 'nonce': case 'alg': case 'keyid': case 'tag': if (value.kind !== 'string') { fail(`Signature parameter "${name}" must be a String`) } break } } } /** * Converts application-supplied HTTP message component parameters into ordered Structured Field * parameters, rejecting duplicates and values that are neither a string nor a boolean. */ function normalizeComponentParameters(parameters: ComponentParameters | undefined): SfParameters { const entries = orderedParameterEntries(parameters) const output: SfParameters = [] for (const [name, value] of entries) { assertSfKey(name, 'Component parameter name') if (output.some(([existing]) => existing === name)) { fail(`Duplicate component parameter "${name}"`) } if (typeof value === 'string') { output.push([name, { kind: 'string', value }]) } else if (typeof value === 'boolean') { output.push([name, { kind: 'boolean', value }]) } else { fail(`Component parameter "${name}" must be a string or boolean`) } } return output } /** * Converts one parsed component parameter into its public form, rejecting the Structured Field * types that RFC 9421 component parameters cannot use. */ function componentParameterFromSfBareItem(value: SfBareItem): ComponentParameterValue { if (value.kind === 'string' || value.kind === 'boolean') { return value.value } return fail('Component parameters must be Strings or Booleans') } /** * Converts one member of a parsed `Signature-Input` or `Accept-Signature` Inner List into a message * component identifier. */ function componentFromSfItem(item: SfItem): MessageComponent { if (item.value.kind !== 'string') { fail('Covered component identifiers must be Structured Field Strings') } return { name: item.value.value, parameters: item.parameters.map(([name, value]) => [ name, componentParameterFromSfBareItem(value), ]), } } /** * Converts a message component identifier into the Structured Field Item used for its signature * base line and for its entry in the `@signature-params` Inner List. */ function serializeComponentIdentifier(identifier: MessageComponent): string { // The name has already been through validateComponentName(), so it is either a known derived // component or a lowercase HTTP field name. Neither character set contains a quote or a // backslash, so the generic String serializer's escaping pass has nothing to do. let output = `"${identifier.name}"` for (const [name, value] of identifier.parameters) { output += `;${serializeKey(name)}` if (value === true) { continue } output += typeof value === 'string' ? `=${serializeString(value)}` : '=?0' } return output } /** * Serializes the `@signature-params` value from identifiers that have already been serialized for * their own base lines. * * Every covered component appears twice in a signature base, once as the line carrying its value * and once inside the `@signature-params` Inner List. Serializing each identifier once and reusing * the string halves that work. */ function serializeSignatureParams( identifiers: ReadonlyArray, parameters: SfParameters, ): string { return `(${identifiers.join(' ')})${serializeParameters(parameters)}` } function componentToSfItem(identifier: MessageComponent): SfItem { return { kind: 'item', value: { kind: 'string', value: identifier.name }, parameters: identifier.parameters.map(([name, value]) => [ name, typeof value === 'string' ? { kind: 'string', value } : { kind: 'boolean', value }, ]), } } /** * Converts one component identifier supplied by an application into its normalized form. * * HTTP field names are lowercased, as RFC 9421 requires. Derived component names are left alone * because they are case-sensitive. The name itself is not checked here, so that a comparison * against an identifier that arrived on the wire does not have to reject it first. */ function toMessageComponent(input: ComponentIdentifier): MessageComponent { let name: string let parameters: ComponentParameters | undefined if (typeof input === 'string') { name = input } else if (input === null || typeof input !== 'object') { return fail('Invalid HTTP message component identifier') } else { assertConfigurationObject(input, 'HTTP message component identifier') if (typeof input.name !== 'string') { return fail('Invalid HTTP message component identifier') } name = input.name parameters = input.parameters } if (!name.startsWith('@')) { name = name.toLowerCase() } return { name, parameters: normalizeComponentParameters(parameters).map(([parameterName, value]) => [ parameterName, componentParameterFromSfBareItem(value), ]), } } /** * Converts the covered component identifiers supplied by an application into normalized identifiers * with ordered parameters, rejecting any name a covered component list cannot carry. */ function normalizeComponents(components: ReadonlyArray): MessageComponent[] { if (!Array.isArray(components)) { fail('"components" must be an array') } return components.map((input) => { const normalized = toMessageComponent(input) validateComponentName(normalized) return normalized }) } /** * Rejects a component name that cannot appear in a covered component list: `@signature-params` * itself, an unknown derived component, and any field name that is not a lowercase HTTP field * name. */ function validateComponentName(identifier: MessageComponent): void { const { name } = identifier if (name === '@signature-params') { fail('"@signature-params" cannot be listed as a covered component') } if (name.startsWith('@')) { if (!DERIVED_COMPONENTS.has(name)) { fail(`Unknown derived component "${name}"`) } } else if (!HTTP_FIELD_NAME.test(name)) { fail(`Invalid or non-lowercase HTTP field component name "${name}"`) } } /** Indexes a component identifier's ordered parameters by name for lookup. */ function componentParameterMap(identifier: MessageComponent): Map { return new Map(identifier.parameters) } /** * Reads a Boolean component parameter, rejecting a flag that carries an explicit value. * * RFC 9421 defines `sf`, `bs`, `tr`, and `req` as bare Boolean true, so `;sf=?0` and `;sf="yes"` * are both invalid. */ function readComponentFlag( parameters: Map, name: string, ): boolean { const value = parameters.get(name) if (value === undefined) { return false } if (value !== true) { fail(`Component parameter "${name}" must be a bare Boolean true`) } return true } /** * Rejects a covered component list that the signature being created must not carry. * * Runs the checks the signature base would run, plus the self-coverage rules, so that a caller who * serializes fields without building a base cannot produce an identifier this package's own parser * rejects, nor a signature that its own appending invalidates. * * Covering the whole `signature` or `signature-input` field is refused because appending the new * signature changes that field's value. Covering this signature's own `signature` Dictionary member * is refused for a stronger reason: that member is the signature bytes being produced, so the value * does not exist while the base is built and is the signature itself afterwards. * * The related request, the trailer section, another label's member, and this signature's own * `signature-input` member are all unaffected. The last of those is self-referential but knowable * in advance, because the member value is exactly the `@signature-params` line of the base being * signed. */ function assertSignableComponents( components: ReadonlyArray, label: string, ): void { assertUniqueComponents(components) for (const identifier of components) { validateComponentParameters(identifier) if (identifier.name !== 'signature' && identifier.name !== 'signature-input') { continue } const componentParameters = componentParameterMap(identifier) if (componentParameters.get('req') === true || componentParameters.get('tr') === true) { continue } const key = componentParameters.get('key') if (key === undefined) { fail('A signature cannot cover fields to which it is being appended') } if (identifier.name === 'signature' && key === label) { fail(`A signature cannot cover its own "signature" Dictionary member "${label}"`) } } } /** * Enforces the component parameters RFC 9421 allows on a given component identifier and reports * whether the value comes from the related request. * * Derived components accept only `req`, plus `name` on `@query-param`, and `@status` accepts * neither. HTTP fields accept `sf`, `key`, `bs`, `tr`, and `req`, and `bs` is incompatible with * `sf` and `key`. */ function validateComponentParameters(identifier: MessageComponent): boolean { validateComponentName(identifier) const parameters = componentParameterMap(identifier) if (identifier.name.startsWith('@')) { const allowed = new Set() if (identifier.name === '@query-param') { allowed.add('name') } if (identifier.name !== '@status') { allowed.add('req') } for (const name of parameters.keys()) { if (!allowed.has(name)) { fail(`Parameter "${name}" does not apply to "${identifier.name}"`) } } const relatedRequest = readComponentFlag(parameters, 'req') if (identifier.name === '@query-param') { const name = parameters.get('name') if (typeof name !== 'string') { fail('"@query-param" requires a String "name" parameter') } } return relatedRequest } const allowed = new Set(['sf', 'key', 'bs', 'tr', 'req']) for (const name of parameters.keys()) { if (!allowed.has(name)) { fail(`Unknown HTTP field component parameter "${name}"`) } } const sf = readComponentFlag(parameters, 'sf') const bs = readComponentFlag(parameters, 'bs') readComponentFlag(parameters, 'tr') const relatedRequest = readComponentFlag(parameters, 'req') const key = parameters.get('key') if (key !== undefined && typeof key !== 'string') { fail('Component parameter "key" must be a String') } if (bs && (sf || key !== undefined)) { fail('Component parameter "bs" is incompatible with "sf" and "key"') } return relatedRequest } /** * Enforces the RFC 9421 rules that depend on whether the signature targets a request or a response. * * `@status` applies only to responses. `req` applies only to response signatures. Every other * derived component describes a request, so covering one in a response signature requires `req`. */ function validateComponentForTarget(identifier: MessageComponent, request: boolean): void { const relatedRequest = validateComponentParameters(identifier) if (identifier.name.startsWith('@')) { if (identifier.name === '@status') { if (request) { fail('"@status" cannot be used with a request') } } else if (request) { if (relatedRequest) { fail('"req" cannot be used with a request signature') } } else if (!relatedRequest) { fail(`"${identifier.name}" requires "req" in a response signature`) } return } if (request && relatedRequest) { fail('"req" cannot be used with a request signature') } } /** Enforces {@link validateComponentForTarget} against the type of an actual message. */ function validateComponentForMessage( identifier: MessageComponent, message: SignableRequest | SignableResponse, ): void { validateComponentForTarget(identifier, isRequest(message)) } /** Reports whether two Structured Field bare items have the same type and value. */ function sameBareItem(left: SfBareItem, right: SfBareItem): boolean { if (left.kind !== right.kind) { return false } if (left.kind === 'binary' && right.kind === 'binary') { return bytesEqual(left.value, right.value) } return left.value === right.value } /** * Reports whether two component identifiers name the same component with the same parameters. * * Parameter order is ignored, because RFC 9651 parameters are an ordered map keyed by name. */ function sameComponent(left: MessageComponent, right: MessageComponent): boolean { if (left.name !== right.name || left.parameters.length !== right.parameters.length) { return false } // Parameter names are unique within an identifier and there are only ever a handful of them, so // a linear scan compares the two ordered maps without allocating. return left.parameters.every(([name, value]) => right.parameters.some(([otherName, otherValue]) => otherName === name && otherValue === value), ) } /** * Rejects a covered component list that resolves the same component twice. * * RFC 9421 requires that a component identifier, including its parameters, appear at most once in a * signature base, and that a parameterized Dictionary key appear at most once for a given field. * The second rule is enforced independently, so `"x";key="a"` and `"x";key="a";sf` are rejected * even though their identifiers differ. */ function assertUniqueComponents(components: ReadonlyArray): void { // Both rules are membership tests, so each component is reduced to a canonical string and checked // against the components already seen. Comparing every pair instead would be quadratic in the // number of covered components, which a peer chooses before any signature has been verified. const identifiers = new Set() const dictionaryKeys = new Set() for (const identifier of components) { const parameters = componentParameterMap(identifier) // Parameter order is ignored, because RFC 9651 parameters are an ordered map keyed by name. // A bare name can never collide with the serialized form of a parameterized identifier, // because a component name cannot start with "[". const canonical = identifier.parameters.length === 0 ? identifier.name : JSON.stringify([ identifier.name, [...parameters].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)), ]) if (identifiers.has(canonical)) { fail(`Duplicate covered component "${identifier.name}"`) } identifiers.add(canonical) const key = parameters.get('key') if (typeof key === 'string') { // A Dictionary key is resolved from one field in one section, so `sf` and any other parameter // that does not select the value must not make an otherwise identical reference unique. const context = JSON.stringify([ identifier.name, key, parameters.get('req'), parameters.get('tr'), ]) if (dictionaryKeys.has(context)) { fail(`Duplicate covered dictionary key "${identifier.name}";key="${key}"`) } dictionaryKeys.add(context) } } } /** * Builds the `@signature-params` value: an Inner List of covered component identifiers carrying the * signature metadata parameters. */ function signatureParametersInnerList( components: ReadonlyArray, parameters: SfParameters, ): SfInnerList { return { kind: 'inner-list', value: components.map(componentToSfItem), parameters } } /** * Converts parsed signature metadata parameters into the public ordered form handed to * applications. */ function signatureParametersFromSf( parameters: SfParameters, ): Array { return parameters.map(([name, value]) => [name, signatureParameterValueFromSfBareItem(value)]) } /** Resolves an injectable clock value to integer UNIX seconds, defaulting to the current time. */ function unixTimestamp(input: number | Date | undefined): number { const value = isDate(input) ? Math.floor(Date.prototype.getTime.call(input) / 1000) : input === undefined ? Math.floor(Date.now() / 1000) : input if (!Number.isSafeInteger(value)) { fail('Clock value must be an integer UNIX timestamp') } return value } /** * Returns the request's target URI with any fragment removed. * * Fetch keeps the fragment in `Request.url`, but RFC 9421 derives `@target-uri`, `@request- * target`, `@query`, and `@query-param` from the target URI, which has none. */ function getTargetUri(request: SignableRequest): string { const hash = request.url.indexOf('#') const value = hash === -1 ? request.url : request.url.slice(0, hash) if (!ASCII.test(value)) { fail('Request target URI must contain only ASCII characters') } return value } /** * Parses a request's target URI, reporting a message whose URI cannot be resolved. * * An "http" or "https" URI must not carry a userinfo subcomponent, and Node.js and browsers refuse * to construct such a Request at all. Deno, Bun, and workerd allow it, which would put a password * into the signature base and into anything that logs or exchanges it. Rejecting here makes every * runtime behave the way the strictest ones already do. The URL parser decides what the userinfo * is, so no separate grammar for it is maintained here. */ function parseTargetUri(target: string): URL { let url: URL try { url = new URL(target) } catch (cause) { throw new TypeError('Request does not have a valid target URI', { cause }) } if (url.username !== '' || url.password !== '') { fail('Request target URI must not include credentials') } return url } /** * The values derived from one request's target URI, memoized for the duration of a single signature * base. * * A covered component list is attacker-controlled during verification and can name many components * of the same message, including one `@query-param` per query parameter. Parsing the target URI and * the query string again for every component would make signature base generation quadratic in the * size of the request, so each request's derived state is computed at most once per signature * base. */ interface TargetUriDerivation { /** The target URI with any fragment removed. */ readonly target: string /** The parsed target URI, used for the components that need its normalized parts. */ readonly url: URL /** Encoded query parameter values indexed by encoded name, built on first `@query-param` use. */ queryParameters?: Map } /** Memoized target URI derivations for the requests read while building one signature base. */ type TargetUriDerivations = Map /** * One HTTP field as read while building a signature base, with the derived forms it can be asked * for. * * A covered component list may reference the same field many times, most often as a Dictionary with * a different `key` each time. Reading, combining, and parsing it once per reference would be * quadratic in the size of a field a peer chooses, so each derivation is computed on first use and * reused for the rest of the base. */ interface FieldDerivation { /** The individual field occurrences, after RFC 9421 field line canonicalization. */ readonly values: ReadonlyArray /** The occurrences combined with `", "`. */ readonly combined: string /** Dictionary members indexed by key, built on first `key` use. */ members?: Map /** The strict re-serialization, built on first `sf` use. */ serialized?: string } /** * Memoized field derivations while building one signature base, indexed by the message they were * read from and then by section and field name. */ type FieldDerivations = Map> /** Everything memoized for the duration of a single signature base build. */ interface BaseDerivations { readonly targetUris: TargetUriDerivations readonly fields: FieldDerivations } /** Creates the memo for one signature base build. */ function createBaseDerivations(): BaseDerivations { return { targetUris: new Map(), fields: new Map() } } /** Returns the memoized derivation of one field from the operation's immutable message snapshot. */ function deriveField( message: MessageSnapshot, name: string, trailers: boolean, derivations: BaseDerivations, ): FieldDerivation { let byName = derivations.fields.get(message) if (byName === undefined) { byName = new Map() derivations.fields.set(message, byName) } const key = `${trailers ? 'trailer' : 'header'} ${name}` let derived = byName.get(key) if (derived === undefined) { const values = collectFieldOccurrences(message, name, trailers) derived = { values, combined: values.join(', ') } byName.set(key, derived) } return derived } /** Returns the memoized target URI derivation for a request, computing it on first use. */ function deriveTargetUri( request: RequestSnapshot, derivations: TargetUriDerivations, ): TargetUriDerivation { let derived = derivations.get(request) if (derived === undefined) { const target = getTargetUri(request) derived = { target, url: parseTargetUri(target) } derivations.set(request, derived) } return derived } /** * Percent-encodes a decoded query parameter name or value using the `application/x-www-form- * urlencoded` percent-encode set of the URL Standard. * * `encodeURIComponent` escapes that set except for `!`, `'`, `(`, `)`, and `~`, which are added * here. Spaces become `%20` rather than `+`, matching the worked example in RFC 9421. */ function formPercentEncode(value: string): string { return encodeURIComponent(value).replace(/[!'()~]/g, (character) => { return `%${character.charCodeAt(0).toString(16).toUpperCase()}` }) } /** * Derives the `@query-param` component value for one encoded parameter name. * * The query string is parsed with the URL Standard's `application/x-www-form-urlencoded` parser and * each decoded name and value is re-encoded, so that `+`, percent escapes, and newlines round- trip * to one unambiguous ASCII value. RFC 9421 requires an error when the name is absent, and requires * a name that occurs more than once to be left out of the signature entirely. */ function deriveQueryParameter(derived: TargetUriDerivation, encodedName: string): string { if (derived.queryParameters === undefined) { const queryStart = derived.target.indexOf('?') const query = queryStart === -1 ? '' : derived.target.slice(queryStart + 1) const parameters = new Map() for (const [name, value] of new URLSearchParams(query)) { const encoded = formPercentEncode(name) const values = parameters.get(encoded) if (values === undefined) { parameters.set(encoded, [formPercentEncode(value)]) } else { values.push(formPercentEncode(value)) } } derived.queryParameters = parameters } const matches = derived.queryParameters.get(encodedName) if (matches === undefined) { fail(`Query parameter "${encodedName}" is not present`) } if (matches.length !== 1) { // RFC 9421 requires a repeated query parameter name to be left out of the signature entirely, // so the component cannot be resolved and the signature base generation fails. fail(`Query parameter "${encodedName}" occurs more than once`) } return matches[0]! } /** * Derives the value of a request-targeted RFC 9421 derived component. * * `@query`, `@query-param`, `@request-target`, and `@target-uri` read the target URI as a string so * that percent-encoded octets are preserved exactly, as the RFC's simple string comparison rules * require. * * The components that read the authority or the absolute path require a target URI that has an * authority. Every URI scheme RFC 9421 applies to has one, so a message whose URI does not, such as * a `data:` or `blob:` URL, fails rather than deriving an empty authority or a relative path. */ function deriveRequestComponentValue( identifier: MessageComponent, parameters: ReadonlyMap, request: RequestSnapshot, derivations: BaseDerivations, ): string { const derived = deriveTargetUri(request, derivations.targetUris) const { target, url } = derived // The absolute path is normalized to "/" when the target URI has no path, matching the "@path" // normalization required by RFC 9421 for both components. const path = url.pathname || '/' const queryStart = target.indexOf('?') switch (identifier.name) { case '@method': return request.method case '@target-uri': return target case '@authority': // The URL parser already lowercases hosts of the special schemes used by HTTP, and lowercasing // again satisfies the RFC's normalization requirement for every other scheme too. return assertTargetUriAuthority(url, identifier.name).toLowerCase() case '@scheme': return url.protocol.slice(0, -1).toLowerCase() case '@request-target': assertTargetUriAuthority(url, identifier.name) return path + (queryStart === -1 ? '' : target.slice(queryStart)) case '@path': assertTargetUriAuthority(url, identifier.name) return path case '@query': return queryStart === -1 ? '?' : target.slice(queryStart) case '@query-param': return deriveQueryParameter(derived, parameters.get('name') as string) default: return fail(`Derived component "${identifier.name}" does not apply to a request`) } } /** * Returns the authority of a target URI, rejecting a URI that has none. * * RFC 9421 derives `@authority` from "the fully qualified authority component of the request" and * `@path` from "the absolute path of the request target". A URI without an authority, which the URL * parser reports as an empty host, has neither, so the component value cannot be derived. */ function assertTargetUriAuthority(url: URL, name: string): string { if (url.host === '') { fail(`Derived component "${name}" requires a target URI with an authority`) } return url.host } /** * Removes the spaces and horizontal tabs at both ends of a field value. * * Scanning rather than replacing with a regular expression matters here: an unanchored alternation * such as `/^[ \t]+|[ \t]+$/` restarts at every position, which makes canonicalization quadratic in * the length of a field value that a peer controls. */ function trimFieldWhitespace(value: string): string { let start = 0 let end = value.length while (start < end && (value[start] === ' ' || value[start] === '\t')) { start++ } while (end > start && (value[end - 1] === ' ' || value[end - 1] === '\t')) { end-- } return value.slice(start, end) } /** * Applies the RFC 9421 field value canonicalization to a single field line: strip leading and * trailing whitespace, then replace obsolete line folding with a single space. * * RFC 9112 defines obsolete line folding as `OWS CRLF RWS`, so the whitespace on both sides of the * CRLF belongs to the fold and collapses into the single replacement space with it. The split * pattern covers the CRLF and the whitespace after it, and the whitespace before it is trimmed from * the end of each preceding segment. Every match starts at a literal `\r\n`, which keeps the work * linear in the length of the value. * * A CRLF that is not followed by whitespace is not a fold, so it survives into the result and is * rejected by {@link assertFieldValue}. */ function normalizeFieldLine(value: string): string { const trimmed = trimFieldWhitespace(value) if (!trimmed.includes('\r\n')) { return trimmed } const segments = trimmed.split(/\r\n[ \t]+/) const last = segments.length - 1 return segments .map((segment, index) => (index === last ? segment : trimFieldWhitespace(segment))) .join(' ') } /** * Rejects a field or component value containing a newline or any other control character that * cannot appear in a signature base line. */ function assertFieldValue(value: string, name: string): void { if (/[\r\n]/.test(value)) { fail(`HTTP field "${name}" contains a newline`) } if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value)) { fail(`HTTP field "${name}" contains an invalid control character`) } } /** * Rejects a resolved component value that cannot appear in a signature base. * * This is where non-ASCII is caught. Every other input to a base is ASCII by construction, so once * each component value has been through here the assembled base needs no scan of its own. It runs * on the resolved value rather than on each field occurrence, because `bs` signs the raw octets of * a field line and puts base64 in the base, so a non-ASCII value is legal there. */ function assertBaseValue(value: string, name: string): void { if (!/[^\t\x20-\x7e]/.test(value)) { return } assertFieldValue(value, name) fail(`HTTP field "${name}" contains a non-ASCII character`) } /** * Collects and canonicalizes a field's occurrences from the immutable operation snapshot. * * An absent field fails signature base generation, as RFC 9421 requires. */ function collectFieldOccurrences( message: MessageSnapshot, name: string, trailers: boolean, ): string[] { const values = (trailers ? message.trailers : message.headers)[name] if (values === undefined || values.length === 0) { fail(`${trailers ? 'Trailer' : 'Header'} field "${name}" is not present`) } return values.map((value) => { const normalized = normalizeFieldLine(value) assertFieldValue(normalized, name) return normalized }) } /** * Converts an HTTP field value to its bytes for the `bs` component parameter, taking one octet per * code unit. * * A descriptor value models one received octet per code unit. Fetch does not expose original * occurrence boundaries, so `bs` requires an occurrence-preserving descriptor except for a * runtime's explicit `getSetCookie()` result. */ function latin1Bytes(value: string, name: string): Uint8Array { const output = new Uint8Array(value.length) for (let i = 0; i < value.length; i++) { const code = value.charCodeAt(i) if (code > 0xff) { fail(`HTTP field "${name}" cannot be represented as bytes`) } output[i] = code } return output } /** * Resolves the Structured Field top-level type of an HTTP field. * * `Signature`, `Signature-Input`, and `Accept-Signature` are Dictionaries by definition. Every * other field's type is application knowledge, supplied through the `structuredFields` option, and * RFC 9421 requires an error when the type is unknown to the implementation. */ function resolveStructuredFieldType( name: string, options: SignatureContext, ): StructuredFieldType | undefined { if (name === 'signature-input' || name === 'signature' || name === 'accept-signature') { return 'dictionary' } if (options.structuredFields !== undefined) { // Own properties only. A component can name a field such as "constructor", which every plain // object inherits, and reading it would report a configured type that the application never // wrote. Both paths reject the component, but this one names the actual reason. if (!Object.hasOwn(options.structuredFields, name)) { return undefined } const configured = options.structuredFields[name] if (configured === 'dictionary' || configured === 'list' || configured === 'item') { return configured } fail(`Structured Field type for "${name}" is invalid`) } return undefined } /** * Derives the component value of an HTTP field, applying the `bs`, `key`, `sf`, and `tr` component * parameters. * * Without those parameters the occurrences are combined with `", "`. With `bs` each occurrence is * wrapped as a Byte Sequence so that separate lines cannot be confused with one combined line. With * `key` or `sf` the combined value is parsed and re-serialized strictly. */ function deriveFieldComponentValue( identifier: MessageComponent, message: MessageSnapshot, options: SignatureContext, derivations: BaseDerivations, ): string { const parameters = componentParameterMap(identifier) const sf = readComponentFlag(parameters, 'sf') const bs = readComponentFlag(parameters, 'bs') const trailers = readComponentFlag(parameters, 'tr') const key = parameters.get('key') const knowledge = occurrenceKnowledge.get(message) const exact = trailers ? knowledge?.trailers : knowledge?.headers if (bs && !exact?.has(identifier.name)) { fail( `"${identifier.name}";bs requires a descriptor with explicit field occurrences because Fetch hides them`, ) } const field = deriveField(message, identifier.name, trailers, derivations) if (bs) { const list: SfList = field.values.map((value) => ({ kind: 'item', value: { kind: 'binary', value: latin1Bytes(value, identifier.name) }, parameters: [], })) return serializeList(list) } if (key !== undefined) { const type = resolveStructuredFieldType(identifier.name, options) if (type !== undefined && type !== 'dictionary') { fail( `Structured Field type for "${identifier.name}" must be "dictionary" with the "key" parameter`, ) } // Indexed on first use, so that covering many keys of one Dictionary stays linear in its size. if (field.members === undefined) { const dictionary = parseSfTopLevel(field.combined, 'dictionary') as SfDictionary field.members = new Map(dictionary) } const member = field.members.get(key as string) if (member === undefined) { fail(`Structured Field "${identifier.name}" has no member "${key}"`) } return serializeMember(member) } if (sf) { const type = resolveStructuredFieldType(identifier.name, options) if (type === undefined) { fail(`Structured Field type for "${identifier.name}" is required by the "sf" parameter`) } field.serialized ??= serializeSfTopLevel(parseSfTopLevel(field.combined, type), type) return field.serialized } return field.combined } /** * Derives the component value of one covered component from the target message, or from the related * request when the identifier carries `req`. * * Derived component values are additionally constrained by RFC 9421 to printable ASCII with no * leading or trailing space. */ function resolveComponentValue( identifier: MessageComponent, message: MessageSnapshot, options: SignatureContext, derivations: BaseDerivations, ): string { validateComponentForMessage(identifier, message) const parameters = componentParameterMap(identifier) const relatedRequest = parameters.has('req') let source: MessageSnapshot = message if (relatedRequest) { if (options.request === undefined) { fail(`Component "${identifier.name}";req requires the related request`) } if (!isRequest(options.request)) { fail('"request" must be the related Request') } source = options.request as RequestSnapshot } let value: string if (identifier.name.startsWith('@')) { if (identifier.name === '@status') { const status = (source as ResponseSnapshot).status if (!Number.isInteger(status) || status < 100 || status > 599) { fail('"@status" requires an unfiltered HTTP response status') } value = String(status) } else { if (!isRequest(source)) { fail(`Derived component "${identifier.name}" requires a request context`) } value = deriveRequestComponentValue( identifier, parameters, source as RequestSnapshot, derivations, ) } if (!PRINTABLE_ASCII.test(value) || value.startsWith(' ') || value.endsWith(' ')) { fail(`Derived component "${identifier.name}" has an invalid value`) } } else { value = deriveFieldComponentValue(identifier, source, options, derivations) } assertBaseValue(value, identifier.name) return value } /** * Builds the RFC 9421 signature base: one canonicalized line per covered component, followed by the * `@signature-params` line. * * Each identifier is serialized before its value is resolved, following the order of RFC 9421 * Section 2.5: an identifier that cannot be serialized is the caller's input error and is reported * as one, without consulting the message. The serialized form is then reused for both the component * line and the `@signature-params` line. * * Each resolved value is checked for non-ASCII as it is produced, so the assembled base needs no * scan of its own. * * Target URI and field derivations are memoized for this one base, so a peer-controlled covered * component list cannot make repeated parsing quadratic. */ function serializeCoveredComponents( components: ReadonlyArray, ): ReadonlyArray { assertUniqueComponents(components) return components.map((identifier) => serializeComponentIdentifier(identifier)) } function buildSignatureBase( message: MessageSnapshot, components: ReadonlyArray, serializedComponents: ReadonlyArray, parameters: SfParameters, options: SignatureContext, ): string { const derivations = createBaseDerivations() let output = '' for (const [index, identifier] of components.entries()) { const serializedIdentifier = serializedComponents[index]! output += `${serializedIdentifier}: ${resolveComponentValue(identifier, message, options, derivations)}\n` } output += `"@signature-params": ${serializeSignatureParams(serializedComponents, parameters)}` return output } /** * Creates the RFC 9421 signature base for a signable request or response. * * Unlike {@link createSignature}, this low-level function does not add a default `created` * parameter. * * @example * * The signature base is the exact ASCII string handed to cryptography: one line per covered * component, then the `@signature-params` line. Compare it byte for byte with a peer implementation * before suspecting the cryptography. * * ```ts * const request = new Request('https://example.com/items?limit=10', { * method: 'POST', * headers: { 'example-field': ' value ' }, * }) * * const base = FetchSig.createSignatureBase(request, { * components: [ * '@method', * '@authority', * '@path', * FetchSig.component('@query-param', [['name', 'limit']]), * 'example-field', * ], * parameters: [ * ['created', 1_735_689_600], * ['keyid', 'interop-key'], * ], * }) * * // "@method": POST * // "@authority": example.com * // "@path": /items * // "@query-param";name="limit": 10 * // "example-field": value * // "@signature-params": ("@method" "@authority" "@path" "@query-param";name="limit" * // "example-field");created=1735689600;keyid="interop-key" * console.log(base) * ``` * * @group Components */ export function createSignatureBase( message: SignableRequest | SignableResponse, options: SignatureBaseOptions, ): string { assertMessage(message) assertConfigurationObject(options, '"options"') assertSignatureContext(options) assertSignatureContextConfiguration(options) const components = normalizeComponents(options.components) const parameters = normalizeSignatureParameters(options.parameters, undefined) const serializedComponents = serializeCoveredComponents(components) const captured = captureSignatureOperation(message, options) return buildSignatureBase( captured.message, components, serializedComponents, parameters, captured.context, ) } /** * Serializes a signature produced outside this package into its `Signature-Input` and `Signature` * fields. * * This is the second half of {@link createSignatureBase}, for a caller who signs the base bytes * themselves. Together the two cover what {@link createSignature} does in one step, without its * `Promise`, so a synchronous signing library can be used where awaiting is not possible. Pass the * same `components` and `parameters` to both calls: they are what the signature commits to, and the * fields describe the base that was actually signed only if the two agree. * * Neither function adds a default `created` timestamp, which {@link createSignature} does, so * supplying one is the caller's job. * * The signature bytes are copied, so a later mutation of the caller's array cannot change the * returned fields. * * @example * * Signing with a synchronous library, in a context that cannot await. * * ```ts * declare function signSynchronously(data: Uint8Array): Uint8Array * declare const request: Request * * const components = ['@method', '@authority', '@path'] * const parameters = [ * ['created', 1_735_689_600], * ['keyid', 'client-key'], * ['alg', 'ed25519'], * ] as const * * const base = FetchSig.createSignatureBase(request, { components, parameters }) * const fields = FetchSig.createSignatureFields({ * signature: signSynchronously(new TextEncoder().encode(base)), * components, * parameters, * }) * * const signed = FetchSig.appendSignature(request, fields) * ``` * * @returns The signature together with the two field values it serializes to. * @group Sender */ export function createSignatureFields(options: SignatureFieldsOptions): SignatureFields { assertConfigurationObject(options, '"options"') const label = options.label ?? 'sig1' assertSfKey(label, 'Signature label') if (!isUint8Array(options.signature)) { fail('"signature" must be a Uint8Array') } const components = normalizeComponents(options.components) assertSignableComponents(components, label) const parameters = normalizeSignatureParameters(options.parameters, undefined) const signature = cloneBytes(options.signature) return { label, components, parameters: signatureParametersFromSf(parameters), signature, ...serializeSignatureFields(label, components, parameters, signature), } } interface ParsedSignatureInput { readonly label: string readonly components: MessageComponent[] readonly parameters: SfParameters } interface ParsedSignatureValue { readonly label: string readonly value: Uint8Array } /** * Interprets one `Signature-Input` Dictionary member as a labeled covered component list with its * signature metadata parameters. */ function parseSignatureInputMember(label: string, member: SfMember): ParsedSignatureInput { if (member.kind !== 'inner-list') { fail(`Signature-Input member "${label}" must be an Inner List`) } return { label, components: member.value.map(componentFromSfItem), parameters: member.parameters } } /** * Enforces the RFC 9421 rules a parsed `Signature-Input` member must satisfy: known parameter * types, valid component identifiers and parameters, and no duplicate covered component. */ function validateSignatureInput(input: ParsedSignatureInput): ParsedSignatureInput { validateKnownSignatureParameters(input.parameters, false) for (const identifier of input.components) { validateComponentParameters(identifier) } assertUniqueComponents(input.components) return input } /** Interprets one `Signature` Dictionary member as a signature byte sequence. */ function parseSignatureValueMember(label: string, member: SfMember): ParsedSignatureValue { if (member.kind !== 'item' || member.value.kind !== 'binary') { fail(`Signature member "${label}" must be a Byte Sequence`) } return { label, value: cloneBytes(member.value.value) } } /** Parses a `Signature-Input` field value into validated members, rejecting a repeated label. */ function parseSignatureInputInternal(value: string): ParsedSignatureInput[] { const dictionary = parseSfTopLevel(value, 'dictionary', true) as SfDictionary return dictionary.map(([label, member]) => { return validateSignatureInput(parseSignatureInputMember(label, member)) }) } /** * Parses a `Signature` field value into labeled signature byte sequences, rejecting a repeated * label. */ function parseSignatureInternal(value: string): ParsedSignatureValue[] { const dictionary = parseSfTopLevel(value, 'dictionary', true) as SfDictionary return dictionary.map(([label, member]) => parseSignatureValueMember(label, member)) } /** * Parses a `Signature-Input` field value into its labeled covered component lists and signature * metadata parameters. * * Rejects a repeated label, an unknown derived component, an inapplicable component parameter, a * duplicate covered component, and a known signature metadata parameter of the wrong Structured * Field type. It does not look at any message and does not verify anything. * * @example * * Inspect what a field value claims, for routing or diagnostics. Nothing here is authenticated. * * ```ts * const [signature] = FetchSig.parseSignatureInput( * 'sig1=("@method" "@path" "example-dictionary";key="a");created=1735689600;keyid="client-key"', * ) * * // sig1 * console.log(signature!.label) * * // [ '@method', '@path', 'example-dictionary' ] * console.log(signature!.components.map(({ name }) => name)) * * // [ [ 'created', 1735689600 ], [ 'keyid', 'client-key' ] ] * console.log(signature!.parameters) * ``` * * @group Recipient */ export function parseSignatureInput( value: string, ): ReadonlyArray> { if (typeof value !== 'string') { fail('"value" must be a string') } return parseSignatureInputInternal(value).map(({ label, components, parameters }) => ({ label, components, parameters: signatureParametersFromSf(parameters), })) } /** * Parses a `Signature` field value into its labeled signature byte sequences. * * Rejects a repeated label and a member that is not a Byte Sequence. It does not look at any * message and does not verify anything. * * @example * * Decode the raw signature bytes carried under each label. * * ```ts * const signatures = FetchSig.parseSignature('sig1=:AQIDBA==:, sig2=:BQYHCA==:') * * // sig1 Uint8Array(4) [ 1, 2, 3, 4 ] * // sig2 Uint8Array(4) [ 5, 6, 7, 8 ] * for (const { label, signature } of signatures) { * console.log(label, signature) * } * ``` * * @group Recipient */ export function parseSignature( value: string, ): ReadonlyArray }>> { if (typeof value !== 'string') { fail('"value" must be a string') } return parseSignatureInternal(value).map(({ label, value: signature }) => ({ label, signature })) } /** * Reads an HTTP field whose value is a Structured Field Dictionary, reporting a field that is * present but empty as absent. * * RFC 9651 gives every Dictionary field a default empty value and represents an empty Dictionary by * omitting the field, so `Signature-Input: ` carries exactly as much as no `Signature-Input` at * all. Normalizing here keeps the reading and appending helpers from disagreeing about such a * message. */ function getDictionaryField(headers: Headers | FieldOccurrences, name: string): string | null { const value = isHeaders(headers) ? headers.get(name) : (headers[name] ?.map((occurrence) => { const normalized = normalizeFieldLine(occurrence) assertFieldValue(normalized, name) return normalized }) .join(', ') ?? null) return value === null || /^[ \t]*$/.test(value) ? null : value } /** * Parses the `Signature` and `Signature-Input` fields of a message and checks that they pair up. * * Both fields must be present or both absent, neither may repeat a label, and the two label sets * must be identical. A message that fails these checks is treated as malformed rather than as a * message carrying some usable signatures. */ function parseSignatureFieldDictionaries(headers: Headers | FieldOccurrences): { readonly inputs: SfDictionary readonly values: SfDictionary } { const signatureInput = getDictionaryField(headers, 'signature-input') const signature = getDictionaryField(headers, 'signature') if (signatureInput === null && signature === null) { return { inputs: [], values: [] } } if (signatureInput === null || signature === null) { fail('Signature and Signature-Input fields must both be present') } const inputs = parseSfTopLevel(signatureInput, 'dictionary', true) as SfDictionary const values = parseSfTopLevel(signature, 'dictionary', true) as SfDictionary const inputLabels = new Set(inputs.map(([label]) => label)) const valueLabels = new Set(values.map(([label]) => label)) if ( inputLabels.size !== valueLabels.size || [...inputLabels].some((label) => !valueLabels.has(label)) ) { fail('Signature and Signature-Input fields must contain identical labels') } return { inputs, values } } /** * Parses and validates every signature carried by a message's `Signature` and `Signature-Input` * fields. */ function parseSignatureFieldMembers(headers: Headers | FieldOccurrences): { readonly inputs: ParsedSignatureInput[] readonly values: ParsedSignatureValue[] } { const dictionaries = parseSignatureFieldDictionaries(headers) return { inputs: dictionaries.inputs.map(([label, member]) => { return validateSignatureInput(parseSignatureInputMember(label, member)) }), values: dictionaries.values.map(([label, member]) => { return parseSignatureValueMember(label, member) }), } } /** * Returns one signature metadata parameter by name, or `undefined` when the signature omits it. * * The parameters are an ordered list rather than an object, because RFC 9421 covers their order in * the signature base. This looks one up without having to reproduce that shape at the call site. * * A parameter read here is unauthenticated when it comes from a {@link VerifierFactory}, which runs * before the signature has been checked. Treat `keyid` as a lookup key into trusted configuration, * and `alg` as a claim that {@link VerificationPolicy.algorithms} still has to allow. * * @example * * Select a trusted key by the `keyid` a signature claims. * * ```ts * declare const publicKeys: ReadonlyMap * * const verifier: FetchSig.VerifierFactory = (signature, context) => { * const keyid = FetchSig.getSignatureParameter(signature, 'keyid') * if (typeof keyid !== 'string') { * throw new FetchSig.VerificationError('unknown_key', 'A key identifier is required') * } * * const publicKey = publicKeys.get(keyid) * if (publicKey === undefined) { * throw new FetchSig.VerificationError('unknown_key', 'Unknown signing key') * } * * return FetchSig.ed25519Verifier(publicKey)(signature, context) * } * ``` * * @group Recipient */ export function getSignatureParameter( signature: Readonly, name: string, ): SignatureParameterValue | undefined { if (signature === null || typeof signature !== 'object' || !Array.isArray(signature.parameters)) { fail('"signature" must be a MessageSignature object') } if (typeof name !== 'string') { fail('"name" must be a string') } return findSignatureParameterValue(signature.parameters, name) } /** * Parses and pairs every signature carried by a message, so that an application can choose which * label to verify. * * Returns an empty array when the message carries neither field. Throws when the two fields do not * pair up: one present without the other, a repeated label, or a label in one field that is missing * from the other. Pairing is checked across the whole message, so one malformed member makes the * message unusable rather than yielding the remaining signatures. * * This reports what a message claims. Nothing here is authenticated until {@link verify} succeeds. * * @example * * Decide which label to verify, then verify it. Pick the label from trusted local configuration - a * label is an unsigned Dictionary key and cannot stand for a role or an identity. * * ```ts * declare const message: Request * declare const verifier: FetchSig.VerifierFactory * * // application [ [ 'created', 1735689600 ], [ 'keyid', 'client-key' ] ] * // audit [ [ 'created', 1735689600 ], [ 'keyid', 'audit-key' ] ] * for (const signature of FetchSig.getSignatures(message)) { * console.log(signature.label, signature.parameters) * } * * await FetchSig.verify(message, { * label: 'application', * verifier, * policy: { * requiredComponents: ['@method', '@authority', '@path'], * requiredParameters: ['created', 'keyid'], * algorithms: ['ed25519'], * maxAge: 60, * }, * }) * ``` * * @group Recipient */ export function getSignatures( message: SignableRequest | SignableResponse, ): ReadonlyArray { const snapshot = captureMessage(message) const { inputs, values } = parseSignatureFieldMembers(snapshot.headers) // Indexed once rather than scanned per input: both Dictionaries are peer-controlled and are read // before any signature has been verified, so pairing them by search would be quadratic whenever // the two label orders disagree. const byLabel = new Map(values.map((entry) => [entry.label, entry.value])) return inputs.map(({ label, components, parameters }) => ({ label, components, parameters: signatureParametersFromSf(parameters), signature: byLabel.get(label)!, })) } /** * Selects the signature to verify and returns it in parsed, internal, and public forms. * * A label is required when the message carries more than one signature, because RFC 9421 labels are * not covered by any signature and therefore carry no application meaning on their own. */ function selectSignature( message: MessageSnapshot, label: string | undefined, ): { readonly input: ParsedSignatureInput readonly signature: Uint8Array readonly public: MessageSignature } { let dictionaries: ReturnType try { dictionaries = parseSignatureFieldDictionaries(message.headers) } catch (cause) { throw verificationError('signature_malformed', cause) } const { inputs, values } = dictionaries if (inputs.length === 0) { verificationFail('signature_missing', 'Message does not contain an HTTP message signature') } let inputMember: SfDictionaryEntry if (label === undefined) { if (inputs.length !== 1) { fail('"label" is required when a message contains multiple signatures') } inputMember = inputs[0]! } else { const found = inputs.find(([candidate]) => candidate === label) if (found === undefined) { verificationFail('signature_missing', `Message does not contain signature label "${label}"`) } inputMember = found } try { const input = validateSignatureInput(parseSignatureInputMember(...inputMember)) const valueMember = values.find(([candidate]) => candidate === input.label)! const signature = parseSignatureValueMember(...valueMember).value return { input, signature, public: { label: input.label, components: input.components, parameters: signatureParametersFromSf(input.parameters), signature, }, } } catch (cause) { throw verificationError('signature_malformed', cause) } } /** * Invokes a {@link SignerFactory} and rejects a return value that does not implement {@link Signer}. * * Any exception the factory throws becomes the `cause` of the reported error. */ function signerFromFactory(factory: SignerFactory): Readonly { if (typeof factory !== 'function') { fail('"signer" must be a factory function') } let signer: Readonly try { signer = factory() if ( signer === null || typeof signer !== 'object' || typeof signer.alg !== 'string' || signer.alg.length === 0 || typeof signer.sign !== 'function' ) { throw new TypeError('Invalid signer implementation') } } catch (cause) { throw new TypeError('Invalid "signer"', { cause }) } return signer } /** * Invokes a {@link VerifierFactory} with the parsed signature and message context, and rejects a * return value that does not implement {@link Verifier}. * * The factory is the application's key-selection and trust boundary. It can explicitly report an * `unknown_key` or `algorithm_unsupported` {@link VerificationError}. Any other exception or * rejection becomes the `cause` of a `verification_failed` error. */ async function verifierFromFactory( factory: VerifierFactory, signature: Readonly, context: Readonly, ): Promise> { if (typeof factory !== 'function') { fail('"verifier" must be a factory function') } let verifier: Readonly try { verifier = await factory(signature, context) } catch (cause) { if ( cause instanceof VerificationError && (cause.code === 'unknown_key' || cause.code === 'algorithm_unsupported') ) { throw verificationError(cause.code, cause) } throw verificationError('verification_failed', cause, 'Invalid "verifier"') } try { const alg = verifier?.alg const verify = verifier?.verify if ( verifier === null || typeof verifier !== 'object' || typeof alg !== 'string' || alg.length === 0 || typeof verify !== 'function' ) { throw new TypeError('Invalid verifier implementation') } return { alg, verify: verify.bind(verifier) } } catch (cause) { throw new TypeError('Invalid "verifier"', { cause }) } } /** Returns the value of one public signature metadata parameter, or `undefined` when it is absent. */ function findSignatureParameterValue( parameters: ReadonlyArray, name: string, ): SignatureParameterValue | undefined { return parameters.find(([candidate]) => candidate === name)?.[1] } /** * Copies one public signature metadata parameter value so that application code cannot reach the * value used by policy checks. */ function cloneSignatureParameterValue(value: SignatureParameterValue): SignatureParameterValue { if (isUint8Array(value)) { return cloneBytes(value) } if (value !== null && typeof value === 'object') { assertConfigurationObject(value, 'Signature parameter value') return { ...value } } return value } /** * Deep copies a parsed signature so that a verifier factory or policy callback cannot mutate the * data that verification and policy enforcement rely on. */ function cloneMessageSignature(signature: Readonly): MessageSignature { return { label: signature.label, components: signature.components.map(({ name, parameters }) => ({ name, parameters: parameters.map(([parameterName, value]) => [parameterName, value]), })), parameters: signature.parameters.map(([name, value]) => [ name, cloneSignatureParameterValue(value), ]), signature: cloneBytes(signature.signature), } } /** * Serializes one signature as the single-member `Signature-Input` and `Signature` Dictionary values * that can be appended to a message. */ function serializeSignatureFields( label: string, components: ReadonlyArray, parameters: SfParameters, signature: Uint8Array, ): { readonly signatureInput: string; readonly signatureField: string } { const inputDictionary: SfDictionary = [ [label, signatureParametersInnerList(components, parameters)], ] const signatureDictionary: SfDictionary = [ [label, { kind: 'item', value: { kind: 'binary', value: signature }, parameters: [] }], ] return { signatureInput: serializeDictionary(inputDictionary), signatureField: serializeDictionary(signatureDictionary), } } interface SignatureCreation { readonly fields: SignatureFields assertUnchanged(): void } /** * Creates one signature and returns it together with a check that re-verifies the signing context. * * Refuses to cover the `Signature` and `Signature-Input` fields the signature is about to be * appended to, unless the identifier selects a different message or an existing labeled member with * `req`, `tr`, or `key`. Signing something that this operation is itself about to change could * never be reproduced by a verifier. */ async function createSignatureInternal( message: SignableRequest | SignableResponse, options: SignOptions, ): Promise { assertMessage(message) assertConfigurationObject(options, '"options"') assertSignatureContext(options) assertSignatureContextConfiguration(options) const label = options.label ?? 'sig1' assertSfKey(label, 'Signature label') const components = normalizeComponents(options.components) assertSignableComponents(components, label) const parameters = normalizeSignatureParameters(options.parameters, unixTimestamp(options.now)) const serializedComponents = serializeCoveredComponents(components) const captured = captureSignatureOperation(message, options) const existing = parseSignatureFieldDictionaries(captured.message.headers) if (existing.inputs.some(([existingLabel]) => existingLabel === label)) { fail(`Signature label "${label}" is already present`) } const base = buildSignatureBase( captured.message, components, serializedComponents, parameters, captured.context, ) const signer = signerFromFactory(options.signer) const algorithm = signer.alg assertBindingUnchanged(captured.binding, 'signing') const signaledAlgorithm = findSfParameterValue(parameters, 'alg') if ( signaledAlgorithm !== undefined && (signaledAlgorithm.kind !== 'string' || signaledAlgorithm.value !== algorithm) ) { fail('The signer algorithm does not match the "alg" signature parameter') } let signature: Uint8Array try { signature = await signer.sign(encoder.encode(base)) } catch (cause) { throw new Error('Failed to create HTTP message signature', { cause }) } if (!isUint8Array(signature)) { fail('Signer output must be a Uint8Array') } assertBindingUnchanged(captured.binding, 'signing') const ownedSignature = cloneBytes(signature) const serializedFields = serializeSignatureFields(label, components, parameters, ownedSignature) const fields: SignatureFields = { label, components, parameters: signatureParametersFromSf(parameters), signature: ownedSignature, ...serializedFields, } return { fields, assertUnchanged() { assertBindingUnchanged(captured.binding, 'signing') }, } } /** * Creates one HTTP message signature without modifying or cloning the source message. * * The returned one-member field values can be attached while constructing a message or passed to * {@link appendSignature}. A `created` timestamp is added by default. Pass `created: false` in * `parameters` to explicitly omit it. * * @example * * Reach for this instead of {@link sign} when a framework owns message construction, or when the * source message's body must stay readable: nothing here touches the message or its body. * * ```ts * declare const signer: FetchSig.SignerFactory * * const request = new Request('https://api.example/orders', { method: 'POST', body: '{}' }) * * const fields = await FetchSig.createSignature(request, { * signer, * label: 'application', * components: ['@method', '@target-uri'], * now: 1_735_689_600, * }) * * // application=("@method" "@target-uri");created=1735689600 * console.log(fields.signatureInput) * * // application=:: * console.log(fields.signatureField) * * // The source request is untouched, so its body is still readable here. * const headers = FetchSig.appendSignature(request.headers, fields) * ``` * * @example * * `created` is added for you. Suppress it with `['created', false]`, or place it yourself to * control where it lands in the signed parameter order. * * ```ts * declare const message: Request * declare const signer: FetchSig.SignerFactory * * const withoutCreated = await FetchSig.createSignature(message, { * signer, * components: ['@method'], * parameters: [ * ['created', false], * ['keyid', 'client-key'], * ], * }) * * // sig1=("@method");keyid="client-key" * console.log(withoutCreated.signatureInput) * * const createdLast = await FetchSig.createSignature(message, { * signer, * components: ['@method'], * parameters: [ * ['keyid', 'client-key'], * ['created', 1_735_689_600], * ], * }) * * // sig1=("@method");keyid="client-key";created=1735689600 * console.log(createdLast.signatureInput) * ``` * * @group Sender */ export async function createSignature( message: SignableRequest | SignableResponse, options: SignOptions, ): Promise { return (await createSignatureInternal(message, options)).fields } /** * Rebuilds a request around new headers, carrying over the properties the Fetch constructor would * otherwise reset. * * The constructor resets an inherited referrer to `client` and an inherited referrer policy to the * empty string whenever `init` is not empty, so a caller that suppressed the `Referer` field would * silently get it back on the reconstructed request. Both are restored explicitly. The getters * report `""` for `no-referrer` and `"about:client"` for `client`, which the constructor accepts * and maps back to the same values. * * A `no-cors` request is rejected outright rather than rebuilt. Fetch gives such a request's * headers the `request-no-cors` guard, and none of the fields this package appends are * CORS-safelisted, so a browser would drop them and send an unsigned message while every operation * reported success. Node.js, Deno, and Bun do not enforce the guard, but a request that signs there * and silently does not in a browser is exactly the portability trap this package exists to avoid. */ function reconstructRequest(request: Request, headers: Headers, carried: string): Request { if (request.mode === 'no-cors') { fail(`A "no-cors" request cannot carry ${carried} because Fetch drops the fields`) } return new Request(request, { headers, referrer: request.referrer, referrerPolicy: request.referrerPolicy, }) } /** * Rejects responses that the Fetch `Response` constructor cannot reproduce, so that the append * helpers report the reason instead of surfacing the constructor's own `RangeError`. * * Fetch reports opaque and network-error responses with status zero, and the `Response` constructor * only accepts statuses in the 200-599 range, so informational responses cannot be rebuilt either. */ function assertReconstructableResponse(response: Response, carried: string): void { if (response.status === 0) { fail(`Opaque and error responses cannot carry ${carried}`) } if (!Number.isInteger(response.status) || response.status < 200 || response.status > 599) { fail(`Fetch cannot reconstruct a response with status ${response.status}`) } } /** * Returns the body to pass to the `Response` constructor when rebuilding a response. * * Fetch defines 204, 205, and 304 as null body statuses and its constructor rejects a body for * them. A response that came from the network can still expose a non-null body stream for those * statuses, and whether it does is runtime-dependent, so the body is dropped here rather than * passed on. The remaining null body statuses, 101 and 103, are already excluded by * {@link assertReconstructableResponse}. */ function reconstructableResponseBody(response: Response): ReadableStream | null { const { status } = response if (status === 204 || status === 205 || status === 304) { return null } return response.body } /** * Appends a member to an HTTP field whose value is a Structured Field Dictionary, combining it with * any existing members using `", "`. * * A field that is present but empty is replaced rather than extended, because prefixing `", "` to * an empty value would produce a Dictionary that starts with a comma and no longer parses. */ function appendToDictionaryField(headers: Headers, name: string, value: string): void { const existing = getDictionaryField(headers, name) headers.set(name, existing === null ? value : `${existing}, ${value}`) } /** * Copies `Headers` and appends one signature to the `Signature-Input` and `Signature` fields. * * The supplied field values are re-parsed and checked to contain exactly the one expected label, * and the combined fields are re-parsed afterwards, so a malformed or colliding input cannot * produce a message whose signature fields no longer pair up. */ function appendSignatureHeaders(headers: Headers, fields: SignatureFields): Headers { const output = new Headers(headers) const existing = parseSignatureFieldDictionaries(output) if (existing.inputs.some(([label]) => label === fields.label)) { fail(`Signature label "${fields.label}" is already present`) } const input = parseSignatureInputInternal(fields.signatureInput) const signature = parseSignatureInternal(fields.signatureField) if ( input.length !== 1 || signature.length !== 1 || input[0]!.label !== fields.label || signature[0]!.label !== fields.label ) { fail('"fields" does not contain exactly one matching signature label') } appendToDictionaryField(output, 'signature-input', fields.signatureInput) appendToDictionaryField(output, 'signature', fields.signatureField) parseSignatureFieldDictionaries(output) return output } /** * Adds one signature to `Headers` and returns a new `Headers` object. * * @example * * Existing signatures are kept, so several parties can sign the same message under distinct labels. * A label that is already present is rejected rather than overwritten. * * ```ts * declare const request: Request * declare const applicationSigner: FetchSig.SignerFactory * declare const auditSigner: FetchSig.SignerFactory * * const application = await FetchSig.createSignature(request, { * label: 'application', * signer: applicationSigner, * components: ['@method', '@authority', '@path'], * }) * let headers = FetchSig.appendSignature(request.headers, application) * * const audit = await FetchSig.createSignature(new Request(request, { headers }), { * label: 'audit', * signer: auditSigner, * components: ['@method', '@target-uri'], * }) * headers = FetchSig.appendSignature(headers, audit) * * // application=("@method" "@authority" "@path");created=…, audit=("@method" "@target-uri");created=… * console.log(headers.get('signature-input')) * ``` * * @group Sender */ export function appendSignature(headers: Headers, fields: SignatureFields): Headers /** * Adds one signature to a `Request` and returns a new `Request`. * * The returned message passes the source body to a new Fetch message without explicitly cloning or * buffering it. The source body's observable state is runtime-dependent. Consume the returned * request and do not rely on the source request afterward. Use {@link createSignature} and construct * the final message explicitly when both bodies must remain readable. */ export function appendSignature(headers: Request, fields: SignatureFields): Request /** * Adds one signature to a `Response` and returns a new `Response`. * * The returned message passes the source body to a new Fetch message without explicitly cloning or * buffering it. The source body's observable state is runtime-dependent. Consume the returned * response and do not rely on the source response afterward. Use {@link createSignature} and * construct the final message explicitly when both bodies must remain readable. * * Fetch does not provide a way to clone a network response while changing its immutable headers. * The returned response preserves status, status text, headers, and body, but Fetch-managed * metadata such as `url`, `redirected`, and `type` cannot be preserved. */ export function appendSignature(headers: Response, fields: SignatureFields): Response /** Adds one signature to a target whose type is not known statically. */ export function appendSignature( headers: Headers | Request | Response, fields: SignatureFields, ): Headers | Request | Response export function appendSignature( message: Headers | Request | Response, fields: SignatureFields, ): Headers | Request | Response { if (fields === null || typeof fields !== 'object') { fail('"fields" must be a SignatureFields object') } if (isHeaders(message)) { return appendSignatureHeaders(message, fields) } assertMessage(message) const headers = appendSignatureHeaders(message.headers, fields) if (isRequest(message)) { return reconstructRequest(message, headers, 'HTTP message signatures') } assertReconstructableResponse(message, 'HTTP message signatures') return new Response(reconstructableResponseBody(message), { headers, status: message.status, statusText: message.statusText, }) } /** * Creates and appends one HTTP message signature. * * Appending passes the source body to a new Fetch message without explicitly cloning or buffering * it. The source body's observable state is runtime-dependent. Consume the returned message and do * not rely on the source message afterward. Use {@link createSignature} and construct the final * message explicitly when both bodies must remain readable. * * @example * * Sign a request. Cover everything the recipient will base a decision on: the method so a `GET` * cannot be replayed as a `POST`, the destination, and the fields that change how the body is * interpreted. * * ```ts * declare const signer: FetchSig.SignerFactory * * const unsigned = new Request('https://api.example/orders?account=123', { * method: 'POST', * headers: { * 'content-type': 'application/json', * 'content-digest': 'sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:', * }, * body: '', * }) * * const signed = await FetchSig.sign(unsigned, { * signer, * components: [ * '@method', * '@authority', * '@path', * FetchSig.component('@query-param', [['name', 'account']]), * 'content-type', * 'content-digest', * ], * parameters: [ * ['alg', 'ed25519'], * ['keyid', 'https://issuer.example/keys/current'], * ['tag', 'order'], * ], * now: 1_735_689_600, * }) * * // sig1=("@method" "@authority" "@path" "@query-param";name="account" "content-type" * // "content-digest");created=1735689600;alg="ed25519" * // ;keyid="https://issuer.example/keys/current";tag="order" * console.log(signed.headers.get('signature-input')) * * // Send the returned request. The source request must not be reused. * await fetch(signed) * ``` * * @example * * Sign a response and bind it to the exact request that produced it. Request components need the * `req` parameter, and that request has to be supplied. * * ```ts * declare const signer: FetchSig.SignerFactory * declare const request: Request * declare const response: Response * * const signed = await FetchSig.sign(response, { * signer, * request, * components: [ * '@status', * 'content-type', * FetchSig.component('@method', [['req', true]]), * FetchSig.component('@authority', [['req', true]]), * FetchSig.component('@path', [['req', true]]), * ], * parameters: [['keyid', 'https://issuer.example/keys/current']], * }) * ``` * * @group Sender */ export function sign(message: Request, options: SignOptions): Promise /** Creates and appends one signature to a `Response`, returning a new `Response`. */ export function sign(message: Response, options: SignOptions): Promise /** Creates and appends one signature to a message whose type is not known statically. */ export function sign(message: Request | Response, options: SignOptions): Promise export async function sign( message: Request | Response, options: SignOptions, ): Promise { const created = await createSignatureInternal(message, options) created.assertUnchanged() return isRequest(message) ? appendSignature(message, created.fields) : appendSignature(message, created.fields) } /** * A verification policy after {@link snapshotVerificationPolicy} has validated and normalized it. * * Narrowing the optional members lets policy enforcement rely on the checks having already run. */ interface NormalizedVerificationPolicy extends VerificationPolicy { readonly requiredComponents: ReadonlyArray readonly requiredParameters: ReadonlyArray readonly algorithms: ReadonlyArray readonly clockSkew: number readonly now?: number } /** * Validates everything about a verification policy that does not depend on a signature, and copies * it so that it cannot change during an asynchronous verification. * * Called when a Fetch wrapper is created as well as on every `verify()` call, so a malformed policy * is reported before any message is processed. */ function snapshotVerificationPolicy(policy: VerificationPolicy): NormalizedVerificationPolicy { assertConfigurationObject(policy, '"policy"') // Store the configuration's ordinary data values before any asynchronous verification begins. const requiredComponents = policy.requiredComponents const requiredParameters = policy.requiredParameters const algorithms = policy.algorithms const validate = policy.validate const clockSkew = policy.clockSkew ?? 0 const maxAge = policy.maxAge const now = policy.now if ( !Array.isArray(requiredComponents) || !Array.isArray(requiredParameters) || !Array.isArray(algorithms) ) { fail('"policy" must define requiredComponents, requiredParameters, and algorithms arrays') } if (validate !== undefined && typeof validate !== 'function') { fail('"policy.validate" must be a function') } if (algorithms.length === 0) { fail('"policy.algorithms" must not be empty') } if (algorithms.some((algorithm) => typeof algorithm !== 'string' || algorithm.length === 0)) { fail('"policy.algorithms" must contain non-empty strings') } for (const parameter of requiredParameters) { if (typeof parameter !== 'string') { fail('"policy.requiredParameters" must contain strings') } assertSfKey(parameter, 'Required signature parameter') } if (!Number.isFinite(clockSkew) || clockSkew < 0) { fail('"policy.clockSkew" must be a non-negative number') } if (maxAge !== undefined && (!Number.isFinite(maxAge) || maxAge < 0)) { fail('"policy.maxAge" must be a non-negative number') } return { requiredComponents: normalizeComponents(requiredComponents), requiredParameters: [...requiredParameters], algorithms: [...algorithms], maxAge, clockSkew, now: now === undefined ? undefined : unixTimestamp(now), validate, } } /** * Applies a validated policy to one parsed signature: algorithm allowlist, covered component * coverage, required metadata parameters, and timestamp acceptance. * * Run before and after cryptographic verification, and again after `policy.validate`, so that a * signature which expires while an asynchronous callback is running is still rejected. */ function enforceVerificationPolicy( signature: Readonly, policy: NormalizedVerificationPolicy, ): void { const signaledAlgorithm = findSignatureParameterValue(signature.parameters, 'alg') if (typeof signaledAlgorithm === 'string' && !policy.algorithms.includes(signaledAlgorithm)) { verificationFail('policy_rejected', `Algorithm "${signaledAlgorithm}" is not allowed by policy`) } for (const required of policy.requiredComponents) { if (!signature.components.some((covered) => sameComponent(required, covered))) { verificationFail('policy_rejected', `Required component "${required.name}" is not covered`) } } for (const parameter of policy.requiredParameters) { if (findSignatureParameterValue(signature.parameters, parameter) === undefined) { verificationFail('policy_rejected', `Required signature parameter "${parameter}" is missing`) } } const skew = policy.clockSkew // Re-read the clock on every call so that a signature which expires while an asynchronous // verifier or policy callback is running is still rejected. const now = unixTimestamp(policy.now) const created = findSignatureParameterValue(signature.parameters, 'created') const expires = findSignatureParameterValue(signature.parameters, 'expires') if (created !== undefined && typeof created !== 'number') { verificationFail('signature_malformed', 'Signature parameter "created" must be an Integer') } if (expires !== undefined && typeof expires !== 'number') { verificationFail('signature_malformed', 'Signature parameter "expires" must be an Integer') } if (created !== undefined && created > now + skew) { verificationFail('signature_time_invalid', 'HTTP message signature was created in the future') } if (expires !== undefined && expires < now - skew) { verificationFail('signature_time_invalid', 'HTTP message signature has expired') } if (created !== undefined && expires !== undefined && expires < created) { verificationFail('signature_malformed', 'HTTP message signature expires before it was created') } if (policy.maxAge !== undefined) { if (created === undefined) { verificationFail( 'policy_rejected', '"policy.maxAge" requires the "created" signature parameter', ) } if (now - created > policy.maxAge + skew) { verificationFail( 'signature_time_invalid', 'HTTP message signature is older than policy permits', ) } } } /** * Checks the algorithm the verifier factory selected against the policy allowlist and against the * `alg` signature parameter. * * RFC 9421 requires the algorithms resolved from different sources to agree. */ function enforceVerificationAlgorithm( signature: Readonly, algorithm: string, policy: NormalizedVerificationPolicy, ): void { if (!policy.algorithms.includes(algorithm)) { verificationFail('policy_rejected', `Algorithm "${algorithm}" is not allowed by policy`) } const signaledAlgorithm = findSignatureParameterValue(signature.parameters, 'alg') if (signaledAlgorithm !== undefined && signaledAlgorithm !== algorithm) { verificationFail( 'signature_malformed', 'The verifier algorithm does not match the "alg" signature parameter', ) } } /** * Verifies and applies explicit application policy to one HTTP message signature. * * Invalid configuration and provider contracts throw a `TypeError`. Parse, policy, context, * key-selection, algorithm, and cryptographic failures throw {@link VerificationError}. When * multiple signatures are present, callers must select a label explicitly. * * @example * * Verification needs all three of a key-resolving verifier factory, an explicit policy, and the * cryptographic check. There is no mode that accepts any cryptographically valid signature. * * ```ts * declare const request: Request * declare const verifier: FetchSig.VerifierFactory * * const verified = await FetchSig.verify(request, { * verifier, * policy: { * // The exact components the application relies on, matched with their parameters. * requiredComponents: ['@method', '@authority', '@path', 'content-digest'], * requiredParameters: ['created', 'keyid', 'nonce'], * algorithms: ['ed25519'], * maxAge: 60, * clockSkew: 5, * async validate(signature, context) { * // Runs only after the signature is cryptographically valid, so the nonce is authentic. * const nonce = FetchSig.getSignatureParameter(signature, 'nonce') * if (typeof nonce !== 'string') { * throw new Error('A nonce is required') * } * await claimNonceOnce(nonce, context.message) * }, * }, * }) * * declare function claimNonceOnce( * nonce: string, * message: FetchSig.MessageSnapshot, * ): Promise * * // ed25519 [ [ 'created', 1735689600 ], [ 'keyid', 'client-key' ], [ 'nonce', '…' ] ] * console.log(verified.algorithm, verified.parameters) * ``` * * @example * * Verify a response and bind it to the request that produced it. Without the related request, a * signature covering `;req` components cannot be reproduced and verification fails. * * ```ts * declare const sentRequest: Request * declare const response: Response * declare const verifier: FetchSig.VerifierFactory * * await FetchSig.verify(response, { * request: sentRequest, * verifier, * policy: { * requiredComponents: [ * '@status', * FetchSig.component('@method', [['req', true]]), * FetchSig.component('@authority', [['req', true]]), * FetchSig.component('@path', [['req', true]]), * ], * requiredParameters: ['created', 'keyid'], * algorithms: ['ed25519'], * maxAge: 60, * }, * }) * ``` * * @group Recipient */ export async function verify( message: SignableRequest | SignableResponse, options: VerifyOptions, ): Promise { assertMessage(message) assertConfigurationObject(options, '"options"') assertSignatureContext(options) assertSignatureContextConfiguration(options) if (options.label !== undefined) { assertSfKey(options.label, 'Signature label') } const policy = snapshotVerificationPolicy(options.policy) let captured: CapturedSignatureOperation try { captured = captureSignatureOperation(message, options) } catch (cause) { throw verificationError('signature_malformed', cause) } const selected = selectSignature(captured.message, options.label) let serializedComponents: ReadonlyArray try { for (const identifier of selected.input.components) { validateComponentForMessage(identifier, captured.message) } serializedComponents = serializeCoveredComponents(selected.input.components) } catch (cause) { throw verificationError('signature_malformed', cause) } const signature = cloneMessageSignature(selected.public) enforceVerificationPolicy(signature, policy) let base: string try { base = buildSignatureBase( captured.message, selected.input.components, serializedComponents, selected.input.parameters, captured.context, ) } catch (cause) { throw verificationError('signature_malformed', cause) } const context: VerificationContext = Object.freeze({ message: captured.message, request: captured.context.request as RequestSnapshot | undefined, }) const verifier = await verifierFromFactory( options.verifier, cloneMessageSignature(signature), context, ) assertVerificationBindingUnchanged(captured.binding) const algorithm = verifier.alg enforceVerificationAlgorithm(signature, algorithm, policy) let valid: boolean try { valid = await verifier.verify(encoder.encode(base), cloneBytes(selected.signature)) } catch (cause) { throw verificationError('verification_failed', cause, 'Failed to verify HTTP message signature') } if (typeof valid !== 'boolean') { fail('Verifier output must be a boolean') } assertVerificationBindingUnchanged(captured.binding) if (!valid) { verificationFail('signature_mismatch', 'HTTP message signature verification failed') } enforceVerificationPolicy(signature, policy) if (policy.validate !== undefined) { try { await policy.validate( cloneMessageSignature(signature), Object.freeze({ ...context, algorithm }), ) } catch (cause) { throw verificationError('policy_rejected', cause) } assertVerificationBindingUnchanged(captured.binding) enforceVerificationPolicy(signature, policy) } assertVerificationBindingUnchanged(captured.binding) return { ...signature, algorithm } } /** A requested HTTP message signature parsed from `Accept-Signature`. */ export interface SignatureRequest { readonly label: string readonly components: ReadonlyArray readonly parameters: ReadonlyArray } /** Input used to create an `Accept-Signature` member. */ export interface SignatureRequestInput { readonly label: string readonly components: ReadonlyArray readonly parameters?: SignatureParameters } /** Options for fulfilling an `Accept-Signature` member. */ export interface RequestedSignOptions extends SignatureContext { readonly signer: SignerFactory /** * Values that satisfy requested parameters and any additional parameters selected by the signer. * An `expires` request requires an explicit `expires` value here. */ readonly parameters?: SignatureParameters readonly now?: number | Date } /** * Converts application-supplied `Accept-Signature` parameters into Structured Field parameters * using the request-side value types. */ function normalizeRequestedParameters(parameters: SignatureParameters | undefined): SfParameters { const output: SfParameters = [] const seen = new Set() for (const [name, input] of orderedParameterEntries(parameters)) { assertSfKey(name, 'Requested signature parameter name') if (seen.has(name)) { fail(`Duplicate requested signature parameter "${name}"`) } seen.add(name) const value = sfBareItemFromSignatureParameter(name, input) if (value !== undefined) { output.push([name, value]) } } validateKnownSignatureParameters(output, true) return output } /** * Parses an `Accept-Signature` field value into validated signature requests, rejecting a repeated * label. */ function parseAcceptSignatureInternal(value: string): ParsedSignatureInput[] { const dictionary = parseSfTopLevel(value, 'dictionary', true) as SfDictionary return dictionary.map(([label, member]) => { if (member.kind !== 'inner-list') { fail(`Accept-Signature member "${label}" must be an Inner List`) } validateKnownSignatureParameters(member.parameters, true) const components = member.value.map(componentFromSfItem) for (const identifier of components) { validateComponentParameters(identifier) } assertUniqueComponents(components) return { label, components, parameters: member.parameters } }) } /** * Parses an `Accept-Signature` field value into its labeled signature requests. * * Validates component identifiers and the value types of requested signature metadata parameters, * where `created` and `expires` carry no value because the signer chooses the timestamps. It does * not check the requested components against a message. Use {@link getSignatureRequests} when the * message is available. * * @example * * A requested `created` carries no value, because the signer chooses the timestamp. A requested * `keyid` carries the value the signer is being asked to use. * * ```ts * const [request] = FetchSig.parseAcceptSignature( * 'response=("@status" "content-type" "@method";req);created;keyid="server-key"', * ) * * // response * console.log(request!.label) * * // [ '@status', 'content-type', '@method' ] * console.log(request!.components.map(({ name }) => name)) * * // [ [ 'created', true ], [ 'keyid', 'server-key' ] ] * console.log(request!.parameters) * ``` * * @group Signature Negotiation */ export function parseAcceptSignature(value: string): ReadonlyArray { if (typeof value !== 'string') { fail('"value" must be a string') } return parseAcceptSignatureInternal(value).map(({ label, components, parameters }) => ({ label, components, parameters: signatureParametersFromSf(parameters), })) } /** * Parses every signature request carried by a message and checks that each requested component * applies to the message that would be signed. * * The target message is the other direction: `Accept-Signature` on a request asks for a signature * on the response, and on a response it asks for a signature on the client's next request. Returns * an empty array when the message carries no `Accept-Signature` field. * * @example * * A server decides which request it is willing to fulfill. The parsed request is untrusted input, * so check the label and the coverage against local policy before signing anything. * * ```ts * declare const incomingRequest: Request * declare const response: Response * declare const signer: FetchSig.SignerFactory * * const [signatureRequest] = FetchSig.getSignatureRequests(incomingRequest) * if (signatureRequest === undefined || signatureRequest.label !== 'response') { * throw new Error('No supported signature request') * } * * const signed = await FetchSig.signRequested(response, signatureRequest, { * signer, * request: incomingRequest, * parameters: [['keyid', 'server-key']], * }) * ``` * * @group Signature Negotiation */ export function getSignatureRequests( message: SignableRequest | SignableResponse, ): ReadonlyArray { const snapshot = captureMessage(message) const value = getDictionaryField(snapshot.headers, 'accept-signature') if (value === null) { return [] } const requests = parseAcceptSignature(value) const targetIsRequest = !isRequest(snapshot) for (const request of requests) { for (const identifier of request.components) { validateComponentForTarget(identifier, targetIsRequest) } } return requests } /** * Serializes one or more signature requests as an `Accept-Signature` Structured Field Dictionary. * * Use {@link appendAcceptSignature} when the sender message is available so component applicability * can also be checked against the type of the requested target message. * * @example * * `created: true` asks for a timestamp without dictating it. A parameter given a value, such as * `keyid`, is a value the signer must reproduce exactly. * * ```ts * const value = FetchSig.createAcceptSignature([ * { * label: 'response', * components: [ * '@status', * 'content-type', * FetchSig.component('@method', [['req', true]]), * FetchSig.component('@path', [['req', true]]), * ], * parameters: [ * ['created', true], * ['keyid', 'server-key'], * ], * }, * ]) * * // response=("@status" "content-type" "@method";req "@path";req);created;keyid="server-key" * console.log(value) * ``` * * @group Signature Negotiation */ export function createAcceptSignature(requests: ReadonlyArray): string { if (!Array.isArray(requests) || requests.length === 0) { fail('"requests" must be a non-empty array') } const dictionary: SfDictionary = [] for (const request of requests) { assertConfigurationObject(request, 'Signature request') assertSfKey(request.label, 'Signature request label') if (dictionary.some(([label]) => label === request.label)) { fail(`Duplicate signature request label "${request.label}"`) } const components = normalizeComponents(request.components) for (const identifier of components) { validateComponentParameters(identifier) } assertUniqueComponents(components) const parameters = normalizeRequestedParameters(request.parameters) dictionary.push([request.label, signatureParametersInnerList(components, parameters)]) } return serializeDictionary(dictionary) } /** * Copies `Headers` and appends `Accept-Signature` requests, re-parsing the combined value and re- * checking that every requested component applies to the message that would be signed. */ function appendAcceptSignatureHeaders( headers: Headers, value: string, targetIsRequest: boolean, ): Headers { const output = new Headers(headers) const existing = getDictionaryField(output, 'accept-signature') const combined = existing === null ? value : `${existing}, ${value}` const requests = parseAcceptSignatureInternal(combined) for (const request of requests) { for (const identifier of request.components) { validateComponentForTarget(identifier, targetIsRequest) } } output.set('accept-signature', combined) return output } /** * Adds `Accept-Signature` requests to a `Request` or `Response` and returns a new message. * * On a request, the field asks for signatures on the response. On a response, it asks for * signatures on the client's next request. * * The returned message passes the source body to a new Fetch message without explicitly cloning or * buffering it. The source body's observable state is runtime-dependent. Consume the returned * message and do not rely on the source message afterward. Use {@link createAcceptSignature} and * construct the final message explicitly when both bodies must remain readable. * * @example * * Ask the server to sign its response. Because the field is on a request, the requested components * are checked against a response, which is why the request components carry `req`. * * ```ts * const request = FetchSig.appendAcceptSignature( * new Request('https://api.example/orders/123'), * [ * { * label: 'response', * components: [ * '@status', * 'content-type', * FetchSig.component('@method', [['req', true]]), * FetchSig.component('@path', [['req', true]]), * ], * parameters: [ * ['created', true], * ['keyid', 'server-key'], * ], * }, * ], * ) * * // response=("@status" "content-type" "@method";req "@path";req);created;keyid="server-key" * console.log(request.headers.get('accept-signature')) * ``` * * @example * * On a response the field asks the client to sign its next request, so the requested components are * checked against a request and `req` is not allowed. * * ```ts * const response = FetchSig.appendAcceptSignature(new Response('', { status: 401 }), [ * { * label: 'client', * components: ['@method', '@authority', '@path'], * parameters: [['nonce', 'e4c7f2a1']], * }, * ]) * * // client=("@method" "@authority" "@path");nonce="e4c7f2a1" * console.log(response.headers.get('accept-signature')) * ``` * * @group Signature Negotiation */ export function appendAcceptSignature( message: Request, requests: ReadonlyArray, ): Request /** Adds `Accept-Signature` requests to a `Response` and returns a new `Response`. */ export function appendAcceptSignature( message: Response, requests: ReadonlyArray, ): Response /** Adds `Accept-Signature` requests to a message whose type is not known statically. */ export function appendAcceptSignature( message: Request | Response, requests: ReadonlyArray, ): Request | Response export function appendAcceptSignature( message: Request | Response, requests: ReadonlyArray, ): Request | Response { assertMessage(message) const value = createAcceptSignature(requests) const targetIsRequest = !isRequest(message) for (const request of requests) { for (const identifier of normalizeComponents(request.components)) { validateComponentForTarget(identifier, targetIsRequest) } } const headers = appendAcceptSignatureHeaders(message.headers, value, targetIsRequest) if (isRequest(message)) { return reconstructRequest(message, headers, 'Accept-Signature') } assertReconstructableResponse(message, 'Accept-Signature') return new Response(reconstructableResponseBody(message), { headers, status: message.status, statusText: message.statusText, }) } /** * Converts the public parameters of a parsed signature request back into Structured Field * parameters so that they can be compared with, and merged into, the parameters the signer * supplies. */ function signatureParametersToSf( parameters: ReadonlyArray, ): SfParameters { if (!Array.isArray(parameters)) { fail('Signature request parameters must be an array') } const seen = new Set() const output: SfParameters = [] for (const [name, value] of parameters) { assertSfKey(name, 'Requested signature parameter name') if (seen.has(name)) { fail(`Duplicate requested signature parameter "${name}"`) } seen.add(name) const item = sfBareItemFromSignatureParameter(name, value) if (item === undefined) { fail(`Signature parameter "${name}" is undefined`) } output.push([name, item]) } validateKnownSignatureParameters(output, true) return output } /** * Merges the parameters an `Accept-Signature` member requested with the values the signer supplied. * * RFC 9421 requires the fulfilling signature to process every requested parameter. A requested * `created` defaults to the signing clock, a requested `expires` or `keyid` must be chosen * explicitly by the signer, an extension parameter the implementation does not define must be * supplied explicitly, and a supplied value must not conflict with the requested one. Parameters * the signer adds on its own are appended afterwards, which the RFC permits. */ function mergeRequestedParameters( request: SignatureRequest, parameters: SignatureParameters | undefined, now: number, ): { readonly parameters: SfParameters; readonly omitDefaultCreated: boolean } { const requested = signatureParametersToSf(request.parameters) const suppliedEntries = orderedParameterEntries(parameters) const supplied = normalizeSignatureParameterEntries(suppliedEntries, undefined) const output: SfParameters = [] const omitDefaultCreated = suppliedEntries.some( ([name, value]) => name === 'created' && value === false, ) for (const [name, requestedValue] of requested) { const suppliedValue = findSfParameterValue(supplied, name) if (name === 'created') { output.push([name, suppliedValue ?? { kind: 'integer', value: now }]) continue } if (name === 'expires') { if (suppliedValue === undefined) { fail('An Accept-Signature "expires" request requires an explicit expiration time') } output.push([name, suppliedValue]) continue } if (name === 'keyid' && suppliedValue === undefined) { fail('An Accept-Signature "keyid" request requires explicit key selection') } if (!SIGNATURE_PARAMETERS.has(name) && suppliedValue === undefined) { fail(`Unsupported requested signature parameter "${name}" must be explicitly processed`) } if (suppliedValue !== undefined && !sameBareItem(requestedValue, suppliedValue)) { fail(`Supplied signature parameter "${name}" conflicts with Accept-Signature`) } output.push([name, requestedValue]) } for (const [name, value] of supplied) { if (!output.some(([existing]) => existing === name)) { output.push([name, value]) } } return { parameters: output, omitDefaultCreated: omitDefaultCreated && findSfParameterValue(output, 'created') === undefined, } } /** Validates a parsed request and turns it into regular signing options. */ function requestedSignatureOptions( message: SignableRequest | SignableResponse, request: SignatureRequest, options: RequestedSignOptions, ): SignOptions { assertMessage(message) if (request === null || typeof request !== 'object') { fail('"request" must be a SignatureRequest') } assertConfigurationObject(options, '"options"') assertSignatureContext(options) assertSignatureContextConfiguration(options) assertSfKey(request.label, 'Signature request label') const components = normalizeComponents(request.components) assertUniqueComponents(components) for (const identifier of components) { validateComponentForMessage(identifier, message) } const normalizedRequest: SignatureRequest = { label: request.label, components, parameters: request.parameters, } const now = unixTimestamp(options.now) const merged = mergeRequestedParameters(normalizedRequest, options.parameters, now) const parameters: SignatureParameter[] = signatureParametersFromSf(merged.parameters) if (merged.omitDefaultCreated) { parameters.push(['created', false]) } return { ...options, label: normalizedRequest.label, components, parameters, now } } /** * Fulfills one parsed `Accept-Signature` request without modifying the target message. * * Signs exactly the requested label and covered components, and processes every requested signature * metadata parameter: a requested `created` defaults to the signing clock, a requested `expires` or * `keyid` must be supplied here, and a requested parameter this implementation does not define must * be supplied here with the same value. Additional parameters may be supplied and are appended. * * @example * * The label and covered components come from the request. The values that cannot be chosen from the * request alone come from the signer. Here `keyid` was requested and so must be selected * explicitly, and `expires` is a local policy decision rather than something the peer dictates. * * ```ts * declare const incomingRequest: Request * declare const response: Response * declare const signer: FetchSig.SignerFactory * * const [signatureRequest] = FetchSig.getSignatureRequests(incomingRequest) * if (signatureRequest === undefined) { * throw new Error('No signature request') * } * * const fields = await FetchSig.createRequestedSignature(response, signatureRequest, { * signer, * request: incomingRequest, * parameters: [ * ['keyid', 'server-key'], * ['expires', 1_735_689_660], * ], * now: 1_735_689_600, * }) * * // response=("@status" "content-type" "@method";req "@path";req) * // ;created=1735689600;keyid="server-key";expires=1735689660 * console.log(fields.signatureInput) * ``` * * @group Signature Negotiation */ export async function createRequestedSignature( message: SignableRequest | SignableResponse, request: SignatureRequest, options: RequestedSignOptions, ): Promise { return createSignature(message, requestedSignatureOptions(message, request, options)) } /** * Fulfills and appends one parsed `Accept-Signature` request. * * Appending passes the source body to a new Fetch message without explicitly cloning or buffering * it. The source body's observable state is runtime-dependent. Consume the returned message and do * not rely on the source message afterward. Use {@link createRequestedSignature} and construct the * final message explicitly when both bodies must remain readable. * * @example * * A server-side handler that answers `Accept-Signature` on the request it just received. * * ```ts * declare const signer: FetchSig.SignerFactory * * async function handle(request: Request): Promise { * const response = new Response('{"ok":true}', { * status: 200, * headers: { 'content-type': 'application/json' }, * }) * * const [signatureRequest] = FetchSig.getSignatureRequests(request) * if (signatureRequest === undefined) { * return response * } * * return FetchSig.signRequested(response, signatureRequest, { * signer, * request, * parameters: [['keyid', 'server-key']], * }) * } * ``` * * @group Signature Negotiation */ export function signRequested( message: Request, request: SignatureRequest, options: RequestedSignOptions, ): Promise /** Fulfills and appends one requested signature on a `Response`. */ export function signRequested( message: Response, request: SignatureRequest, options: RequestedSignOptions, ): Promise /** Fulfills and appends one requested signature on a message whose type is not known statically. */ export function signRequested( message: Request | Response, request: SignatureRequest, options: RequestedSignOptions, ): Promise export async function signRequested( message: Request | Response, request: SignatureRequest, options: RequestedSignOptions, ): Promise { const created = await createSignatureInternal( message, requestedSignatureOptions(message, request, options), ) created.assertUnchanged() return isRequest(message) ? appendSignature(message, created.fields) : appendSignature(message, created.fields) } /** Options for a Fetch-compatible function that signs requests. */ export interface SigningFetchOptions { readonly sign: Omit readonly fetch?: typeof globalThis.fetch } /** Options for a Fetch-compatible function that verifies responses against their requests. */ export interface VerifyingFetchOptions { readonly verify: Omit readonly fetch?: typeof globalThis.fetch } /** Options for a Fetch-compatible function that signs requests and optionally verifies responses. */ export interface SignedFetchOptions { readonly sign: Omit readonly verify?: Omit readonly fetch?: typeof globalThis.fetch } /** * Copies the `structuredFields` mapping so that a Fetch wrapper cannot be reconfigured after it was * created. */ function snapshotStructuredFields( structuredFields: SignatureContext['structuredFields'], ): SignatureContext['structuredFields'] { if (structuredFields === undefined) { return undefined } const snapshot = Object.create(null) as Record for (const [name, type] of Object.entries(structuredFields)) { Object.defineProperty(snapshot, name, { configurable: false, enumerable: true, value: type, writable: false, }) } return Object.freeze(snapshot) } /** * Copies one signature metadata parameter input, including `Date` and `Uint8Array` values, so that * a Fetch wrapper cannot be reconfigured after it was created. */ function snapshotSignatureParameterInput(value: SignatureParameterInput): SignatureParameterInput { if (isDate(value)) { return new Date(Date.prototype.getTime.call(value)) } if (isUint8Array(value)) { return cloneBytes(value) } if (value !== null && typeof value === 'object') { assertConfigurationObject(value, 'Signature parameter value') return { ...value } } return value } /** * Copies signature metadata parameters into ordered entries so that a Fetch wrapper cannot be * reconfigured after it was created. */ function snapshotSignatureParameters( parameters: SignatureParameters | undefined, ): SignatureParameters | undefined { if (parameters === undefined) { return undefined } return orderedParameterEntries(parameters).map(([name, value]) => [ name, snapshotSignatureParameterInput(value), ]) } /** Copies and validates the signing configuration of a Fetch wrapper at construction time. */ function snapshotFetchWrapperSignOptions( options: Omit, ): Omit { assertConfigurationObject(options, '"options.sign"') assertSignatureContext(options) assertSignatureContextConfiguration(options) return { signer: options.signer, components: normalizeComponents(options.components), parameters: snapshotSignatureParameters(options.parameters), label: options.label, now: isDate(options.now) ? new Date(Date.prototype.getTime.call(options.now)) : options.now, structuredFields: snapshotStructuredFields(options.structuredFields), } } /** Copies and validates the verification configuration of a Fetch wrapper at construction time. */ function snapshotFetchWrapperVerifyOptions( options: Omit, ): Omit { assertConfigurationObject(options, '"options.verify"') assertSignatureContext(options) assertSignatureContextConfiguration(options) return { verifier: options.verifier, policy: snapshotVerificationPolicy(options.policy), label: options.label, structuredFields: snapshotStructuredFields(options.structuredFields), } } /** Resolves the Fetch implementation a wrapper delegates to, defaulting to the global `fetch`. */ function resolveFetchImplementation( options: Readonly<{ fetch?: typeof globalThis.fetch }>, ): typeof globalThis.fetch { const implementation = options.fetch ?? globalThis.fetch if (typeof implementation !== 'function') { fail('"options.fetch" must be a Fetch implementation') } return implementation } /** * Builds the `Request` a Fetch wrapper will operate on, downgrading automatic redirects to manual * ones. * * Fetch cannot re-sign each request in a redirect chain and does not expose the request that * produced a response after following a redirect, so following redirects automatically would either * forward stale signature fields to another origin or verify a response against the wrong request. * An explicitly configured `redirect` mode is left as the caller set it. */ function createFetchRequest(input: RequestInfo | URL, init?: RequestInit): Request { if (init !== undefined && init !== null) { assertConfigurationObject(init, 'Request initializer') } let request = new Request(input, init) if (request.redirect === 'follow') { request = new Request(request, { redirect: 'manual', referrer: request.referrer, referrerPolicy: request.referrerPolicy, }) } return request } /** The `RequestInit` members Fetch defines, all of which a constructed `Request` already carries. */ const STANDARD_REQUEST_INIT = new Set([ 'body', 'cache', 'credentials', 'duplex', 'headers', 'integrity', 'keepalive', 'method', 'mode', 'priority', 'redirect', 'referrer', 'referrerPolicy', 'signal', 'window', ]) /** * Builds the initializer forwarded to the underlying `fetch` alongside the signed request. * * Runtimes define transport options outside Fetch: `dispatcher` on Node.js, `client` on Deno, `cf` * on Cloudflare Workers, and `protocol`, `proxy`, `tls`, and `unix` on Bun, among others. The * wrappers turn the caller's initializer into a `Request` so it can be signed, and passing only * that request to the underlying `fetch` would drop any option the runtime does not itself attach * to the request, opening an ordinary connection where the caller required a proxy, a client * certificate, or a Unix socket. Which options survive a reconstruction on their own varies by * runtime, so every own enumerable extension member is forwarded. Initializers follow the package's * ordinary-data contract: inherited, non-enumerable, accessor, callable, and class-based shapes are * rejected before signing rather than assigned special forwarding semantics. * * Other standard members are deliberately excluded: they are already on the signed request, and * forwarding `headers` in particular would replace the signature fields. `referrer` and * `referrerPolicy` are the exception, and are set from the signed request rather than the caller's * initializer, because a non-empty initializer makes the implementation rebuild the request and * that rebuild would otherwise reset them. * * A data property is captured here, while this call is still synchronous with the caller's * invocation, which is when `fetch()` reads its own initializer. A caller that reuses one * initializer and assigns to it again while an earlier signature is still pending therefore keeps * the value the earlier request was called with. Deferring that read would let a later assignment * change the transport of a request already in flight, and assigning `undefined` would turn a * request that had to use a proxy into a direct connection. */ function runtimeFetchOptions(request: Request, init?: RequestInit): RequestInit | undefined { if (init === null || init === undefined) { return undefined } const source = init as Record // Null-prototype, and members are installed with defineProperty rather than assignment, so that // nothing the caller did not put here can be read back out as a standard member. let forwarded: Record | undefined const carry = (name: string, value: unknown) => { forwarded ??= Object.create(null) as Record Object.defineProperty(forwarded, name, { configurable: true, enumerable: true, value, writable: true, }) } // Every own member has already been checked to be an enumerable data property. for (const name of Object.keys(source)) { // "__proto__" is not a Fetch member, and carrying it only creates the risk that something // downstream copies it onto an ordinary object and installs a prototype from it. An initializer // parsed from untrusted JSON is the one realistic way this key arrives. if (STANDARD_REQUEST_INIT.has(name) || name === '__proto__') { continue } carry(name, Object.getOwnPropertyDescriptor(source, name)!.value) } if (forwarded === undefined) { return undefined } carry('referrer', request.referrer) carry('referrerPolicy', request.referrerPolicy) return forwarded as RequestInit } /** * Resolves with the signed request, or rejects as soon as the caller's signal aborts. * * A signer can be arbitrarily slow: an HSM, a remote signing service, or an application signer that * stalls. Awaiting it without observing the signal would leave the caller's `fetch()` pending for * as long as the signer takes, with no way to give up. When the abort wins, the transport is never * reached, because the wrapper never gets a request to send. */ function settleBeforeAbort( operation: () => Promise, signal: AbortSignal | null, onLoss?: (value: T | undefined) => void, ): Promise { // The operation is started only once the signal has been checked, so an already-aborted request // never reaches a signer or a verifier and cannot trigger remote signing service or HSM work. if (signal?.aborted) { return Promise.reject(signal.reason) } const pending = operation() if (signal === null || signal === undefined) { return pending } // The operation keeps running when it loses, so its result is consumed: an eventual rejection must // not surface as an unhandled rejection, and an eventual value may own a resource to release. const consume = () => { pending.then( (value) => onLoss?.(value), () => onLoss?.(undefined), ) } // Starting the operation runs the signer or verifier factory synchronously, and that factory can // abort. An abort event is not replayed to a listener added afterwards, so the flag is read again // here rather than relying on the listener below to observe it. if (signal.aborted) { consume() return Promise.reject(signal.reason) } return new Promise((resolve, reject) => { const onAbort = () => { consume() reject(signal.reason) } signal.addEventListener('abort', onAbort, { once: true }) pending.then( (value) => { signal.removeEventListener('abort', onAbort) resolve(value) }, (error: unknown) => { signal.removeEventListener('abort', onAbort) reject(error as Error) }, ) }) } /** * Releases the body of a message the caller will never receive. * * A wrapper that rejects keeps no reference the caller could use to cancel the stream itself, so an * unverified streaming response would hold its connection until garbage collection. Best effort: * the verification error is what matters, and a runtime that refuses the cancel must not replace * it. */ function cancelUndeliveredBody(message: Request | Response): void { try { const { body } = message // A body that has already been read is still cancellable as long as no reader holds the lock, // and cancelling it is what releases the underlying connection, so only a null or currently // locked body is skipped. if (body !== null && !body.locked) { // Deliberately not awaited. `cancel()` is allowed to reject, and nothing requires it to // settle at all, so awaiting it could hold back the error the caller is waiting for // indefinitely. Its rejection is consumed so it cannot surface as an unhandled rejection. void body.cancel().catch(() => {}) } } catch {} } /** * Drop-in `fetch` that signs outgoing requests only. Responses are returned unverified. * * Use this when the peer verifies what you send but does not sign what it returns. To verify * responses as well, use {@link createSignedFetch}. To verify without signing, use * {@link createVerifyingFetch}. * * Automatic redirects are changed to manual redirects because Fetch cannot re-sign each redirected * request and could otherwise forward stale signature fields to a different origin. * * @example * * Drop-in replacement for `fetch` that signs on the way out. Use this one, rather than * {@link createSignedFetch}, when a bundler should be able to drop the verification code. * * ```ts * declare const privateKey: CryptoKey * * const signingFetch = FetchSig.createSigningFetch({ * sign: { * signer: FetchSig.ed25519Signer(privateKey), * components: ['@method', '@authority', '@path'], * parameters: [ * ['alg', 'ed25519'], * ['keyid', 'client-key'], * ], * }, * }) * * // Takes the same arguments as fetch. * const response = await signingFetch('https://api.example/orders', { * method: 'POST', * headers: { 'content-type': 'application/json' }, * body: '{}', * }) * ``` * * @example * * Components and parameters are copied when the wrapper is created and cannot be changed * afterwards. Key material can still rotate, because the signer factory runs once per signature. * * ```ts * declare const keys: { current: CryptoKey } * declare const upstreamFetch: typeof fetch * * const signingFetch = FetchSig.createSigningFetch({ * sign: { * signer: () => FetchSig.ed25519Signer(keys.current)(), * components: ['@method', '@authority', '@path'], * parameters: [['alg', 'ed25519']], * }, * // Delegate to something other than the global fetch, such as an instrumented client. * fetch: upstreamFetch, * }) * ``` * * @group Fetch Wrappers */ export function createSigningFetch(options: SigningFetchOptions): typeof globalThis.fetch { assertConfigurationObject(options, '"options"') const implementation = resolveFetchImplementation(options) const signOptions = snapshotFetchWrapperSignOptions(options.sign) return async (input: RequestInfo | URL, init?: RequestInit): Promise => { const request = createFetchRequest(input, init) const forwarded = runtimeFetchOptions(request, init) let signedRequest: Request try { signedRequest = await settleBeforeAbort( () => sign(request, signOptions), request.signal, (signed) => { if (signed !== undefined) { cancelUndeliveredBody(signed) } }, ) } catch (error) { cancelUndeliveredBody(request) throw error } return forwarded === undefined ? implementation(signedRequest) : implementation(signedRequest, forwarded) } } /** * Drop-in `fetch` that verifies responses only. Requests are sent unsigned. * * Use this when the peer signs what it returns but does not require a signature from you. To sign * outgoing requests as well, use {@link createSignedFetch}. To sign without verifying, use * {@link createSigningFetch}. * * Automatic redirects are changed to manual redirects because Fetch does not expose the request * that produced a response after following a redirect. * * @example * * Verify every response without signing anything on the way out. The wrapper passes the exact * request it sent as the related request, which is what makes `;req` components verifiable. * * ```ts * declare const verifier: FetchSig.VerifierFactory * * const verifyingFetch = FetchSig.createVerifyingFetch({ * verify: { * verifier, * policy: { * requiredComponents: [ * '@status', * FetchSig.component('@method', [['req', true]]), * FetchSig.component('@path', [['req', true]]), * ], * requiredParameters: ['created', 'keyid'], * algorithms: ['ed25519'], * maxAge: 60, * }, * }, * }) * * // Rejects rather than resolving when the response is unsigned or the signature does not verify. * const response = await verifyingFetch('https://api.example/orders') * * // The body is untouched by verification, and its integrity is not implied by it: check * // Content-Digest separately if the response covers one. * const orders = await response.json() * ``` * * @group Fetch Wrappers */ export function createVerifyingFetch(options: VerifyingFetchOptions): typeof globalThis.fetch { assertConfigurationObject(options, '"options"') const implementation = resolveFetchImplementation(options) const verifyOptions = snapshotFetchWrapperVerifyOptions(options.verify) return async (input: RequestInfo | URL, init?: RequestInit): Promise => { const request = createFetchRequest(input, init) const forwarded = runtimeFetchOptions(request, init) const response = forwarded === undefined ? await implementation(request) : await implementation(request, forwarded) try { await settleBeforeAbort( () => verify(response, { ...verifyOptions, request }), request.signal, () => cancelUndeliveredBody(response), ) } catch (error) { cancelUndeliveredBody(response) throw error } return response } } /** * Drop-in `fetch` that signs outgoing requests and verifies responses, in both directions. * * Use this when both peers sign. Response verification is optional, so leaving `verify` out gives * the same behavior as {@link createSigningFetch} from one wrapper. To do only one direction and let * a bundler drop the other, use {@link createSigningFetch} or {@link createVerifyingFetch}. * * Automatic redirects are changed to manual redirects because Fetch cannot re-sign each redirected * request and could otherwise forward stale signature fields to a different origin. * * @example * * Both directions in one wrapper. Prefer this over nesting {@link createSigningFetch} inside * {@link createVerifyingFetch}, which would verify against a different request object and can * reconstruct a streaming request an extra time. * * ```ts * declare const privateKey: CryptoKey * declare const verifier: FetchSig.VerifierFactory * * const signedFetch = FetchSig.createSignedFetch({ * sign: { * signer: FetchSig.ed25519Signer(privateKey), * components: ['@method', '@authority', '@path'], * parameters: [['keyid', 'client-key']], * }, * verify: { * verifier, * policy: { * // The response is bound to the request this wrapper signed. * requiredComponents: [ * '@status', * FetchSig.component('@method', [['req', true]]), * FetchSig.component('@path', [['req', true]]), * ], * requiredParameters: ['created', 'keyid'], * algorithms: ['ed25519'], * maxAge: 60, * }, * }, * }) * * const response = await signedFetch('https://api.example/orders') * ``` * * @group Fetch Wrappers */ export function createSignedFetch(options: SignedFetchOptions): typeof globalThis.fetch { assertConfigurationObject(options, '"options"') const implementation = resolveFetchImplementation(options) const signOptions = snapshotFetchWrapperSignOptions(options.sign) const verifyOptions = options.verify === undefined ? undefined : snapshotFetchWrapperVerifyOptions(options.verify) return async (input: RequestInfo | URL, init?: RequestInit): Promise => { const request = createFetchRequest(input, init) const forwarded = runtimeFetchOptions(request, init) let signedRequest: Request try { signedRequest = await settleBeforeAbort( () => sign(request, signOptions), request.signal, (signed) => { if (signed !== undefined) { cancelUndeliveredBody(signed) } }, ) } catch (error) { cancelUndeliveredBody(request) throw error } const response = forwarded === undefined ? await implementation(signedRequest) : await implementation(signedRequest, forwarded) if (verifyOptions !== undefined) { try { await settleBeforeAbort( () => verify(response, { ...verifyOptions, request: signedRequest }), signedRequest.signal, () => cancelUndeliveredBody(response), ) } catch (error) { cancelUndeliveredBody(response) throw error } } return response } }