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