UNPKG

@fedify/fedify

Version:

An ActivityPub server framework

1,340 lines 57.7 kB
import { Temporal } from "@js-temporal/polyfill"; import { URLPattern } from "urlpattern-polyfill"; import { getLogger } from "@logtape/logtape"; import { CryptographicKey, Object as Object$1, isActor } from "@fedify/vocab"; import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api"; import { encodeHex } from "byte-encodings/hex"; import { Item, decodeDict, encodeDict, encodeItem } from "structured-field-values"; import { FetchError, getDocumentLoader } from "@fedify/vocab-runtime"; import { ATTR_HTTP_REQUEST_HEADER, ATTR_HTTP_REQUEST_METHOD, ATTR_URL_FULL } from "@opentelemetry/semantic-conventions"; import { decodeBase64, encodeBase64 } from "byte-encodings/base64"; //#region deno.json var name = "@fedify/fedify"; var version = "2.2.3"; //#endregion //#region src/sig/accept.ts /** * `Accept-Signature` header parsing, serialization, and validation utilities * for RFC 9421 §5 challenge-response negotiation. * * @module */ /** * Parses an `Accept-Signature` header value (RFC 9421 §5.1) into an * array of {@link AcceptSignatureMember} objects. * * The `Accept-Signature` field is a Dictionary Structured Field * (RFC 8941 §3.2). Each dictionary member describes a single * requested message signature. * * On parse failure (malformed or empty header), returns an empty array. * * @param header The raw `Accept-Signature` header value string. * @returns An array of parsed members. Empty if the header is * malformed or empty. * @since 2.1.0 */ function parseAcceptSignature(header) { try { return parseEachSignature(decodeDict(header)); } catch { getLogger([ "fedify", "sig", "http" ]).warn("Failed to parse Accept-Signature header: {header}", { header }); return []; } } const compactObject = (obj) => Object.fromEntries(Object.entries(obj).filter(([_, v]) => v !== void 0)); const parseEachSignature = (dict) => Object.entries(dict).filter(([_, item]) => Array.isArray(item.value)).map(([label, item]) => ({ label, components: item.value.filter((subitem) => typeof subitem.value === "string").map((subitem) => ({ value: subitem.value, params: subitem.params ?? {} })), parameters: compactParams(item) })); const compactParams = (item) => { const { keyid, alg, created, expires, nonce, tag } = item.params ?? {}; return compactObject({ keyid: stringOrUndefined(keyid), alg: stringOrUndefined(alg), created: trueOrUndefined(created), expires: trueOrUndefined(expires), nonce: stringOrUndefined(nonce), tag: stringOrUndefined(tag) }); }; const stringOrUndefined = (v) => typeof v === "string" ? v : void 0; const trueOrUndefined = (v) => v === true ? true : void 0; /** * Serializes an array of {@link AcceptSignatureMember} objects into an * `Accept-Signature` header value string (RFC 9421 §5.1). * * The output is a Dictionary Structured Field (RFC 8941 §3.2). * * @param members The members to serialize. * @returns The serialized header value string. * @since 2.1.0 */ function formatAcceptSignature(members) { const items = members.map((member) => [member.label, new Item(compToItems(member), compactParameters(member))]); return encodeDict(Object.fromEntries(items)); } const compToItems = (member) => member.components.map((c) => new Item(c.value, c.params)); const compactParameters = (member) => { const { keyid, alg, created, expires, nonce, tag } = member.parameters; return compactObject({ keyid, alg, created, expires, nonce, tag }); }; /** * Filters out {@link AcceptSignatureMember} entries whose covered * components include response-only identifiers (`@status`) that are * not applicable to request-target messages, as required by * [RFC 9421 §5](https://www.rfc-editor.org/rfc/rfc9421#section-5). * * A warning is logged for each discarded entry. * * @param members The parsed `Accept-Signature` entries to validate. * @returns Only entries that are valid for request-target messages. * @since 2.1.0 */ function validateAcceptSignature(members) { const logger = getLogger([ "fedify", "sig", "http" ]); return members.filter((member) => { if (member.components.every((c) => c.value !== "@status")) return true; logLabel(logger, member.label); return false; }); } const logLabel = (logger, label) => logger.warn("Discarding Accept-Signature member {label}: covered components include response-only identifier @status.", { label }); /** * Attempts to translate an {@link AcceptSignatureMember} challenge into * RFC 9421 signing options that the local signer can fulfill. * * Returns `null` if the challenge cannot be fulfilled—for example, if * the requested `alg` or `keyid` is incompatible with the local key. * * Safety constraints: * - `alg`: only honored if it matches `localAlg`. * - `keyid`: only honored if it matches `localKeyId`. * - `components`: passed through exactly as requested, per RFC 9421 §5.2. * - `nonce`, `tag`, and `expires` are passed through directly. * * @param entry The challenge entry from the `Accept-Signature` header. * @param localKeyId The local key identifier (e.g., the actor key URL). * @param localAlg The algorithm of the local private key * (e.g., `"rsa-v1_5-sha256"`). * @returns Signing options if the challenge can be fulfilled, or `null`. * @since 2.1.0 */ function fulfillAcceptSignature(entry, localKeyId, localAlg) { if (entry.parameters.alg != null && entry.parameters.alg !== localAlg) return null; if (entry.parameters.keyid != null && entry.parameters.keyid !== localKeyId) return null; return { label: entry.label, components: entry.components, nonce: entry.parameters.nonce, tag: entry.parameters.tag, expires: entry.parameters.expires }; } //#endregion //#region src/sig/key.ts /** * Checks if the given key is valid and supported. No-op if the key is valid, * otherwise throws an error. * @param key The key to check. * @param type Which type of key to check. If not specified, the key can be * either public or private. * @throws {TypeError} If the key is invalid or unsupported. */ function validateCryptoKey(key, type) { if (type != null && key.type !== type) throw new TypeError(`The key is not a ${type} key.`); if (!key.extractable) throw new TypeError("The key is not extractable."); if (key.algorithm.name !== "RSASSA-PKCS1-v1_5" && key.algorithm.name !== "Ed25519") throw new TypeError("Currently only RSASSA-PKCS1-v1_5 and Ed25519 keys are supported. More algorithms will be added in the future!"); if (key.algorithm.name === "RSASSA-PKCS1-v1_5") { if (key.algorithm.hash.name !== "SHA-256") throw new TypeError("For compatibility with the existing Fediverse software (e.g., Mastodon), hash algorithm for RSASSA-PKCS1-v1_5 keys must be SHA-256."); } } /** * Generates a key pair which is appropriate for Fedify. * @param algorithm The algorithm to use. Currently only RSASSA-PKCS1-v1_5 and * Ed25519 are supported. * @returns The generated key pair. * @throws {TypeError} If the algorithm is unsupported. */ function generateCryptoKeyPair(algorithm) { if (algorithm == null) getLogger([ "fedify", "sig", "key" ]).warn("No algorithm specified. Using RSASSA-PKCS1-v1_5 by default, but it is recommended to specify the algorithm explicitly as the parameter will be required in the future."); if (algorithm == null || algorithm === "RSASSA-PKCS1-v1_5") return crypto.subtle.generateKey({ name: "RSASSA-PKCS1-v1_5", modulusLength: 4096, publicExponent: new Uint8Array([ 1, 0, 1 ]), hash: "SHA-256" }, true, ["sign", "verify"]); else if (algorithm === "Ed25519") return crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]); throw new TypeError("Unsupported algorithm: " + algorithm); } /** * Exports a key in JWK format. * @param key The key to export. Either public or private key. * @returns The exported key in JWK format. The key is suitable for * serialization and storage. * @throws {TypeError} If the key is invalid or unsupported. */ async function exportJwk(key) { validateCryptoKey(key); const jwk = await crypto.subtle.exportKey("jwk", key); if (jwk.crv === "Ed25519") jwk.alg = "Ed25519"; return jwk; } /** * Imports a key from JWK format. * @param jwk The key in JWK format. * @param type Which type of key to import, either `"public"` or `"private"`. * @returns The imported key. * @throws {TypeError} If the key is invalid or unsupported. */ async function importJwk(jwk, type) { let key; if (jwk.kty === "RSA" && jwk.alg === "RS256") key = await crypto.subtle.importKey("jwk", jwk, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, true, type === "public" ? ["verify"] : ["sign"]); else if (jwk.kty === "OKP" && jwk.crv === "Ed25519") { if (navigator?.userAgent === "Cloudflare-Workers") { jwk = { ...jwk }; delete jwk.alg; } key = await crypto.subtle.importKey("jwk", jwk, "Ed25519", true, type === "public" ? ["verify"] : ["sign"]); } else throw new TypeError("Unsupported JWK format."); validateCryptoKey(key, type); return key; } async function withFetchKeySpan(keyId, tracerProvider, fetcher) { tracerProvider ??= trace.getTracerProvider(); return await tracerProvider.getTracer(name, version).startActiveSpan("activitypub.fetch_key", { kind: SpanKind.CLIENT, attributes: { "http.method": "GET", "url.full": keyId.href, "url.scheme": keyId.protocol.replace(/:$/, ""), "url.domain": keyId.hostname, "url.path": keyId.pathname, "url.query": keyId.search.replace(/^\?/, ""), "url.fragment": keyId.hash.replace(/^#/, "") } }, async (span) => { try { const result = await fetcher(); span.setAttribute("activitypub.actor.key.cached", result.cached); return result; } catch (e) { span.setStatus({ code: SpanStatusCode.ERROR, message: String(e) }); throw e; } finally { span.end(); } }); } /** * Fetches a {@link CryptographicKey} or {@link Multikey} from the given URL. * If the given URL contains an {@link Actor} object, it tries to find * the corresponding key in the `publicKey` or `assertionMethod` property. * @template T The type of the key to fetch. Either {@link CryptographicKey} * or {@link Multikey}. * @param keyId The URL of the key. * @param cls The class of the key to fetch. Either {@link CryptographicKey} * or {@link Multikey}. * @param options Options for fetching the key. See {@link FetchKeyOptions}. * @returns The fetched key or `null` if the key is not found. * @since 1.3.0 */ function fetchKey(keyId, cls, options = {}) { keyId = typeof keyId === "string" ? new URL(keyId) : keyId; return withFetchKeySpan(keyId, options.tracerProvider, () => fetchKeyInternal(keyId, cls, options)); } /** * Fetches a {@link CryptographicKey} or {@link Multikey} from the given URL, * preserving transport-level fetch failures for callers that need to inspect * why the key could not be loaded. * * @template T The type of the key to fetch. Either {@link CryptographicKey} * or {@link Multikey}. * @param keyId The URL of the key. * @param cls The class of the key to fetch. Either {@link CryptographicKey} * or {@link Multikey}. * @param options Options for fetching the key. * @returns The fetched key, or detailed fetch failure information. * @since 2.1.0 */ async function fetchKeyDetailed(keyId, cls, options = {}) { const cacheKey = typeof keyId === "string" ? new URL(keyId) : keyId; return await withFetchKeySpan(cacheKey, options.tracerProvider, async () => { return await fetchKeyWithResult(cacheKey, cls, options, async (cacheKey, keyId, keyCache, logger) => { const fetchError = await keyCache?.getFetchError?.(cacheKey); if (fetchError != null) { logger.debug("Entry {keyId} found in cache with preserved fetch failure details.", { keyId }); return { key: null, cached: true, fetchError }; } logger.debug("Entry {keyId} found in cache, but no fetch failure details are available.", { keyId }); return { key: null, cached: true }; }, async (error, cacheKey, keyId, keyCache, logger) => { logger.debug("Failed to fetch key {keyId}.", { keyId, error }); await keyCache?.set(cacheKey, null); if (error instanceof FetchError && error.response != null) { const fetchError = { status: error.response.status, response: error.response.clone() }; await keyCache?.setFetchError?.(cacheKey, fetchError); return { key: null, cached: false, fetchError }; } const fetchError = { error: error instanceof Error ? error : new Error(String(error)) }; await keyCache?.setFetchError?.(cacheKey, fetchError); return { key: null, cached: false, fetchError }; }); }); } async function getCachedFetchKey(cacheKey, keyId, cls, keyCache, logger) { if (keyCache == null) return null; const cachedKey = await keyCache.get(cacheKey); if (cachedKey instanceof cls && cachedKey.publicKey != null) { logger.debug("Key {keyId} found in cache.", { keyId }); return { key: cachedKey, cached: true }; } else if (cachedKey === null) { logger.debug("Entry {keyId} found in cache, but it is unavailable.", { keyId }); return { key: null, cached: true }; } return null; } async function clearFetchErrorMetadata(keyId, keyCache) { await keyCache?.setFetchError?.(keyId, null); } async function resolveFetchedKey(document, cacheKey, keyId, cls, { documentLoader, contextLoader, keyCache, tracerProvider }, logger) { let object; try { object = await Object$1.fromJsonLd(document, { documentLoader, contextLoader, tracerProvider }); } catch (e) { if (!(e instanceof TypeError)) throw e; try { object = await cls.fromJsonLd(document, { documentLoader, contextLoader, tracerProvider }); } catch (e) { if (e instanceof TypeError) { logger.debug("Failed to verify; key {keyId} returned an invalid object.", { keyId }); await keyCache?.set(cacheKey, null); await clearFetchErrorMetadata(cacheKey, keyCache); return { key: null, cached: false }; } throw e; } } let key = null; if (object instanceof cls) key = object; else if (isActor(object)) { const keys = cls === CryptographicKey ? object.getPublicKeys({ documentLoader, contextLoader, tracerProvider }) : object.getAssertionMethods({ documentLoader, contextLoader, tracerProvider }); let length = 0; let lastKey = null; for await (const k of keys) { length++; lastKey = k; if (k.id?.href === keyId) { key = k; break; } } const keyIdUrl = new URL(keyId); if (key == null && keyIdUrl.hash === "" && length === 1) key = lastKey; if (key == null) { logger.debug("Failed to verify; object {keyId} returned an {actorType}, but has no key matching {keyId}.", { keyId, actorType: object.constructor.name }); await keyCache?.set(cacheKey, null); await clearFetchErrorMetadata(cacheKey, keyCache); return { key: null, cached: false }; } } else { logger.debug("Failed to verify; key {keyId} returned an invalid object.", { keyId }); await keyCache?.set(cacheKey, null); await clearFetchErrorMetadata(cacheKey, keyCache); return { key: null, cached: false }; } if (key.publicKey == null) { logger.debug("Failed to verify; key {keyId} has no publicKeyPem field.", { keyId }); await keyCache?.set(cacheKey, null); await clearFetchErrorMetadata(cacheKey, keyCache); return { key: null, cached: false }; } if (keyCache != null) { await keyCache.set(cacheKey, key); logger.debug("Key {keyId} cached.", { keyId }); } await clearFetchErrorMetadata(cacheKey, keyCache); return { key, cached: false }; } async function fetchKeyWithResult(cacheKey, cls, options, onCachedUnavailable, onFetchError) { const logger = getLogger([ "fedify", "sig", "key" ]); const keyId = cacheKey.href; const keyCache = options.keyCache; const cached = await getCachedFetchKey(cacheKey, keyId, cls, keyCache, logger); if (cached?.key === null && cached.cached) return await onCachedUnavailable(cacheKey, keyId, keyCache, logger); if (cached != null) return cached; logger.debug("Fetching key {keyId} to verify signature...", { keyId }); let document; try { document = (await (options.documentLoader ?? getDocumentLoader())(keyId)).document; } catch (error) { return await onFetchError(error, cacheKey, keyId, keyCache, logger); } return await resolveFetchedKey(document, cacheKey, keyId, cls, options, logger); } async function fetchKeyInternal(keyId, cls, options = {}) { return await fetchKeyWithResult(typeof keyId === "string" ? new URL(keyId) : keyId, cls, options, (_cacheKey, _keyId, _keyCache, _logger) => { return { key: null, cached: true }; }, async (error, cacheKey, keyId, keyCache, logger) => { logger.debug("Failed to fetch key {keyId}.", { keyId, error }); await keyCache?.set(cacheKey, null); if (error instanceof FetchError && error.response != null) await keyCache?.setFetchError?.(cacheKey, { status: error.response.status, response: error.response.clone() }); else await keyCache?.setFetchError?.(cacheKey, { error: error instanceof Error ? error : new Error(String(error)) }); return { key: null, cached: false }; }); } //#endregion //#region src/sig/http.ts const DEFAULT_MAX_REDIRECTION = 20; const DOUBLE_KNOCK_TRANSPORT_RETRY_DELAY_MS = 100; /** * Signs a request using the given private key. * @param request The request to sign. * @param privateKey The private key to use for signing. * @param keyId The key ID to use for the signature. It will be used by the * verifier. * @returns The signed request. * @throws {TypeError} If the private key is invalid or unsupported. */ async function signRequest(request, privateKey, keyId, options = {}) { validateCryptoKey(privateKey, "private"); return await (options.tracerProvider ?? trace.getTracerProvider()).getTracer(name, version).startActiveSpan("http_signatures.sign", async (span) => { try { const spec = options.spec ?? "draft-cavage-http-signatures-12"; let signed; if (spec === "rfc9421") signed = await signRequestRfc9421(request, privateKey, keyId, span, options.currentTime, options.body, options.rfc9421); else signed = await signRequestDraft(request, privateKey, keyId, span, options.currentTime, options.body); if (span.isRecording()) { span.setAttribute(ATTR_HTTP_REQUEST_METHOD, signed.method); span.setAttribute(ATTR_URL_FULL, signed.url); for (const [name, value] of signed.headers) span.setAttribute(ATTR_HTTP_REQUEST_HEADER(name), value); span.setAttribute("http_signatures.key_id", keyId.href); } return signed; } catch (error) { span.setStatus({ code: SpanStatusCode.ERROR, message: String(error) }); throw error; } finally { span.end(); } }); } async function signRequestDraft(request, privateKey, keyId, span, currentTime, bodyBuffer) { if (privateKey.algorithm.name !== "RSASSA-PKCS1-v1_5") throw new TypeError("Unsupported algorithm: " + privateKey.algorithm.name); const url = new URL(request.url); const body = bodyBuffer !== void 0 ? bodyBuffer : request.method !== "GET" && request.method !== "HEAD" ? await request.clone().arrayBuffer() : null; const headers = new Headers(request.headers); if (!headers.has("Host")) headers.set("Host", url.host); if (!headers.has("Digest") && body != null) { const digest = await crypto.subtle.digest("SHA-256", body); headers.set("Digest", `SHA-256=${encodeBase64(digest)}`); if (span.isRecording()) span.setAttribute("http_signatures.digest.sha-256", encodeHex(digest)); } if (!headers.has("Date")) headers.set("Date", currentTime == null ? (/* @__PURE__ */ new Date()).toUTCString() : new Date(currentTime.toString()).toUTCString()); const serialized = [["(request-target)", `${request.method.toLowerCase()} ${url.pathname}`], ...headers]; const headerNames = serialized.map(([name]) => name); const message = serialized.map(([name, value]) => `${name}: ${value.trim()}`).join("\n"); const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", privateKey, new TextEncoder().encode(message)); const sigHeader = `keyId="${keyId.href}",algorithm="rsa-sha256",headers="${headerNames.join(" ")}",signature="${encodeBase64(signature)}"`; headers.set("Signature", sigHeader); if (span.isRecording()) { span.setAttribute("http_signatures.algorithm", "rsa-sha256"); span.setAttribute("http_signatures.signature", encodeHex(signature)); } return new Request(request, { headers, body }); } function formatRfc9421SignatureParameters(params) { return Array.from(iterRfc9421(params)).join(";"); } function* iterRfc9421(params) { yield `alg="${params.algorithm}"`; yield `keyid="${params.keyId.href}"`; yield `created=${params.created}`; if (params.expires != null) yield `expires=${params.expires}`; if (params.nonce != null) yield `nonce="${escapeSfString(params.nonce)}"`; if (params.tag != null) yield `tag="${escapeSfString(params.tag)}"`; } const escapeSfString = (value) => value.replace(/\\/g, "\\\\").replace(/"/g, "\\\""); function formatComponentId(component) { return encodeItem(new Item(component.value, component.params)); } /** * Creates a signature base for a request according to RFC 9421. * @param request The request to create a signature base for. * @param components The components to include in the signature base. * @param parameters The signature parameters to include in the signature base. * @returns The signature base as a string. */ function createRfc9421SignatureBase(request, components, parameters) { const url = new URL(request.url); return components.map((component) => { const id = formatComponentId(component); const derived = derivedComponents[component.value]?.(request, url); if (derived != null) return `${id}: ${derived}`; if (component.value.startsWith("@")) throw new Error(`Unsupported derived component: ${component.value}`); const header = request.headers.get(component.value); if (header == null) throw new Error(`Missing header: ${component.value}`); return `${id}: ${header}`; }).concat([`"@signature-params": (${components.map((c) => formatComponentId(c)).join(" ")});${parameters}`]).join("\n"); } const derivedComponents = { "@method": (request) => request.method.toUpperCase(), "@target-uri": (_, url) => url.href, "@authority": (_, url) => url.host, "@scheme": (_, url) => url.protocol.slice(0, -1), "@request-target": (request, url) => `${request.method.toLowerCase()} ${url.pathname}${url.search}`, "@path": (_, url) => url.pathname, "@query": (_, { search }) => search.startsWith("?") ? search.slice(1) : search, "@query-param": () => { throw new Error("@query-param requires a parameter name"); }, "@status": () => { throw new Error("@status is only valid for responses"); } }; /** * Formats a signature using rfc9421 format. * @param signature The raw signature bytes. * @param components The components that were signed. * @param parameters The signature parameters. * @returns The formatted signature string. */ function formatRfc9421Signature(signature, components, parameters, label = "sig1") { return [`${label}=(${components.map((c) => formatComponentId(c)).join(" ")});${parameters}`, `${label}=:${encodeBase64(signature)}:`]; } /** * Parse RFC 9421 Signature-Input header. * @param signatureInput The Signature-Input header value. * @returns Parsed signature input parameters. */ function parseRfc9421SignatureInput(signatureInput) { let dict; try { dict = decodeDict(signatureInput); } catch (error) { getLogger([ "fedify", "sig", "http" ]).debug("Failed to parse Signature-Input header: {signatureInput}", { signatureInput, error }); return {}; } const result = {}; for (const [label, item] of Object.entries(dict)) { if (!Array.isArray(item.value) || typeof item.params.keyid !== "string" || typeof item.params.created !== "number") continue; const components = item.value.filter((subitem) => typeof subitem.value === "string").map((subitem) => ({ value: subitem.value, params: subitem.params ?? {} })); const params = encodeItem(new Item(0, item.params)); result[label] = { keyId: item.params.keyid, alg: item.params.alg, created: item.params.created, nonce: typeof item.params.nonce === "string" ? item.params.nonce : void 0, tag: typeof item.params.tag === "string" ? item.params.tag : void 0, components, parameters: params.slice(params.indexOf(";") + 1) }; } return result; } /** * Parse RFC 9421 Signature header. * @param signature The Signature header value. * @returns Parsed signature values. */ function parseRfc9421Signature(signature) { let dict; try { dict = decodeDict(signature); } catch (error) { getLogger([ "fedify", "sig", "http" ]).debug("Failed to parse Signature header: {signature}", { signature, error }); return {}; } const result = {}; for (const [key, value] of Object.entries(dict)) if (value.value instanceof Uint8Array) result[key] = value.value; return result; } async function signRequestRfc9421(request, privateKey, keyId, span, currentTime, bodyBuffer, rfc9421Options) { if (privateKey.algorithm.name !== "RSASSA-PKCS1-v1_5") throw new TypeError("Unsupported algorithm: " + privateKey.algorithm.name); const url = new URL(request.url); const body = bodyBuffer !== void 0 ? bodyBuffer : request.method !== "GET" && request.method !== "HEAD" ? await request.clone().arrayBuffer() : null; const headers = new Headers(request.headers); if (!headers.has("Host")) headers.set("Host", url.host); if (!headers.has("Content-Digest") && body != null) { const digest = await crypto.subtle.digest("SHA-256", body); headers.set("Content-Digest", `sha-256=:${encodeBase64(digest)}:`); if (span.isRecording()) span.setAttribute("http_signatures.digest.sha-256", encodeHex(digest)); } currentTime ??= Temporal.Now.instant(); const created = currentTime.epochMilliseconds / 1e3 | 0; if (!headers.has("Date")) headers.set("Date", new Date(currentTime.toString()).toUTCString()); const label = rfc9421Options?.label ?? "sig1"; const components = [...rfc9421Options?.components ?? [ { value: "@method", params: {} }, { value: "@target-uri", params: {} }, { value: "@authority", params: {} }, { value: "host", params: {} }, { value: "date", params: {} } ], ...body != null ? [{ value: "content-digest", params: {} }] : []]; const signatureParams = formatRfc9421SignatureParameters({ algorithm: "rsa-v1_5-sha256", keyId, created, expires: rfc9421Options?.expires === true ? (currentTime.epochMilliseconds / 1e3 | 0) + 3600 : void 0, nonce: rfc9421Options?.nonce, tag: rfc9421Options?.tag }); let signatureBase; try { signatureBase = createRfc9421SignatureBase(new Request(request.url, { method: request.method, headers }), components, signatureParams); } catch (error) { throw new TypeError(`Failed to create signature base: ${String(error)}; it is probably a bug in the implementation. Please report it at Fedify's issue tracker.`); } const signatureBytes = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", privateKey, new TextEncoder().encode(signatureBase)); const [signatureInput, signature] = formatRfc9421Signature(signatureBytes, components, signatureParams, label); const existingInput = headers.get("Signature-Input"); headers.set("Signature-Input", existingInput != null ? `${existingInput}, ${signatureInput}` : signatureInput); const existingSignature = headers.get("Signature"); headers.set("Signature", existingSignature != null ? `${existingSignature}, ${signature}` : signature); if (span.isRecording()) { span.setAttribute("http_signatures.algorithm", "rsa-v1_5-sha256"); span.setAttribute("http_signatures.signature", encodeHex(signatureBytes)); span.setAttribute("http_signatures.created", created.toString()); } return new Request(request, { headers, body }); } const supportedHashAlgorithms = { "sha": "SHA-1", "sha-256": "SHA-256", "sha-512": "SHA-512" }; function noSignatureResult() { return { verified: false, reason: { type: "noSignature" } }; } function invalidSignatureResult(keyId) { return keyId == null ? { verified: false, reason: { type: "invalidSignature" } } : { verified: false, reason: { type: "invalidSignature", keyId } }; } function keyFetchErrorResult(keyId, result) { return { verified: false, reason: { type: "keyFetchError", keyId, result } }; } function parseKeyId(value) { if (value == null) return null; try { return new URL(value); } catch { return null; } } function getKeyFetchErrorName(error) { return error.name || error.constructor.name || "Error"; } function recordVerificationResult(span, result) { span.setAttribute("http_signatures.verified", result.verified); if (result.verified === true) return; const reason = result.reason; span.setAttribute("http_signatures.failure_reason", reason.type); if ("keyId" in reason && reason.keyId != null) span.setAttribute("http_signatures.key_id", reason.keyId.href); if (reason.type !== "keyFetchError") return; if ("status" in reason.result) span.setAttribute("http_signatures.key_fetch_status", reason.result.status); else span.setAttribute("http_signatures.key_fetch_error", getKeyFetchErrorName(reason.result.error)); } /** * Verifies the signature of a request. * * Note that this function consumes the request body, so it should not be used * if the request body is already consumed. Consuming the request body after * calling this function is okay, since this function clones the request * under the hood. * * @param request The request to verify. * @param options Options for verifying the request. * @returns The public key of the verified signature, or `null` if the signature * could not be verified. */ async function verifyRequest(request, options = {}) { const result = await verifyRequestDetailed(request, options); return result.verified ? result.key : null; } /** * Verifies the signature of a request and returns a structured failure reason * when verification does not succeed. * * @param request The request to verify. * @param options Options for verifying the request. * @returns The verified public key, or a structured verification failure. * @since 2.1.0 */ async function verifyRequestDetailed(request, options = {}) { return await (options.tracerProvider ?? trace.getTracerProvider()).getTracer(name, version).startActiveSpan("http_signatures.verify", async (span) => { if (span.isRecording()) { span.setAttribute(ATTR_HTTP_REQUEST_METHOD, request.method); span.setAttribute(ATTR_URL_FULL, request.url); for (const [name, value] of request.headers) span.setAttribute(ATTR_HTTP_REQUEST_HEADER(name), value); } try { let spec = options.spec; if (spec == null) spec = request.headers.has("Signature-Input") ? "rfc9421" : "draft-cavage-http-signatures-12"; let result; if (spec === "rfc9421") result = await verifyRequestRfc9421(request, span, options); else result = await verifyRequestDraft(request, span, options); recordVerificationResult(span, result); if (!result.verified) span.setStatus({ code: SpanStatusCode.ERROR }); return result; } catch (error) { span.setStatus({ code: SpanStatusCode.ERROR, message: String(error) }); throw error; } finally { span.end(); } }); } async function verifyRequestDraft(request, span, { documentLoader, contextLoader, timeWindow, currentTime, keyCache, tracerProvider } = {}) { const logger = getLogger([ "fedify", "sig", "http" ]); if (request.bodyUsed) { logger.error("Failed to verify; the request body is already consumed.", { url: request.url }); return noSignatureResult(); } else if (request.body?.locked) { logger.error("Failed to verify; the request body is locked.", { url: request.url }); return noSignatureResult(); } const originalRequest = request; request = request.clone(); const sigHeader = request.headers.get("Signature"); if (sigHeader == null) { logger.debug("Failed to verify; no Signature header found.", { headers: Object.fromEntries(request.headers.entries()) }); return noSignatureResult(); } const sigValues = Object.fromEntries(sigHeader.split(",").map((pair) => pair.match(/^\s*([A-Za-z]+)=(?:"([^"]*)"|(\d+))\s*$/)).filter((m) => m != null).map((m) => [m[1], m[2] ?? m[3]])); const parsedKeyId = parseKeyId(sigValues.keyId); const dateHeader = request.headers.get("Date"); if (dateHeader == null) { logger.debug("Failed to verify; no Date header found.", { headers: Object.fromEntries(request.headers.entries()) }); return invalidSignatureResult(parsedKeyId); } const digestHeader = request.headers.get("Digest"); if (request.method !== "GET" && request.method !== "HEAD" && digestHeader == null) { logger.debug("Failed to verify; no Digest header found.", { headers: Object.fromEntries(request.headers.entries()) }); return invalidSignatureResult(parsedKeyId); } let body = null; if (digestHeader != null) { body = await request.arrayBuffer(); const digests = digestHeader.split(",").map((pair) => pair.includes("=") ? pair.split("=", 2) : [pair, ""]); let matched = false; for (let [algo, digestBase64] of digests) { algo = algo.trim().toLowerCase(); if (!(algo in supportedHashAlgorithms)) continue; let digest; try { digest = decodeBase64(digestBase64); } catch (error) { logger.debug("Failed to verify; invalid base64 encoding: {digest}.", { digest: digestBase64, error }); return invalidSignatureResult(parsedKeyId); } if (span.isRecording()) span.setAttribute(`http_signatures.digest.${algo}`, encodeHex(digest)); const expectedDigest = await crypto.subtle.digest(supportedHashAlgorithms[algo], body); if (!timingSafeEqual(digest, new Uint8Array(expectedDigest))) { logger.debug("Failed to verify; digest mismatch ({algorithm}): {digest} != {expectedDigest}.", { algorithm: algo, digest: digestBase64, expectedDigest: encodeBase64(expectedDigest) }); return invalidSignatureResult(parsedKeyId); } matched = true; } if (!matched) { logger.debug("Failed to verify; no supported digest algorithm found. Supported: {supportedAlgorithms}; found: {algorithms}.", { supportedAlgorithms: Object.keys(supportedHashAlgorithms), algorithms: digests.map(([algo]) => algo) }); return invalidSignatureResult(parsedKeyId); } } const date = Temporal.Instant.from(new Date(dateHeader).toISOString()); const now = currentTime ?? Temporal.Now.instant(); if (timeWindow !== false) { const tw = timeWindow ?? { hours: 1 }; if (Temporal.Instant.compare(date, now.add(tw)) > 0) { logger.debug("Failed to verify; Date is too far in the future.", { date: date.toString(), now: now.toString() }); return invalidSignatureResult(parsedKeyId); } else if (Temporal.Instant.compare(date, now.subtract(tw)) < 0) { logger.debug("Failed to verify; Date is too far in the past.", { date: date.toString(), now: now.toString() }); return invalidSignatureResult(parsedKeyId); } } if (!("keyId" in sigValues)) { logger.debug("Failed to verify; no keyId field found in the Signature header.", { signature: sigHeader }); return invalidSignatureResult(null); } else if (!("headers" in sigValues)) { logger.debug("Failed to verify; no headers field found in the Signature header.", { signature: sigHeader }); return invalidSignatureResult(parsedKeyId); } else if (!("signature" in sigValues)) { logger.debug("Failed to verify; no signature field found in the Signature header.", { signature: sigHeader }); return invalidSignatureResult(parsedKeyId); } if ("expires" in sigValues) { const expiresSeconds = parseInt(sigValues.expires); if (!Number.isInteger(expiresSeconds)) { logger.debug("Failed to verify; invalid expires field in the Signature header: {expires}.", { expires: sigValues.expires, signature: sigHeader }); return invalidSignatureResult(parsedKeyId); } const expires = Temporal.Instant.fromEpochMilliseconds(expiresSeconds * 1e3); if (Temporal.Instant.compare(now, expires) > 0) { logger.debug("Failed to verify; signature expired at {expires} (now: {now}).", { expires: expires.toString(), now: now.toString(), signature: sigHeader }); return invalidSignatureResult(parsedKeyId); } } if ("created" in sigValues) { const createdSeconds = parseInt(sigValues.created); if (!Number.isInteger(createdSeconds)) { logger.debug("Failed to verify; invalid created field in the Signature header: {created}.", { created: sigValues.created, signature: sigHeader }); return invalidSignatureResult(parsedKeyId); } if (timeWindow !== false) { const created = Temporal.Instant.fromEpochMilliseconds(createdSeconds * 1e3); const tw = timeWindow ?? { minutes: 1 }; if (Temporal.Instant.compare(created, now.add(tw)) > 0) { logger.debug("Failed to verify; created is too far in the future.", { created: created.toString(), now: now.toString() }); return invalidSignatureResult(parsedKeyId); } else if (Temporal.Instant.compare(created, now.subtract(tw)) < 0) { logger.debug("Failed to verify; created is too far in the past.", { created: created.toString(), now: now.toString() }); return invalidSignatureResult(parsedKeyId); } } } const { keyId, headers, signature } = sigValues; const keyIdUrl = parseKeyId(keyId); if (keyIdUrl == null) return invalidSignatureResult(null); span?.setAttribute("http_signatures.key_id", keyId); if ("algorithm" in sigValues) span?.setAttribute("http_signatures.algorithm", sigValues.algorithm); const { key, cached, fetchError } = await fetchKeyDetailed(keyIdUrl, CryptographicKey, { documentLoader, contextLoader, keyCache, tracerProvider }); if (fetchError != null) return keyFetchErrorResult(keyIdUrl, fetchError); if (key == null) return invalidSignatureResult(keyIdUrl); const headerNames = headers.split(/\s+/g); if (!headerNames.includes("(request-target)") || !headerNames.includes("date")) { logger.debug("Failed to verify; required headers missing in the Signature header: {headers}.", { headers }); return invalidSignatureResult(keyIdUrl); } if (body != null && !headerNames.includes("digest")) { logger.debug("Failed to verify; required headers missing in the Signature header: {headers}.", { headers }); return invalidSignatureResult(keyIdUrl); } const message = headerNames.map((name) => `${name}: ` + (name === "(request-target)" ? `${request.method.toLowerCase()} ${new URL(request.url).pathname}` : name === "(created)" ? sigValues.created ?? "" : name === "(expires)" ? sigValues.expires ?? "" : name === "host" ? request.headers.get("host") ?? new URL(request.url).host : request.headers.get(name))).join("\n"); const sig = decodeBase64(signature); span?.setAttribute("http_signatures.signature", encodeHex(sig)); if (!await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key.publicKey, sig, new TextEncoder().encode(message))) { if (cached) { logger.debug("Failed to verify with the cached key {keyId}; signature {signature} is invalid. Retrying with the freshly fetched key...", { keyId, signature, message }); return await verifyRequestDetailed(originalRequest, { documentLoader, contextLoader, timeWindow, currentTime, keyCache: { get: () => Promise.resolve(void 0), set: async (keyId, key) => await keyCache?.set(keyId, key) } }); } logger.debug("Failed to verify with the fetched key {keyId}; signature {signature} is invalid. Check if the key is correct or if the signed message is correct. The message to sign is:\n{message}", { keyId, signature, message }); return invalidSignatureResult(keyIdUrl); } return { verified: true, key }; } /** * RFC 9421 map of algorithm identifiers to WebCrypto algorithms */ const rfc9421AlgorithmMap = { "rsa-v1_5-sha256": { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, "rsa-v1_5-sha512": { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" }, "rsa-pss-sha512": { name: "RSA-PSS", hash: "SHA-512" }, "ecdsa-p256-sha256": { name: "ECDSA", hash: "SHA-256" }, "ecdsa-p384-sha384": { name: "ECDSA", hash: "SHA-384" }, "ed25519": { name: "Ed25519" } }; /** * Verifies a Content-Digest header according to RFC 9421. * @param digestHeader The Content-Digest header value. * @param body The message body to verify against. * @returns Whether the digest is valid. */ async function verifyRfc9421ContentDigest(digestHeader, body) { const digests = digestHeader.split(",").map((pair) => { pair = pair.trim(); const pos = pair.indexOf("="); const algo = pos < 0 ? pair : pair.slice(0, pos); const value = pos < 0 ? "" : pair.slice(pos + 1); return { algo: algo.trim().toLowerCase(), value: value.trim() }; }); for (const { algo, value } of digests) { let hashAlgo; if (algo === "sha-256") hashAlgo = "SHA-256"; else if (algo === "sha-512") hashAlgo = "SHA-512"; else continue; const base64Match = value.match(/^:([^:]+):$/); if (!base64Match) continue; let digest; try { digest = decodeBase64(base64Match[1]); } catch { continue; } const calculatedDigest = await crypto.subtle.digest(hashAlgo, body); if (timingSafeEqual(digest, new Uint8Array(calculatedDigest))) return true; } return false; } async function verifyRequestRfc9421(request, span, { documentLoader, contextLoader, timeWindow, currentTime, keyCache, tracerProvider } = {}) { const logger = getLogger([ "fedify", "sig", "http" ]); if (request.bodyUsed) { logger.error("Failed to verify; the request body is already consumed.", { url: request.url }); return noSignatureResult(); } else if (request.body?.locked) { logger.error("Failed to verify; the request body is locked.", { url: request.url }); return noSignatureResult(); } const originalRequest = request; request = request.clone(); const signatureInputHeader = request.headers.get("Signature-Input"); if (!signatureInputHeader) { logger.debug("Failed to verify; no Signature-Input header found.", { headers: Object.fromEntries(request.headers.entries()) }); return noSignatureResult(); } const signatureHeader = request.headers.get("Signature"); if (!signatureHeader) { logger.debug("Failed to verify; no Signature header found.", { headers: Object.fromEntries(request.headers.entries()) }); return noSignatureResult(); } const signatureInputs = parseRfc9421SignatureInput(signatureInputHeader); logger.debug("Parsed Signature-Input header: {signatureInputs}", { signatureInputs }); const signatures = parseRfc9421Signature(signatureHeader); const signatureNames = Object.keys(signatureInputs); if (signatureNames.length === 0) { logger.debug("Failed to verify; no valid signatures found in Signature-Input header.", { header: signatureInputHeader }); return invalidSignatureResult(null); } let failure = noSignatureResult(); for (const sigName of signatureNames) { if (!signatures[sigName]) { failure = invalidSignatureResult(parseKeyId(signatureInputs[sigName]?.keyId)); continue; } const sigInput = signatureInputs[sigName]; const sigBytes = signatures[sigName]; const keyId = parseKeyId(sigInput.keyId); if (!sigInput.keyId) { logger.debug("Failed to verify; missing keyId in signature {signatureName}.", { signatureName: sigName, signatureInput: signatureInputHeader }); failure = invalidSignatureResult(null); continue; } if (!sigInput.created) { logger.debug("Failed to verify; missing created timestamp in signature {signatureName}.", { signatureName: sigName, signatureInput: signatureInputHeader }); failure = invalidSignatureResult(keyId); continue; } const signatureCreated = Temporal.Instant.fromEpochMilliseconds(sigInput.created * 1e3); const now = currentTime ?? Temporal.Now.instant(); if (timeWindow !== false) { const tw = timeWindow ?? { hours: 1 }; if (Temporal.Instant.compare(signatureCreated, now.add(tw)) > 0) { logger.debug("Failed to verify; signature created time is too far in the future.", { created: signatureCreated.toString(), now: now.toString() }); failure = invalidSignatureResult(keyId); continue; } else if (Temporal.Instant.compare(signatureCreated, now.subtract(tw)) < 0) { logger.debug("Failed to verify; signature created time is too far in the past.", { created: signatureCreated.toString(), now: now.toString() }); failure = invalidSignatureResult(keyId); continue; } } if (request.method !== "GET" && request.method !== "HEAD" && sigInput.components.some((c) => c.value === "content-digest")) { const contentDigestHeader = request.headers.get("Content-Digest"); if (!contentDigestHeader) { logger.debug("Failed to verify; Content-Digest header required but not found.", { components: sigInput.components }); failure = invalidSignatureResult(keyId); continue; } if (!await verifyRfc9421ContentDigest(contentDigestHeader, await request.arrayBuffer())) { logger.debug("Failed to verify; Content-Digest verification failed.", { contentDigest: contentDigestHeader }); failure = invalidSignatureResult(keyId); continue; } } span?.setAttribute("http_signatures.key_id", sigInput.keyId); span?.setAttribute("http_signatures.created", sigInput.created.toString()); if (keyId == null) { failure = invalidSignatureResult(null); continue; } const { key, cached, fetchError } = await fetchKeyDetailed(keyId, CryptographicKey, { documentLoader, contextLoader, keyCache, tracerProvider }); if (fetchError != null) { failure = keyFetchErrorResult(keyId, fetchError); continue; } if (!key) { logger.debug("Failed to fetch key: {keyId}", { keyId: sigInput.keyId }); failure = invalidSignatureResult(keyId); continue; } let alg = sigInput.alg?.toLowerCase(); if (alg == null) { if (key.publicKey.algorithm.name === "RSASSA-PKCS1-v1_5") alg = "hash" in key.publicKey.algorithm ? key.publicKey.algorithm.hash === "SHA-512" ? "rsa-v1_5-sha512" : "rsa-v1_5-sha256" : "rsa-v1_5-sha256"; else if (key.publicKey.algorithm.name === "RSA-PSS") alg = "rsa-pss-sha512"; else if (key.publicKey.algorithm.name === "ECDSA") alg = "namedCurve" in key.publicKey.algorithm && key.publicKey.algorithm.namedCurve === "P-256" ? "ecdsa-p256-sha256" : "ecdsa-p384-sha384"; else if (key.publicKey.algorithm.name === "Ed25519") alg = "ed25519"; } if (alg) span?.setAttribute("http_signatures.algorithm", alg); const algorithm = alg && rfc9421AlgorithmMap[alg]; if (!algorithm) { logger.debug("Failed to verify; unsupported algorithm: {algorithm}", { algorithm: sigInput.alg, supported: Object.keys(rfc9421AlgorithmMap) }); failure = invalidSignatureResult(keyId); continue; } let signatureBase; try { signatureBase = createRfc9421SignatureBase(request, sigInput.components, sigInput.parameters); } catch (error) { logger.debug("Failed to create signature base for verification: {error}", { error, signatureInput: sigInput }); failure = invalidSignatureResult(keyId); continue; } const signatureBaseBytes = new TextEncoder().encode(signatureBase); span?.setAttribute("http_signatures.signature", encodeHex(sigBytes)); try { if (await crypto.subtle.verify(algorithm, key.publicKey, sigBytes.slice(), signatureBaseBytes)) return { verified: true, key, signatureLabel: sigName }; else if (cached) { logger.debug("Failed to verify with cached key {keyId}; retrying with fresh key...", { keyId: sigInput.keyId }); return await verifyRequestDetailed(originalRequest, { documentLoader, contextLoader, timeWindow, currentTime, keyCache: { get: () => Promise.resolve(void 0), set: async (keyId, key) => await keyCache?.set(keyId, key) }, spec: "rfc9421" }); } else { logger.debug("Failed to verify signature with fetched key {keyId}; signature invalid.", { keyId: sigInput.keyId, signatureBase }); failure = invalidSignatureResult(keyId); } } catch (error) { logger.debug("Error during signature verification: {error}", { error, keyId: sigInput.keyId, algorithm: sigInput.alg }); failure = invalidSignatureResult(keyId); } } return failure; } /** * Helper function to create a new Request for redirect handling. * @param request The original request. * @param location The redirect location. * @param body The request body as ArrayBuffer or null. * @returns A new Request object for the redirect. */ function createRedirectRequest(request, location, body) { const url = new URL(location, request.url); return new Request(url, { method: request.method, headers: request.headers, body, redirect: "manual", signal: request.signal, mode: request.mode, credentials: request.credentials, referrer: request.referrer, referrerPolicy: request.referrerPolicy, integrity: request.integrity, keepalive: request.keepalive, cache: request.cache }); } async function fetchDoubleKnockRequest(request, signedRequest, signal) { const maxAttempts = request.method === "GET" || request.method === "HEAD" ? 2 : 1; for (let attempt = 1;; attempt++) try { return await fetch(signedRequest, { redirect: "manual", signal }); } catch (error) { const abortedSignal = getAbortedSignal(signal, request.signal, signedRequest.signal); if (abortedSignal != null) throw getAbortReason(abortedSignal); if (isAbortError(error)) throw error; if (attempt >= maxAttempts) throw createFetchError(request.url, error); await sleep(DOUBLE_KNOCK_TRANSPORT_RETRY_DELAY_MS, signal, request.signal, signedRequest.signa