@fedify/fedify
Version:
An ActivityPub server framework
1,148 lines • 61.3 kB
JavaScript
const { Temporal } = require("@js-temporal/polyfill");
const { URLPattern } = require("urlpattern-polyfill");
const require_chunk = require("./chunk-DDcVe30Y.cjs");
const require_http = require("./http-Cl0Q2bUO.cjs");
let _logtape_logtape = require("@logtape/logtape");
let _fedify_vocab = require("@fedify/vocab");
let _opentelemetry_api = require("@opentelemetry/api");
let byte_encodings_hex = require("byte-encodings/hex");
let _fedify_vocab_runtime = require("@fedify/vocab-runtime");
let byte_encodings_base64 = require("byte-encodings/base64");
let _fedify_vocab_runtime_jsonld = require("@fedify/vocab-runtime/jsonld");
_fedify_vocab_runtime_jsonld = require_chunk.__toESM(_fedify_vocab_runtime_jsonld);
let json_canon = require("json-canon");
json_canon = require_chunk.__toESM(json_canon);
//#region src/sig/ld.ts
const logger$3 = (0, _logtape_logtape.getLogger)([
"fedify",
"sig",
"ld"
]);
const localContext = [
"https://w3id.org/identity/v1",
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
"https://w3id.org/security/data-integrity/v1"
];
const localContextUrls = new Set(localContext);
const builtInContextLoader = (0, _fedify_vocab_runtime.getDocumentLoader)();
const disallowedJsonLdKeywords = new Set([
"@graph",
"@included",
"@reverse"
]);
/** @internal */
var UnsafeJsonLdError = class extends TypeError {
constructor(keyword) {
super(`Unsupported JSON-LD keyword: ${keyword}.`);
this.keyword = keyword;
this.name = "UnsafeJsonLdError";
}
};
/** @internal */
var InvalidContextReferenceError = class extends TypeError {
constructor(reference) {
super(`Invalid JSON-LD context reference: ${reference}.`);
this.reference = reference;
this.name = "InvalidContextReferenceError";
}
};
function createLoadingRemoteContextFailedError(reference, cause) {
const message = cause instanceof Error ? cause.message : String(cause);
const error = /* @__PURE__ */ new Error(`Dereferencing a URL did not result in a valid JSON-LD context: ${reference}. ${message}`);
error.name = "jsonld.InvalidUrl";
error.details = {
code: "loading remote context failed",
url: reference
};
error.cause = cause;
return error;
}
/** @internal */
function isClearlyMalformedContextReference(reference) {
for (const char of reference) {
const code = char.charCodeAt(0);
if (code <= 32 || code === 127) return true;
}
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(reference) && !URL.canParse(reference)) return true;
for (let i = 0; i < reference.length; i++) {
if (reference[i] !== "%") continue;
if (i + 2 >= reference.length || !/[0-9A-Fa-f]/.test(reference[i + 1]) || !/[0-9A-Fa-f]/.test(reference[i + 2])) return true;
i += 2;
}
if (reference.startsWith("./") || reference.startsWith("../") || reference.startsWith("/") || reference.startsWith("//")) {
for (const char of reference) if ("[]<>\"\\^`{|}".includes(char)) return true;
}
return false;
}
function cloneRemoteDocument(remoteDocument) {
return structuredClone(remoteDocument);
}
function createMemoizedDocumentLoader(documentLoader) {
const cache = /* @__PURE__ */ new Map();
return async (url, options) => {
const cacheKey = URL.canParse(url) ? new URL(url).href : url;
let remoteDocument = cache.get(cacheKey);
if (remoteDocument == null) {
remoteDocument = Promise.resolve(documentLoader(url, options)).then(cloneRemoteDocument);
remoteDocument.catch(() => {
if (cache.get(cacheKey) === remoteDocument) cache.delete(cacheKey);
});
cache.set(cacheKey, remoteDocument);
}
return cloneRemoteDocument(await remoteDocument);
};
}
/** @internal */
function wrapContextLoaderForJsonLd(contextLoader) {
const loader = contextLoader ?? builtInContextLoader;
return async (url, options) => {
try {
return await loader(url, options);
} catch (error) {
if (!isInvalidUrlTypeError(error)) throw error;
if (isClearlyMalformedContextReference(url)) throw new InvalidContextReferenceError(url);
throw createLoadingRemoteContextFailedError(url, error);
}
};
}
/** @internal */
function getNormalizationContextLoader(contextLoader) {
const loader = wrapContextLoaderForJsonLd(contextLoader);
return createMemoizedDocumentLoader(async (url, options) => {
if (URL.canParse(url)) {
const normalizedUrl = new URL(url).href;
if (localContextUrls.has(normalizedUrl)) return await builtInContextLoader(normalizedUrl, options);
}
return await loader(url, options);
});
}
/** @internal */
async function compactJsonLd(jsonLd, contextLoader) {
const hasLds = typeof jsonLd === "object" && jsonLd != null && "signature" in jsonLd;
const signature = hasLds ? jsonLd.signature : void 0;
const normalizationContextLoader = getNormalizationContextLoader(contextLoader);
const document = hasLds ? detachSignature(jsonLd) : jsonLd;
await assertNoGraphBeforeCompaction(document, normalizationContextLoader);
const compacted = await _fedify_vocab_runtime_jsonld.default.compact(document, localContext, { documentLoader: normalizationContextLoader });
if (hasLds && typeof compacted === "object" && compacted != null) compacted.signature = signature;
assertSafeJsonLd(compacted);
return compacted;
}
function createInvalidRemoteContextError(reference) {
const error = /* @__PURE__ */ new Error(`Dereferencing a URL did not result in a JSON object. The response was valid JSON, but it was not a JSON object. URL: "${reference}".`);
error.name = "jsonld.InvalidUrl";
error.details = {
code: "invalid remote context",
url: reference
};
return error;
}
function getRemoteContext(remoteDocument, reference) {
const { contextUrl, documentUrl } = remoteDocument;
let { document } = remoteDocument;
if (typeof document === "string") document = JSON.parse(document);
if (typeof document !== "object" || document == null || Array.isArray(document)) throw createInvalidRemoteContextError(reference);
let context = "@context" in document ? document["@context"] : {};
if (contextUrl != null) context = Array.isArray(context) ? [...context, contextUrl] : [context, contextUrl];
return {
context,
baseUrl: documentUrl ?? reference
};
}
function createGraphAliasContextState() {
return {
graphTerms: /* @__PURE__ */ new Set(),
jsonTerms: /* @__PURE__ */ new Set(),
propertyContexts: /* @__PURE__ */ new Map(),
termTargets: /* @__PURE__ */ new Map()
};
}
function cloneGraphAliasContextState(state) {
return {
graphTerms: new Set(state.graphTerms),
jsonTerms: new Set(state.jsonTerms),
propertyContexts: new Map(state.propertyContexts),
termTargets: new Map(state.termTargets)
};
}
function resolveContextTarget(target, state) {
if (target === "@graph") return target;
const mapped = state.termTargets.get(target);
if (mapped == null) return target;
return mapped;
}
function getDirectContextTarget(definition) {
if (definition === null) return null;
if (typeof definition === "string") return definition;
if (typeof definition === "object" && definition != null && "@id" in definition) {
const id = definition["@id"];
if (id === null) return null;
if (typeof id === "string") return id;
}
}
function isJsonTypedDefinition(definition) {
return typeof definition === "object" && definition != null && "@type" in definition && definition["@type"] === "@json";
}
function resolveLocalContextTarget(target, state, localTargets, seen = /* @__PURE__ */ new Set()) {
if (target === "@graph") return target;
if (seen.has(target)) return target;
seen.add(target);
if (localTargets.has(target)) {
const localTarget = localTargets.get(target);
return localTarget == null ? target : resolveLocalContextTarget(localTarget, state, localTargets, seen);
}
return resolveContextTarget(target, state);
}
function refreshGraphAliases(state) {
state.graphTerms.clear();
for (const [term, target] of state.termTargets) if (target === "@graph") state.graphTerms.add(term);
}
function normalizeContextReference(reference, baseUrl) {
if (baseUrl != null) return new URL(reference, baseUrl).href;
return URL.canParse(reference) ? new URL(reference).href : reference;
}
/** @internal */
function isInvalidUrlTypeError(error) {
const code = error.code;
return error instanceof TypeError && (code === "ERR_INVALID_URL" || /^Invalid URL(?::|$)/.test(error.message) || / cannot be parsed as a URL\.?$/.test(error.message));
}
async function applyGraphAliasContext(state, context, documentLoader, remoteContextCache, baseUrl = null, processingContexts = /* @__PURE__ */ new Set()) {
if (context === null) return createGraphAliasContextState();
let nextState = cloneGraphAliasContextState(state);
if (Array.isArray(context)) {
for (const item of context) nextState = await applyGraphAliasContext(nextState, item, documentLoader, remoteContextCache, baseUrl, processingContexts);
return nextState;
}
if (typeof context === "string") {
const reference = normalizeContextReference(context, baseUrl);
const cacheKey = `${baseUrl ?? ""}\n${reference}`;
if (processingContexts.has(cacheKey)) return nextState;
processingContexts.add(cacheKey);
try {
let remoteContext = remoteContextCache.get(cacheKey);
if (remoteContext == null) {
remoteContext = (async () => {
try {
return getRemoteContext(await documentLoader(reference), reference);
} catch (error) {
if (reference === context && isInvalidUrlTypeError(error) && isClearlyMalformedContextReference(context)) throw new InvalidContextReferenceError(context);
throw error;
}
})();
remoteContextCache.set(cacheKey, remoteContext);
}
const loadedRemoteContext = await remoteContext;
return await applyGraphAliasContext(nextState, loadedRemoteContext.context, documentLoader, remoteContextCache, loadedRemoteContext.baseUrl, processingContexts);
} finally {
processingContexts.delete(cacheKey);
}
}
if (typeof context === "object" && context != null) {
if ("@import" in context && typeof context["@import"] === "string") nextState = await applyGraphAliasContext(nextState, context["@import"], documentLoader, remoteContextCache, baseUrl, processingContexts);
const localTargets = /* @__PURE__ */ new Map();
for (const [term, definition] of globalThis.Object.entries(context)) {
if (term.startsWith("@")) continue;
const target = getDirectContextTarget(definition);
if (target == null) localTargets.set(term, null);
else if (typeof target === "string") localTargets.set(term, target);
else localTargets.delete(term);
}
for (const [term, definition] of globalThis.Object.entries(context)) {
if (term.startsWith("@")) continue;
if (localTargets.has(term)) {
const directTarget = localTargets.get(term);
if (directTarget == null) nextState.termTargets.set(term, null);
else nextState.termTargets.set(term, resolveLocalContextTarget(directTarget, nextState, localTargets));
} else nextState.termTargets.delete(term);
if (typeof definition === "object" && definition != null && "@context" in definition) nextState.propertyContexts.set(term, {
context: definition["@context"],
baseUrl
});
else nextState.propertyContexts.delete(term);
if (isJsonTypedDefinition(definition)) nextState.jsonTerms.add(term);
else nextState.jsonTerms.delete(term);
}
refreshGraphAliases(nextState);
}
return nextState;
}
async function assertNoGraphBeforeCompaction(jsonLd, documentLoader, inheritedState = createGraphAliasContextState(), propertyContext, remoteContextCache = /* @__PURE__ */ new Map()) {
if (Array.isArray(jsonLd)) {
for (const item of jsonLd) await assertNoGraphBeforeCompaction(item, documentLoader, inheritedState, propertyContext, remoteContextCache);
return;
}
if (typeof jsonLd !== "object" || jsonLd == null) return;
const jsonLiteralWrapper = isJsonLiteralWrapper(jsonLd);
let state = inheritedState;
if (propertyContext !== void 0) state = await applyGraphAliasContext(state, propertyContext.context, documentLoader, remoteContextCache, propertyContext.baseUrl);
if ("@context" in jsonLd) state = await applyGraphAliasContext(state, jsonLd["@context"], documentLoader, remoteContextCache);
for (const [key, value] of globalThis.Object.entries(jsonLd)) {
if (key === "@context") continue;
if (jsonLiteralWrapper && key === "@value") continue;
if (key === "@graph" || state.graphTerms.has(key)) throw new UnsafeJsonLdError("@graph");
if (state.jsonTerms.has(key)) continue;
await assertNoGraphBeforeCompaction(value, documentLoader, state, state.propertyContexts.get(key), remoteContextCache);
}
}
function isJsonLiteralWrapper(value) {
return "@value" in value && (value["@type"] === "@json" || value.type === "@json");
}
/** @internal */
function assertSafeJsonLd(jsonLd) {
if (Array.isArray(jsonLd)) for (const item of jsonLd) assertSafeJsonLd(item);
else if (typeof jsonLd === "object" && jsonLd != null) {
const jsonLiteralWrapper = isJsonLiteralWrapper(jsonLd);
for (const [key, value] of globalThis.Object.entries(jsonLd)) {
if (disallowedJsonLdKeywords.has(key)) throw new UnsafeJsonLdError(key);
if (jsonLiteralWrapper && key === "@value") continue;
assertSafeJsonLd(value);
}
}
}
/**
* Attaches a LD signature to the given JSON-LD document.
* @param jsonLd The JSON-LD document to attach the signature to. It is not
* modified.
* @param signature The signature to attach.
* @returns The JSON-LD document with the attached signature.
* @throws {TypeError} If the input document is not a valid JSON-LD document.
* @since 1.0.0
*/
function attachSignature(jsonLd, signature) {
if (typeof jsonLd !== "object" || jsonLd == null) throw new TypeError("Failed to attach signature; invalid JSON-LD document.");
return {
...jsonLd,
signature
};
}
/**
* Creates a LD signature for the given JSON-LD document.
* @param jsonLd The JSON-LD document to sign.
* @param privateKey The private key to sign the document.
* @param keyId The ID of the public key that corresponds to the private key.
* @param options Additional options for creating the signature.
* See also {@link CreateSignatureOptions}.
* @return The created signature.
* @throws {TypeError} If the private key is invalid or unsupported.
* @since 1.0.0
*/
async function createSignature(jsonLd, privateKey, keyId, { contextLoader, created } = {}) {
require_http.validateCryptoKey(privateKey, "private");
if (privateKey.algorithm.name !== "RSASSA-PKCS1-v1_5") throw new TypeError("Unsupported algorithm: " + privateKey.algorithm.name);
const options = {
"@context": "https://w3id.org/identity/v1",
creator: keyId.href,
created: created?.toString() ?? (/* @__PURE__ */ new Date()).toISOString()
};
const message = await hashJsonLd(options, contextLoader) + await hashJsonLd(jsonLd, contextLoader);
const messageBytes = new TextEncoder().encode(message);
const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", privateKey, messageBytes);
return {
...options,
type: "RsaSignature2017",
signatureValue: (0, byte_encodings_base64.encodeBase64)(signature)
};
}
/**
* Signs the given JSON-LD document with the private key and returns the signed
* JSON-LD document.
* @param jsonLd The JSON-LD document to sign.
* @param privateKey The private key to sign the document.
* @param keyId The key ID to use in the signature. It will be used by the
* verifier to fetch the corresponding public key.
* @param options Additional options for signing the document.
* See also {@link SignJsonLdOptions}.
* @returns The signed JSON-LD document.
* @throws {TypeError} If the private key is invalid or unsupported.
* @since 1.0.0
*/
async function signJsonLd(jsonLd, privateKey, keyId, options) {
return await (options.tracerProvider ?? _opentelemetry_api.trace.getTracerProvider()).getTracer(require_http.name, require_http.version).startActiveSpan("ld_signatures.sign", { attributes: { "ld_signatures.key_id": keyId.href } }, async (span) => {
try {
const signature = await createSignature(jsonLd, privateKey, keyId, options);
if (span.isRecording()) {
span.setAttribute("ld_signatures.type", signature.type);
span.setAttribute("ld_signatures.signature", (0, byte_encodings_hex.encodeHex)((0, byte_encodings_base64.decodeBase64)(signature.signatureValue)));
}
return attachSignature(jsonLd, signature);
} catch (error) {
span.setStatus({
code: _opentelemetry_api.SpanStatusCode.ERROR,
message: String(error)
});
throw error;
} finally {
span.end();
}
});
}
/**
* Checks if the given JSON-LD document has a Linked Data Signature-like
* object, without restricting it to a single suite-specific shape.
* @param jsonLd The JSON-LD document to check.
* @returns `true` if the document has a signature-like object; `false`
* otherwise.
* @since 2.2.0
*/
function hasSignatureLike(jsonLd) {
if (typeof jsonLd !== "object" || jsonLd == null) return false;
const signature = jsonLd.signature;
const hasReference = (value) => {
if (typeof value === "string") return true;
if (Array.isArray(value)) return value.some(hasReference);
return typeof value === "object" && value != null && ("id" in value && typeof value.id === "string" || "@id" in value && typeof value["@id"] === "string");
};
const hasSignatureObject = (value) => {
if (typeof value !== "object" || value == null) return false;
const signatureRecord = value;
return (typeof signatureRecord.type === "string" || Array.isArray(signatureRecord.type) && signatureRecord.type.some((item) => typeof item === "string")) && (hasReference(signatureRecord.creator) || hasReference(signatureRecord.verificationMethod)) && (typeof signatureRecord.signatureValue === "string" || typeof signatureRecord.jws === "string");
};
return Array.isArray(signature) ? signature.some(hasSignatureObject) : hasSignatureObject(signature);
}
/**
* Checks if the given JSON-LD document has a Linked Data Signature.
* @param jsonLd The JSON-LD document to check.
* @returns `true` if the document has a signature; `false` otherwise.
* @since 1.0.0
*/
function hasSignature(jsonLd) {
if (typeof jsonLd !== "object" || jsonLd == null) return false;
if ("signature" in jsonLd) {
const signature = jsonLd.signature;
if (typeof signature !== "object" || signature == null) return false;
return "type" in signature && signature.type === "RsaSignature2017" && "creator" in signature && typeof signature.creator === "string" && "created" in signature && typeof signature.created === "string" && "signatureValue" in signature && typeof signature.signatureValue === "string";
}
return false;
}
/**
* Detaches Linked Data Signatures from the given JSON-LD document.
* @param jsonLd The JSON-LD document to modify.
* @returns The modified JSON-LD document. If the input document does not
* contain a signature, the original document is returned.
* @since 1.0.0
*/
function detachSignature(jsonLd) {
if (typeof jsonLd !== "object" || jsonLd == null) return jsonLd;
const doc = { ...jsonLd };
delete doc.signature;
return doc;
}
/**
* Verifies Linked Data Signatures of the given JSON-LD document.
* @param jsonLd The JSON-LD document to verify.
* @param options Options for verifying the signature.
* @returns The public key that signed the document or `null` if the signature
* is invalid or the key is not found.
* @since 1.0.0
*/
async function verifySignature(jsonLd, options = {}) {
if (!hasSignature(jsonLd)) return null;
const sig = jsonLd.signature;
let signature;
try {
signature = (0, byte_encodings_base64.decodeBase64)(sig.signatureValue);
} catch (error) {
logger$3.debug("Failed to verify; invalid base64 signatureValue: {signatureValue}", {
...sig,
error
});
return null;
}
const { key, cached } = await require_http.fetchKey(new URL(sig.creator), _fedify_vocab.CryptographicKey, options);
if (key == null) return null;
const sigOpts = {
...sig,
"@context": "https://w3id.org/identity/v1"
};
delete sigOpts.type;
delete sigOpts.id;
delete sigOpts.signatureValue;
let sigOptsHash;
try {
sigOptsHash = await hashJsonLd(sigOpts, options.contextLoader);
} catch (error) {
logger$3.warn("Failed to verify; failed to hash the signature options: {signatureOptions}\n{error}", {
signatureOptions: sigOpts,
error
});
return null;
}
const document = { ...jsonLd };
delete document.signature;
let docHash;
try {
docHash = await hashJsonLd(document, options.contextLoader);
} catch (error) {
logger$3.warn("Failed to verify; failed to hash the document: {document}\n{error}", {
document,
error
});
return null;
}
const encoder = new TextEncoder();
const message = sigOptsHash + docHash;
const messageBytes = encoder.encode(message);
if (await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key.publicKey, signature.slice(), messageBytes)) return key;
if (cached) {
logger$3.debug("Failed to verify with the cached key {keyId}; signature {signatureValue} is invalid. Retrying with the freshly fetched key...", {
keyId: sig.creator,
...sig
});
const { key } = await require_http.fetchKey(new URL(sig.creator), _fedify_vocab.CryptographicKey, {
...options,
keyCache: {
get: () => Promise.resolve(void 0),
set: async (keyId, key) => await options.keyCache?.set(keyId, key)
}
});
if (key == null) return null;
return await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key.publicKey, signature.slice(), messageBytes) ? key : null;
}
logger$3.debug("Failed to verify with the fetched key {keyId}; signature {signatureValue} is invalid. Check if the key is correct or if the signed message is correct. The message to sign is:\n{message}", {
keyId: sig.creator,
...sig,
message
});
return null;
}
/**
* Verify the authenticity of the given JSON-LD document using Linked Data
* Signatures. If the document is signed, this function verifies the signature
* and checks if the document is attributed to the owner of the public key.
* If the document is not signed, this function returns `false`.
* @param jsonLd The JSON-LD document to verify.
* @param options Options for verifying the document.
* @returns `true` if the document is authentic; `false` otherwise.
*/
async function verifyJsonLd(jsonLd, options = {}) {
return await verifyJsonLdInternal(jsonLd, options, true);
}
/** @internal */
async function verifyCompactJsonLd(jsonLd, options = {}) {
return await verifyJsonLdInternal(jsonLd, options, false);
}
async function verifyJsonLdInternal(jsonLd, options, compact) {
return await (options.tracerProvider ?? _opentelemetry_api.trace.getTracerProvider()).getTracer(require_http.name, require_http.version).startActiveSpan("ld_signatures.verify", async (span) => {
try {
const verificationOptions = hasSignature(jsonLd) ? {
...options,
contextLoader: getNormalizationContextLoader(options.contextLoader)
} : options;
const compacted = compact ? hasSignature(jsonLd) ? await compactJsonLd(jsonLd, options.contextLoader) : jsonLd : jsonLd;
const object = await _fedify_vocab.Object.fromJsonLd(compacted, verificationOptions);
if (object.id != null) span.setAttribute("activitypub.object.id", object.id.href);
span.setAttribute("activitypub.object.type", (0, _fedify_vocab.getTypeId)(object).href);
if (typeof jsonLd === "object" && jsonLd != null && "signature" in jsonLd && typeof jsonLd.signature === "object" && jsonLd.signature != null) {
if ("creator" in jsonLd.signature && typeof jsonLd.signature.creator === "string") span.setAttribute("ld_signatures.key_id", jsonLd.signature.creator);
if ("signatureValue" in jsonLd.signature && typeof jsonLd.signature.signatureValue === "string") span.setAttribute("ld_signatures.signature", jsonLd.signature.signatureValue);
if ("type" in jsonLd.signature && typeof jsonLd.signature.type === "string") span.setAttribute("ld_signatures.type", jsonLd.signature.type);
}
const attributions = new Set(object.attributionIds.map((uri) => uri.href));
if (object instanceof _fedify_vocab.Activity) for (const uri of object.actorIds) attributions.add(uri.href);
const key = await verifySignature(compacted, verificationOptions);
if (key == null) return false;
if (key.ownerId == null) {
logger$3.debug("Key {keyId} has no owner.", { keyId: key.id?.href });
return false;
}
attributions.delete(key.ownerId.href);
if (attributions.size > 0) {
logger$3.debug("Some attributions are not authenticated by the Linked Data Signatures: {attributions}.", { attributions: [...attributions] });
return false;
}
return true;
} catch (error) {
span.setStatus({
code: _opentelemetry_api.SpanStatusCode.ERROR,
message: String(error)
});
throw error;
} finally {
span.end();
}
});
}
async function hashJsonLd(jsonLd, contextLoader) {
const canon = await _fedify_vocab_runtime_jsonld.default.canonize(jsonLd, {
format: "application/n-quads",
documentLoader: contextLoader ?? (0, _fedify_vocab_runtime.getDocumentLoader)()
});
const encoder = new TextEncoder();
return (0, byte_encodings_hex.encodeHex)(await crypto.subtle.digest("SHA-256", encoder.encode(canon)));
}
//#endregion
//#region src/sig/owner.ts
/**
* Checks if the actor of the given activity owns the specified key.
* @param activity The activity to check.
* @param key The public key to check.
* @param options Options for checking the key ownership.
* @returns Whether the actor is the owner of the key.
*/
async function doesActorOwnKey(activity, key, options) {
return await (options.tracerProvider ?? _opentelemetry_api.trace.getTracerProvider()).getTracer(require_http.name, require_http.version).startActiveSpan("activitypub.verify_key_ownership", {
kind: _opentelemetry_api.SpanKind.INTERNAL,
attributes: {
"activitypub.actor.id": activity.actorId?.href ?? "",
"activitypub.key.id": key.id?.href ?? ""
}
}, async (span) => {
try {
if (key.ownerId != null) {
const owns = key.ownerId.href === activity.actorId?.href;
span.setAttribute("activitypub.key_ownership.verified", owns);
span.setAttribute("activitypub.key_ownership.method", "owner_id");
return owns;
}
const actor = await activity.getActor(options);
if (actor == null || !(0, _fedify_vocab.isActor)(actor)) {
span.setAttribute("activitypub.key_ownership.verified", false);
span.setAttribute("activitypub.key_ownership.method", "actor_fetch");
return false;
}
for (const publicKeyId of actor.publicKeyIds) if (key.id != null && publicKeyId.href === key.id.href) {
span.setAttribute("activitypub.key_ownership.verified", true);
span.setAttribute("activitypub.key_ownership.method", "actor_fetch");
return true;
}
span.setAttribute("activitypub.key_ownership.verified", false);
span.setAttribute("activitypub.key_ownership.method", "actor_fetch");
return false;
} catch (error) {
span.recordException(error);
span.setStatus({
code: _opentelemetry_api.SpanStatusCode.ERROR,
message: String(error)
});
throw error;
} finally {
span.end();
}
});
}
/**
* Gets the actor that owns the specified key. Returns `null` if the key has no
* known owner.
*
* @param keyId The ID of the key to check, or the key itself.
* @param options Options for getting the key owner.
* @returns The actor that owns the key, or `null` if the key has no known
* owner.
* @since 0.7.0
*/
async function getKeyOwner(keyId, options) {
const tracerProvider = options.tracerProvider ?? _opentelemetry_api.trace.getTracerProvider();
const documentLoader = options.documentLoader ?? (0, _fedify_vocab_runtime.getDocumentLoader)();
const contextLoader = options.contextLoader ?? (0, _fedify_vocab_runtime.getDocumentLoader)();
let object;
if (keyId instanceof _fedify_vocab.CryptographicKey) {
object = keyId;
if (object.id == null) return null;
keyId = object.id;
} else {
let keyDoc;
try {
const { document } = await documentLoader(keyId.href);
keyDoc = document;
} catch (_) {
return null;
}
try {
object = await _fedify_vocab.Object.fromJsonLd(keyDoc, {
documentLoader,
contextLoader,
tracerProvider
});
} catch (e) {
if (!(e instanceof TypeError)) throw e;
try {
object = await _fedify_vocab.CryptographicKey.fromJsonLd(keyDoc, {
documentLoader,
contextLoader,
tracerProvider
});
} catch (e) {
if (e instanceof TypeError) return null;
throw e;
}
}
}
let owner = null;
if (object instanceof _fedify_vocab.CryptographicKey) {
if (object.ownerId == null) return null;
owner = await object.getOwner({
documentLoader,
contextLoader,
tracerProvider
});
} else if ((0, _fedify_vocab.isActor)(object)) owner = object;
else return null;
if (owner == null) return null;
for (const kid of owner.publicKeyIds) if (kid.href === keyId.href) return owner;
return null;
}
//#endregion
//#region src/compat/preloaded-context-loader.ts
/**
* A restricted JSON-LD document loader that resolves only contexts bundled
* with Fedify.
*
* This is intentionally narrower than `getDocumentLoader()`: normalization
* helpers are also reached from verification paths that operate on inbound,
* attacker-controlled JSON-LD, so the default fallback must never fetch
* attacker-supplied context URLs.
*/
const preloadedOnlyDocumentLoader = (url) => {
if (Object.hasOwn(_fedify_vocab_runtime.preloadedContexts, url)) return Promise.resolve({
contextUrl: null,
documentUrl: url,
document: _fedify_vocab_runtime.preloadedContexts[url]
});
return Promise.reject(/* @__PURE__ */ new Error("Refusing to fetch a non-preloaded JSON-LD context: " + url));
};
//#endregion
//#region src/compat/public-audience.ts
const logger$2 = (0, _logtape_logtape.getLogger)([
"fedify",
"compat",
"public-audience"
]);
const PUBLIC_ADDRESSING_FIELDS = new Set([
"to",
"cc",
"bto",
"bcc",
"audience"
]);
const AS_CONTEXT_URL$1 = "https://www.w3.org/ns/activitystreams";
const MAX_TRAVERSAL_DEPTH$1 = 64;
const KNOWN_SAFE_CONTEXT_URLS$1 = new Set(Object.keys(_fedify_vocab_runtime.preloadedContexts));
function hasPublicCurieInAddressing(value, parentKey, depth = 0) {
if (typeof value === "string") return parentKey != null && PUBLIC_ADDRESSING_FIELDS.has(parentKey) && (value === "as:Public" || value === "Public");
if (depth >= MAX_TRAVERSAL_DEPTH$1) return false;
if (Array.isArray(value)) return value.some((item) => hasPublicCurieInAddressing(item, parentKey, depth + 1));
if (typeof value !== "object" || value == null) return false;
const record = value;
for (const key of Object.keys(record)) {
if (key === "@context") continue;
if (hasPublicCurieInAddressing(record[key], key, depth + 1)) return true;
}
return false;
}
function rewritePublicAudience(value, parentKey, depth = 0) {
if (typeof value === "string" && parentKey != null && PUBLIC_ADDRESSING_FIELDS.has(parentKey) && (value === "as:Public" || value === "Public")) return _fedify_vocab.PUBLIC_COLLECTION.href;
if (depth >= MAX_TRAVERSAL_DEPTH$1) return value;
if (Array.isArray(value)) {
let changed = false;
const mapped = value.map((item) => {
const rewritten = rewritePublicAudience(item, parentKey, depth + 1);
if (rewritten !== item) changed = true;
return rewritten;
});
return changed ? mapped : value;
}
if (typeof value !== "object" || value == null) return value;
const record = value;
let changed = false;
const normalized = Object.create(null);
for (const key of Object.keys(record)) {
const rewritten = key === "@context" ? record[key] : rewritePublicAudience(record[key], key, depth + 1);
if (rewritten !== record[key]) changed = true;
normalized[key] = rewritten;
}
return changed ? normalized : value;
}
/**
* Reports whether `value` carries an `@context` property anywhere inside
* its subtree (not counting the value itself). A nested `@context` can
* introduce a local term-definition scope that redefines `as:` or `Public`
* even when the top-level `@context` is safe, so the fast path must defer
* to the URDNA2015 equivalence check whenever one is present.
*/
function hasNestedContext$1(value, depth = 0) {
if (depth >= MAX_TRAVERSAL_DEPTH$1) return true;
if (Array.isArray(value)) return value.some((item) => hasNestedContext$1(item, depth + 1));
if (typeof value !== "object" || value == null) return false;
const record = value;
for (const key of Object.keys(record)) {
if (key === "@context") return true;
if (hasNestedContext$1(record[key], depth + 1)) return true;
}
return false;
}
/**
* Checks whether the `@context` of a JSON-LD document is guaranteed not
* to redefine the `as:` prefix or the bare `Public` term. Only documents
* whose `@context` is a string, or an array of strings, drawn from Fedify's
* preloaded context set AND including the ActivityStreams URL qualify,
* AND no nested subtree carries its own `@context` that might redefine
* those terms within a local scope. When all of that holds the rewrite
* is provably semantics-preserving and the URDNA2015 equivalence check
* can be skipped. Any other shape (unknown external URLs, inline
* objects at the top level, nested `@context` blocks) is treated as
* potentially unsafe.
*/
function hasKnownSafeContext$1(jsonLd) {
if (typeof jsonLd !== "object" || jsonLd == null) return false;
const record = jsonLd;
if (!Object.hasOwn(record, "@context")) return false;
const ctx = record["@context"];
const entries = typeof ctx === "string" ? [ctx] : Array.isArray(ctx) ? ctx : null;
if (entries == null || entries.length === 0) return false;
let hasAs = false;
for (const entry of entries) {
if (typeof entry !== "string") return false;
if (!KNOWN_SAFE_CONTEXT_URLS$1.has(entry)) return false;
if (entry === AS_CONTEXT_URL$1) hasAs = true;
}
if (!hasAs) return false;
for (const key of Object.keys(record)) {
if (key === "@context") continue;
if (hasNestedContext$1(record[key])) return false;
}
return true;
}
/**
* Rewrites the compact `as:Public` / `Public` CURIE appearing in activity
* addressing fields (`to`, `cc`, `bto`, `bcc`, `audience`) to the fully
* expanded `https://www.w3.org/ns/activitystreams#Public` URI.
*
* Several ActivityPub implementations, Lemmy among them, match these
* fields as plain URLs without running JSON-LD expansion, and silently
* drop activities whose public addressing appears in CURIE form. This
* helper works around that gap.
*
* For documents whose `@context` is drawn entirely from Fedify's
* preloaded context set and includes the ActivityStreams URL, the
* rewrite is applied directly: the content of every preloaded non-AS
* context is known not to redefine the `as:` prefix or the bare `Public`
* term, so the semantics are preserved by construction. Any other
* shape (an inline object, an unknown external URL, and so on) is
* treated as potentially unsafe and gated on a JSON-LD equivalence
* check; both forms are canonicalized with URDNA2015 and the resulting
* N-Quads are compared. When they differ, the original document is
* returned unchanged. Canonicalization failures also fall back to the
* original document.
*
* When no `contextLoader` is supplied the helper falls back to an
* internal loader that resolves only the URLs in Fedify's
* preloaded-contexts set and rejects every other URL without issuing a
* network request. That behaviour is deliberately narrower than
* `@fedify/vocab-runtime`'s `getDocumentLoader()`, which after its
* `validatePublicUrl` check will happily fetch non-preloaded URLs: the
* helper is reached from verification paths (`verifyProof()` /
* `verifyObject()`) that operate on inbound, potentially adversarial
* JSON-LD, and a default loader that fetches attacker-supplied
* `@context` URLs on the caller's behalf would be an SSRF vector.
* Canonicalization failures against the restricted loader fall back to
* the original document, same as any other canonicalization error.
* Callers that genuinely need the remote-fetch loader (for example
* applications that sign local JSON-LD against a custom vocabulary)
* should pass a `contextLoader` explicitly.
*
* Must be called before any signing step that canonicalizes the
* compact form byte-for-byte (for example, Object Integrity Proofs
* using the `eddsa-jcs-2022` cryptosuite), so the signed payload
* matches what is sent on the wire.
*/
async function normalizePublicAudience(jsonLd, contextLoader) {
if (!hasPublicCurieInAddressing(jsonLd)) return jsonLd;
const normalized = rewritePublicAudience(jsonLd);
if (hasKnownSafeContext$1(jsonLd)) return normalized;
const loader = contextLoader ?? preloadedOnlyDocumentLoader;
try {
const [before, after] = await Promise.all([_fedify_vocab_runtime_jsonld.default.canonize(jsonLd, {
format: "application/n-quads",
documentLoader: loader
}), _fedify_vocab_runtime_jsonld.default.canonize(normalized, {
format: "application/n-quads",
documentLoader: loader
})]);
if (before === after) return normalized;
logger$2.warn("Expanding the public audience CURIE to its full URI would change the canonical form of the activity; sending the activity as is. This usually means the active JSON-LD context redefines the `as:` prefix or the bare `Public` term.");
} catch (error) {
logger$2.debug("Failed to verify public audience normalization equivalence via JSON-LD canonicalization; sending the activity as is.\n{error}", { error });
}
return jsonLd;
}
//#endregion
//#region src/compat/outgoing-jsonld.ts
const logger$1 = (0, _logtape_logtape.getLogger)([
"fedify",
"compat",
"outgoing-jsonld"
]);
const ATTACHMENT_FIELDS = new Set(["attachment", "https://www.w3.org/ns/activitystreams#attachment"]);
const AS_CONTEXT_URL = "https://www.w3.org/ns/activitystreams";
const KNOWN_SAFE_CONTEXT_URLS = getKnownSafeContextUrls();
const MAX_TRAVERSAL_DEPTH = 64;
function isJsonLdListObject(value) {
return typeof value === "object" && value != null && Object.hasOwn(value, "@list");
}
function isJsonLdValueObject(value) {
return typeof value === "object" && value != null && Object.hasOwn(value, "@value");
}
function* getContextObjects(value, seen = /* @__PURE__ */ new WeakSet()) {
if (Array.isArray(value)) {
if (seen.has(value)) return;
seen.add(value);
for (const item of value) yield* getContextObjects(item, seen);
return;
}
if (typeof value === "object" && value != null) {
if (seen.has(value)) return;
seen.add(value);
const record = value;
yield record;
for (const definition of Object.values(record)) {
if (typeof definition !== "object" || definition == null) continue;
const nestedContext = definition["@context"];
if (nestedContext == null) continue;
yield* getContextObjects(nestedContext, seen);
}
}
}
function isActivityStreamsAttachmentTerm(value) {
return typeof value === "object" && value != null && value["@id"] === "as:attachment" && value["@type"] === "@id";
}
/** @internal */
function isPreloadedContextAttachmentSafe(document) {
if (typeof document !== "object" || document == null) return true;
const context = document["@context"];
for (const contextObject of getContextObjects(context)) {
if (!Object.hasOwn(contextObject, "attachment")) continue;
if (isActivityStreamsAttachmentTerm(contextObject.attachment)) continue;
return false;
}
return true;
}
function getKnownSafeContextUrls() {
const urls = /* @__PURE__ */ new Set();
for (const [url, document] of Object.entries(_fedify_vocab_runtime.preloadedContexts)) if (isPreloadedContextAttachmentSafe(document)) urls.add(url);
else logger$1.warn("Preloaded JSON-LD context {contextUrl} redefines the `attachment` term incompatibly; attachment array normalization will require canonicalization for documents using it.", { contextUrl: url });
return urls;
}
/**
* Wraps scalar ActivityStreams attachment properties in arrays.
*/
function wrapScalarAttachments(jsonLd, depth = 0) {
if (depth >= MAX_TRAVERSAL_DEPTH) return jsonLd;
if (Array.isArray(jsonLd)) {
let normalized = null;
for (let i = 0; i < jsonLd.length; i++) {
const item = jsonLd[i];
const next = wrapScalarAttachments(item, depth + 1);
if (normalized == null && next !== item) normalized = jsonLd.slice(0, i);
if (normalized != null) normalized[i] = next;
}
return normalized ?? jsonLd;
}
if (typeof jsonLd !== "object" || jsonLd == null) return jsonLd;
const record = jsonLd;
const keys = Object.keys(record);
let normalized = null;
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const value = record[key];
const next = key === "@context" || key === "@value" && isJsonLdValueObject(jsonLd) ? value : wrapScalarAttachments(value, depth + 1);
const output = ATTACHMENT_FIELDS.has(key) && next != null && !Array.isArray(next) && !isJsonLdListObject(next) ? [next] : next;
if (normalized == null && output !== value) {
const cloned = Object.create(null);
for (let j = 0; j < i; j++) {
const previousKey = keys[j];
cloned[previousKey] = record[previousKey];
}
normalized = cloned;
}
if (normalized != null) normalized[key] = output;
}
return normalized ?? jsonLd;
}
function hasNestedContext(value, depth = 0) {
if (depth >= MAX_TRAVERSAL_DEPTH) return true;
if (Array.isArray(value)) return value.some((item) => hasNestedContext(item, depth + 1));
if (typeof value !== "object" || value == null) return false;
const record = value;
for (const key of Object.keys(record)) {
if (key === "@context") return true;
if (key === "@value" && isJsonLdValueObject(value)) continue;
if (hasNestedContext(record[key], depth + 1)) return true;
}
return false;
}
function exceedsTraversalDepth(value, depth = 0) {
if (depth >= MAX_TRAVERSAL_DEPTH) return true;
if (Array.isArray(value)) return value.some((item) => exceedsTraversalDepth(item, depth + 1));
if (typeof value !== "object" || value == null) return false;
const record = value;
for (const key of Object.keys(record)) {
if (key === "@context" || key === "@value" && isJsonLdValueObject(value)) continue;
if (exceedsTraversalDepth(record[key], depth + 1)) return true;
}
return false;
}
function hasKnownSafeContext(jsonLd) {
if (typeof jsonLd !== "object" || jsonLd == null) return false;
const record = jsonLd;
if (!Object.hasOwn(record, "@context")) return false;
const context = record["@context"];
const entries = typeof context === "string" ? [context] : Array.isArray(context) ? context : null;
if (entries == null || entries.length < 1) return false;
let hasActivityStreamsContext = false;
for (const entry of entries) {
if (typeof entry !== "string") return false;
if (!KNOWN_SAFE_CONTEXT_URLS.has(entry)) return false;
if (entry === AS_CONTEXT_URL) hasActivityStreamsContext = true;
}
if (!hasActivityStreamsContext) return false;
for (const key of Object.keys(record)) {
if (key === "@context") continue;
if (hasNestedContext(record[key])) return false;
}
return true;
}
function getLogSafeJsonLdMetadata(jsonLd) {
if (typeof jsonLd !== "object" || jsonLd == null) return {};
const record = jsonLd;
const context = record["@context"];
return {
id: typeof record.id === "string" ? record.id : typeof record["@id"] === "string" ? record["@id"] : void 0,
type: typeof record.type === "string" ? record.type : typeof record["@type"] === "string" ? record["@type"] : void 0,
context: typeof context === "string" ? context : Array.isArray(context) ? context.filter((entry) => typeof entry === "string").slice(0, 4) : context == null ? void 0 : "[inline context]"
};
}
/**
* Ensures ActivityStreams attachment properties are represented as arrays
* when doing so preserves the JSON-LD semantics.
*
* JSON-LD compaction collapses single-item arrays into scalar values by
* default. Some ActivityPub implementations, Pixelfed among them, parse
* `attachment` as a plain JSON array rather than a JSON-LD property and reject
* otherwise valid objects whose single attachment is emitted as a scalar.
*
* When no `contextLoader` is supplied, the helper falls back to a restricted
* loader that resolves only Fedify's preloaded JSON-LD contexts and rejects
* every other URL without network access. Documents with custom, inline, or
* otherwise uncached contexts should pass a real `contextLoader` if they need
* the semantic-preservation check to succeed; otherwise canonicalization
* failures leave the original document unchanged.
*/
async function normalizeAttachmentArrays(jsonLd, contextLoader) {
const normalized = wrapScalarAttachments(jsonLd);
if (normalized === jsonLd) return jsonLd;
if (exceedsTraversalDepth(jsonLd)) {
logger$1.debug("Skipping attachment array normalization because the JSON-LD document exceeds the safe traversal depth; leaving it unchanged.");
return jsonLd;
}
if (hasKnownSafeContext(jsonLd)) return normalized;
const loader = contextLoader ?? preloadedOnlyDocumentLoader;
try {
const [before, after] = await Promise.all([_fedify_vocab_runtime_jsonld.default.canonize(jsonLd, {
format: "application/n-quads",
documentLoader: loader
}), _fedify_vocab_runtime_jsonld.default.canonize(normalized, {
format: "application/n-quads",
documentLoader: loader
})]);
if (before === after) return normalized;
logger$1.warn("Wrapping scalar attachment values in arrays would change the canonical form of the JSON-LD document; leaving it unchanged. This usually means the active JSON-LD context redefines the `attachment` term. Document: {id}; type: {type}; context: {context}.", getLogSafeJsonLdMetadata(jsonLd));
} catch (error) {
logger$1.debug("Failed to verify attachment array normalization equivalence via JSON-LD canonicalization; leaving the JSON-LD document unchanged.\n{error}", { error });
}
return jsonLd;
}
/**
* Applies Fedify's internal JSON-LD wire-format interoperability workarounds
* to locally generated outgoing activities before they are signed, enqueued,
* or sent.
*/
async function normalizeOutgoingActivityJsonLd(jsonLd, contextLoader) {
jsonLd = await normalizePublicAudience(jsonLd, contextLoader);
return await normalizeAttachmentArrays(jsonLd, contextLoader);
}
//#endregion
//#region src/sig/proof.ts
const logger = (0, _logtape_logtape.getLogger)([
"fedify",
"sig",
"proof"
]);
/**
* Checks if the given JSON-LD document has a DataIntegrityProof-like object,
* without fully deserializing it into vocabulary classes.
* @param jsonLd The JSON-LD document to check.
* @returns `true` if the document has a proof-like object; `false` otherwise.
* @since 2.2.0
*/
function hasProofLike(jsonLd) {
if (typeof jsonLd !== "object" || jsonLd == null) return false;
const record = jsonLd;
const proof = record.proof ?? record["https://w3id.org/security#proof"];
const getField = (source, compact, expanded) => source[compact] ?? source[expanded];
const isReference = (value) => {
if (typeof value === "string") return true;
if (Array.isArray(value)) return value.some(isReference);
return typeof value === "object" && value != null && ("id" in value && typeof value.id === "string" || "@id" in value && typeof value["@id"] === "string" || "@value" in value && typeof value["@value"] === "string");
};
const hasType = (value) => {
if (typeof value === "string") return value === "DataIntegrityProof" || value === "https://w3id.org/security#DataIntegrityProof";
if (Array.isArray(value)) return value.some(hasType);
return false;
};
const isProofLike = (value) => {
if (typeof value !== "object" || value == null) return false;
const proofRecord = value;
return hasType(proofRecord.type ?? proofRecord["@type"]) && isReference(getField(proofRecord, "verificationMethod", "https://w3id.org/security#verificationMethod")) && isReference(getField(proofRecord, "proofPurpose", "https://w3id.org/security#proofPurpose")) && isReference(getField(proofRecord, "proofValue", "https://w3id.org/security#proofValue"));
};
return Array.isArray(proof) ? proof.some(isProofLike) : isProofLike(proof);
}
/**
* Creates a proof for the given object.
* @param object The object to create a proof for.
* @param privateKey The private key to sign the proof with.
* @param keyId The key ID to use in the proof. It will be used by the verifier.
* @param options Additional options. See also {@link CreateProofOptions}.
* @returns The created proof.
* @throws {TypeError} If the private key is invalid or unsupported.
* @since 0.10.0
*/
async function createProof(object, privateKey, keyId, { contextLoader, context, created } = {}) {
require_http.validateCryptoKey(privateKey, "private");
if (privateKey.algorithm.name !== "Ed25519") throw new TypeError("Unsupported algorithm: " + privateKey.algorithm.name);
let compactMsg = await object.clone({ proofs: [] }).toJsonLd({
format: "compact",
contextLoader,
context
});
compactMsg = await normalizeOutgoingActivityJsonLd(compactMsg, contextLoader);
const msgCanon = (0, json_canon.default)(compactMsg);
const encoder = new TextEncoder();
const msgBytes = encoder.encode(msgCanon);
const msgDigest = await crypto.subtle.digest("SHA-256", msgBytes);
created ??= Temporal.Now.instant();
const proofCanon = (0, json_canon.default)({
"@context": compactMsg["@context"],
type: "DataIntegrityProof",
cryptosuite: "eddsa-jcs-2022",
verificationMethod: keyId.href,
proofPurpose: "assertionMethod",
created: created.toString()
});
const proofBytes = encoder.encode(proofCanon);
const proofDigest = await crypto.subtle.digest("SHA-256", proofBytes);
const digest = new Uint8Array(proofDigest.byteLength + msgDigest.byteLength);
digest.set(new Uint8Array(proofDigest), 0);
digest.set(new Uint8Array(msgDigest), proofDigest.byteLength);
const sig = await crypto.subtle.sign("Ed25519", privateKey, digest);
return new _fedify_vocab.DataIntegrityProof({
cryptosuite: "eddsa-jcs-2022",
verificationMethod: keyId,
proofPurpose: "assertionMethod",
created: created ?? Temporal.Now.instant(),
proofValue: new Uint8Array(sig)
});
}
/**
* Signs the given object with the private key and returns the signed object.
* @param object