@fedify/fedify
Version:
An ActivityPub server framework
1,340 lines • 57.7 kB
JavaScript
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