UNPKG

@fedify/fedify

Version:

An ActivityPub server framework

1,148 lines • 61.3 kB
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