UNPKG

@fedify/fedify

Version:

An ActivityPub server framework

146 lines (145 loc) 5.25 kB
import "@js-temporal/polyfill"; import "urlpattern-polyfill"; globalThis.addEventListener = () => {}; import { getLogger } from "@logtape/logtape"; import { Item, decodeDict, encodeDict } from "structured-field-values"; //#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 export { validateAcceptSignature as i, fulfillAcceptSignature as n, parseAcceptSignature as r, formatAcceptSignature as t };