UNPKG

@fedify/fedify

Version:

An ActivityPub server framework

287 lines (286 loc) • 12.6 kB
import { Temporal } from "@js-temporal/polyfill"; import "urlpattern-polyfill"; globalThis.addEventListener = () => {}; import { n as version, t as name } from "./deno-DMg4SgCb.mjs"; import { n as fetchKey, o as validateCryptoKey } from "./key-BAQuZEU1.mjs"; import { n as preloadedOnlyDocumentLoader } from "./public-audience-DYFHzm_c.mjs"; import { r as normalizeOutgoingActivityJsonLd } from "./outgoing-jsonld-CNmZLixq.mjs"; import { Activity, DataIntegrityProof, Multikey, getTypeId } from "@fedify/vocab"; import { SpanStatusCode, trace } from "@opentelemetry/api"; import { getLogger } from "@logtape/logtape"; import { encodeHex } from "byte-encodings/hex"; import serialize from "json-canon"; //#region src/sig/proof.ts const logger = 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 } = {}) { 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 = 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 proofCanon = serialize({ "@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 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 = {}) { return await (options.tracerProvider ?? trace.getTracerProvider()).getTracer(name, version).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 of object.getProofs(options)) existingProofs.push(proof); 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 = {}) { return await (options.tracerProvider ?? trace.getTracerProvider()).getTracer(name, version).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" || jsonLd == null || Array.isArray(jsonLd) || 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 encoder = new TextEncoder(); const proofBytes = encoder.encode(serialize(proofConfig)); const proofDigest = await crypto.subtle.digest("SHA-256", proofBytes); const msg = { ...jsonLd }; if ("proof" in msg) delete msg.proof; if ("https://w3id.org/security#proof" in msg) delete msg["https://w3id.org/security#proof"]; 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(void 0), 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 digest = new Uint8Array(proofDigest.byteLength + 32); digest.set(new Uint8Array(proofDigest), 0); const proofValue = proof.proofValue; const verifyCandidate = async (candidate) => { const msgBytes = encoder.encode(serialize(candidate)); const msgDigest = await crypto.subtle.digest("SHA-256", msgBytes); digest.set(new Uint8Array(msgDigest), proofDigest.byteLength); return await crypto.subtle.verify("Ed25519", publicKey.publicKey, proofValue.slice(), digest); }; if (await verifyCandidate(msg)) return publicKey; const normalized = await normalizeOutgoingActivityJsonLd(msg, preloadedOnlyDocumentLoader); if (normalized !== msg && await verifyCandidate(normalized)) return publicKey; 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; } /** * 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. * @template 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 = 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.debug("Key {keyId} does not have a controller.", { keyId: key.id?.href }); continue; } attributions.delete(key.controllerId.href); } if (attributions.size > 0) { logger.debug("Some attributions are not authenticated by the proofs: {attributions}.", { attributions: [...attributions] }); return null; } return object; } //#endregion export { verifyProof as a, verifyObject as i, hasProofLike as n, signObject as r, createProof as t };