@fedify/fedify
Version:
An ActivityPub server framework
146 lines (145 loc) • 5.25 kB
JavaScript
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 };