@fedify/fedify
Version:
An ActivityPub server framework
287 lines (286 loc) • 12.6 kB
JavaScript
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 };