UNPKG

@fedify/fedify

Version:

An ActivityPub server framework

595 lines (589 loc) • 23.3 kB
import { Temporal } from "@js-temporal/polyfill"; import { URLPattern } from "urlpattern-polyfill"; import { deno_default, getDocumentLoader } from "./docloader-b8Rvguvo.js"; import { Activity, CryptographicKey, DataIntegrityProof, Multikey, Object as Object$1, getTypeId, isActor } from "./actor-E2ennwBD.js"; import { fetchKey, validateCryptoKey } from "./key-BNa6EpJl.js"; import { getLogger } from "@logtape/logtape"; import { SpanStatusCode, trace } from "@opentelemetry/api"; import jsonld from "jsonld"; import { decodeBase64, encodeBase64 } from "byte-encodings/base64"; import { encodeHex } from "byte-encodings/hex"; import serialize from "json-canon"; //#region sig/ld.ts const logger$1 = getLogger([ "fedify", "sig", "ld" ]); /** * 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 } = {}) { 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 optionsHash = await hashJsonLd(options, contextLoader); const docHash = await hashJsonLd(jsonLd, contextLoader); const message = optionsHash + docHash; const encoder = new TextEncoder(); const messageBytes = encoder.encode(message); const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", privateKey, messageBytes); return { ...options, type: "RsaSignature2017", signatureValue: 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) { const tracerProvider = options.tracerProvider ?? trace.getTracerProvider(); const tracer = tracerProvider.getTracer(deno_default.name, deno_default.version); return await tracer.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", encodeHex(decodeBase64(signature.signatureValue))); } return attachSignature(jsonLd, signature); } catch (error) { span.setStatus({ code: SpanStatusCode.ERROR, message: String(error) }); throw error; } finally { span.end(); } }); } /** * 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 = decodeBase64(sig.signatureValue); } catch (error) { logger$1.debug("Failed to verify; invalid base64 signatureValue: {signatureValue}", { ...sig, error }); return null; } const { key, cached } = await fetchKey(new URL(sig.creator), 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$1.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$1.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); const verified = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key.publicKey, signature, messageBytes); if (verified) return key; if (cached) { logger$1.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: key$1 } = await fetchKey(new URL(sig.creator), CryptographicKey, { ...options, keyCache: { get: () => Promise.resolve(void 0), set: async (keyId, key$2) => await options.keyCache?.set(keyId, key$2) } }); if (key$1 == null) return null; const verified$1 = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key$1.publicKey, signature, messageBytes); return verified$1 ? key$1 : null; } logger$1.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 = {}) { const tracerProvider = options.tracerProvider ?? trace.getTracerProvider(); const tracer = tracerProvider.getTracer(deno_default.name, deno_default.version); return await tracer.startActiveSpan("ld_signatures.verify", async (span) => { try { const object = await Object$1.fromJsonLd(jsonLd, options); if (object.id != null) span.setAttribute("activitypub.object.id", object.id.href); span.setAttribute("activitypub.object.type", 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 Activity) for (const uri of object.actorIds) attributions.add(uri.href); const key = await verifySignature(jsonLd, options); if (key == null) return false; if (key.ownerId == null) { logger$1.debug("Key {keyId} has no owner.", { keyId: key.id?.href }); return false; } attributions.delete(key.ownerId.href); if (attributions.size > 0) { logger$1.debug("Some attributions are not authenticated by the Linked Data Signatures: {attributions}.", { attributions: [...attributions] }); return false; } return true; } catch (error) { span.setStatus({ code: SpanStatusCode.ERROR, message: String(error) }); throw error; } finally { span.end(); } }); } async function hashJsonLd(jsonLd, contextLoader) { const canon = await jsonld.canonize(jsonLd, { format: "application/n-quads", documentLoader: contextLoader ?? getDocumentLoader() }); const encoder = new TextEncoder(); const hash = await crypto.subtle.digest("SHA-256", encoder.encode(canon)); return encodeHex(hash); } //#endregion //#region 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) { if (key.ownerId != null) return key.ownerId.href === activity.actorId?.href; const actor = await activity.getActor(options); if (actor == null || !isActor(actor)) return false; for (const publicKeyId of actor.publicKeyIds) if (key.id != null && publicKeyId.href === key.id.href) return true; return false; } /** * 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 ?? trace.getTracerProvider(); const documentLoader = options.documentLoader ?? getDocumentLoader(); const contextLoader = options.contextLoader ?? getDocumentLoader(); let object; if (keyId instanceof 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 Object$1.fromJsonLd(keyDoc, { documentLoader, contextLoader, tracerProvider }); } catch (e) { if (!(e instanceof TypeError)) throw e; try { object = await CryptographicKey.fromJsonLd(keyDoc, { documentLoader, contextLoader, tracerProvider }); } catch (e$1) { if (e$1 instanceof TypeError) return null; throw e$1; } } } let owner = null; if (object instanceof CryptographicKey) { if (object.ownerId == null) return null; owner = await object.getOwner({ documentLoader, contextLoader, tracerProvider }); } else if (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 sig/proof.ts const logger = getLogger([ "fedify", "sig", "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: context$1, created } = {}) { validateCryptoKey(privateKey, "private"); if (privateKey.algorithm.name !== "Ed25519") throw new TypeError("Unsupported algorithm: " + privateKey.algorithm.name); const objectWithoutProofs = object.clone({ proofs: [] }); const compactMsg = await objectWithoutProofs.toJsonLd({ format: "compact", contextLoader, context: context$1 }); const msgCanon = serialize(compactMsg); const encoder = new TextEncoder(); const msgBytes = encoder.encode(msgCanon); const msgDigest = await crypto.subtle.digest("SHA-256", msgBytes); created ??= Temporal.Now.instant(); const proofConfig = { "@context": compactMsg["@context"], type: "DataIntegrityProof", cryptosuite: "eddsa-jcs-2022", verificationMethod: keyId.href, proofPurpose: "assertionMethod", created: created.toString() }; const proofCanon = serialize(proofConfig); 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 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 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 SignObjectOptions}. * @returns The signed object. * @throws {TypeError} If the private key is invalid or unsupported. * @since 0.10.0 */ async function signObject(object, privateKey, keyId, options = {}) { const tracerProvider = options.tracerProvider ?? trace.getTracerProvider(); const tracer = tracerProvider.getTracer(deno_default.name, deno_default.version); return await tracer.startActiveSpan("object_integrity_proofs.sign", { attributes: { "activitypub.object.type": getTypeId(object).href } }, async (span) => { try { if (object.id != null) span.setAttribute("activitypub.object.id", object.id.href); const existingProofs = []; for await (const proof$1 of object.getProofs(options)) existingProofs.push(proof$1); const proof = await createProof(object, privateKey, keyId, options); if (span.isRecording()) { if (proof.cryptosuite != null) span.setAttribute("object_integrity_proofs.cryptosuite", proof.cryptosuite); if (proof.verificationMethodId != null) span.setAttribute("object_integrity_proofs.key_id", proof.verificationMethodId.href); if (proof.proofValue != null) span.setAttribute("object_integrity_proofs.signature", encodeHex(proof.proofValue)); } return object.clone({ proofs: [...existingProofs, proof] }); } catch (error) { span.setStatus({ code: SpanStatusCode.ERROR, message: String(error) }); throw error; } finally { span.end(); } }); } /** * Verifies the given proof for the object. * @param jsonLd The JSON-LD object to verify the proof for. If it contains * any proofs, they will be ignored. * @param proof The proof to verify. * @param options Additional options. See also {@link VerifyProofOptions}. * @returns The public key that was used to sign the proof, or `null` if the * proof is invalid. * @since 0.10.0 */ async function verifyProof(jsonLd, proof, options = {}) { const tracerProvider = options.tracerProvider ?? trace.getTracerProvider(); const tracer = tracerProvider.getTracer(deno_default.name, deno_default.version); return await tracer.startActiveSpan("object_integrity_proofs.verify", async (span) => { if (span.isRecording()) { if (proof.cryptosuite != null) span.setAttribute("object_integrity_proofs.cryptosuite", proof.cryptosuite); if (proof.verificationMethodId != null) span.setAttribute("object_integrity_proofs.key_id", proof.verificationMethodId.href); if (proof.proofValue != null) span.setAttribute("object_integrity_proofs.signature", encodeHex(proof.proofValue)); } try { const key = await verifyProofInternal(jsonLd, proof, options); if (key == null) span.setStatus({ code: SpanStatusCode.ERROR }); return key; } catch (error) { span.setStatus({ code: SpanStatusCode.ERROR, message: String(error) }); throw error; } finally { span.end(); } }); } async function verifyProofInternal(jsonLd, proof, options) { if (typeof jsonLd !== "object" || proof.cryptosuite !== "eddsa-jcs-2022" || proof.verificationMethodId == null || proof.proofPurpose !== "assertionMethod" || proof.proofValue == null || proof.created == null) return null; const publicKeyPromise = fetchKey(proof.verificationMethodId, Multikey, options); const proofConfig = { "@context": jsonLd["@context"], type: "DataIntegrityProof", cryptosuite: proof.cryptosuite, verificationMethod: proof.verificationMethodId.href, proofPurpose: proof.proofPurpose, created: proof.created.toString() }; const proofCanon = serialize(proofConfig); const encoder = new TextEncoder(); const proofBytes = encoder.encode(proofCanon); const proofDigest = await crypto.subtle.digest("SHA-256", proofBytes); const msg = { ...jsonLd }; if ("proof" in msg) delete msg.proof; const msgCanon = serialize(msg); const msgBytes = encoder.encode(msgCanon); const msgDigest = await crypto.subtle.digest("SHA-256", msgBytes); const digest = new Uint8Array(proofDigest.byteLength + msgDigest.byteLength); digest.set(new Uint8Array(proofDigest), 0); digest.set(new Uint8Array(msgDigest), proofDigest.byteLength); let fetchedKey; try { fetchedKey = await publicKeyPromise; } catch (error) { logger.debug("Failed to get the key (verificationMethod) for the proof:\n{proof}", { proof, keyId: proof.verificationMethodId.href, error }); return null; } const publicKey = fetchedKey.key; if (publicKey == null) { logger.debug("Failed to get the key (verificationMethod) for the proof:\n{proof}", { proof, keyId: proof.verificationMethodId.href }); return null; } if (publicKey.publicKey.algorithm.name !== "Ed25519") { if (fetchedKey.cached) { logger.debug("The cached key (verificationMethod) for the proof is not a valid Ed25519 key:\n{keyId}; retrying with the freshly fetched key...", { proof, keyId: proof.verificationMethodId.href }); return await verifyProof(jsonLd, proof, { ...options, keyCache: { get: () => Promise.resolve(null), set: async (keyId, key) => await options.keyCache?.set(keyId, key) } }); } logger.debug("The fetched key (verificationMethod) for the proof is not a valid Ed25519 key:\n{keyId}", { proof, keyId: proof.verificationMethodId.href }); return null; } const verified = await crypto.subtle.verify("Ed25519", publicKey.publicKey, proof.proofValue, digest); if (!verified) { if (fetchedKey.cached) { logger.debug("Failed to verify the proof with the cached key {keyId}; retrying with the freshly fetched key...", { keyId: proof.verificationMethodId.href, proof }); return await verifyProof(jsonLd, proof, { ...options, keyCache: { get: () => Promise.resolve(void 0), set: async (keyId, key) => await options.keyCache?.set(keyId, key) } }); } logger.debug("Failed to verify the proof with the fetched key {keyId}:\n{proof}", { keyId: proof.verificationMethodId.href, proof }); return null; } return publicKey; } /** * Verifies the given object. It will verify all the proofs in the object, * and succeed only if all the proofs are valid and all attributions and * actors are authenticated by the proofs. * @typeParam T The type of the object to verify. * @param cls The class of the object to verify. It must be a subclass of * the {@link Object}. * @param jsonLd The JSON-LD object to verify. It's assumed that the object * is a compacted JSON-LD representation of a `T` with `@context`. * @param options Additional options. See also {@link VerifyObjectOptions}. * @returns The object if it's verified, or `null` if it's not. * @throws {TypeError} If the object is invalid or unsupported. * @since 0.10.0 */ async function verifyObject(cls, jsonLd, options = {}) { const logger$2 = getLogger([ "fedify", "sig", "proof" ]); const object = await cls.fromJsonLd(jsonLd, options); const attributions = new Set(object.attributionIds.map((uri) => uri.href)); if (object instanceof Activity) for (const uri of object.actorIds) attributions.add(uri.href); for await (const proof of object.getProofs(options)) { const key = await verifyProof(jsonLd, proof, options); if (key === null) return null; if (key.controllerId == null) { logger$2.debug("Key {keyId} does not have a controller.", { keyId: key.id?.href }); continue; } attributions.delete(key.controllerId.href); } if (attributions.size > 0) { logger$2.debug("Some attributions are not authenticated by the proofs: {attributions}.", { attributions: [...attributions] }); return null; } return object; } //#endregion export { attachSignature, createProof, createSignature, detachSignature, doesActorOwnKey, getKeyOwner, hasSignature, signJsonLd, signObject, verifyJsonLd, verifyObject, verifyProof, verifySignature };