@fedify/fedify
Version:
An ActivityPub server framework
289 lines (288 loc) • 12.2 kB
JavaScript
import * as dntShim from "../_dnt.shims.js";
import { getLogger } from "@logtape/logtape";
import { SpanStatusCode, trace } from "@opentelemetry/api";
import { decodeBase64, encodeBase64 } from "../deps/jsr.io/@std/encoding/1.0.7/base64.js";
import { encodeHex } from "../deps/jsr.io/@std/encoding/1.0.7/hex.js";
// @ts-ignore TS7016
import jsonld from "jsonld";
import metadata from "../deno.js";
import { getDocumentLoader, } from "../runtime/docloader.js";
import { getTypeId } from "../vocab/type.js";
import { Activity, CryptographicKey, Object } from "../vocab/vocab.js";
import { fetchKey, validateCryptoKey } from "./key.js";
const logger = 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
*/
export 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
*/
export 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() ?? 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 dntShim.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
*/
export async function signJsonLd(jsonLd, privateKey, keyId, options) {
const tracerProvider = options.tracerProvider ?? trace.getTracerProvider();
const tracer = tracerProvider.getTracer(metadata.name, metadata.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
*/
export 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
*/
export 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
*/
export async function verifySignature(jsonLd, options = {}) {
if (!hasSignature(jsonLd))
return null;
const sig = jsonLd.signature;
let signature;
try {
signature = decodeBase64(sig.signatureValue);
}
catch (error) {
logger.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.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.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 dntShim.crypto.subtle.verify("RSASSA-PKCS1-v1_5", key.publicKey, signature, messageBytes);
if (verified)
return key;
if (cached) {
logger.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 fetchKey(new URL(sig.creator), CryptographicKey, {
...options,
keyCache: {
get: () => Promise.resolve(undefined),
set: async (keyId, key) => await options.keyCache?.set(keyId, key),
},
});
if (key == null)
return null;
const verified = await dntShim.crypto.subtle.verify("RSASSA-PKCS1-v1_5", key.publicKey, signature, messageBytes);
return verified ? key : null;
}
logger.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.
*/
export async function verifyJsonLd(jsonLd, options = {}) {
const tracerProvider = options.tracerProvider ?? trace.getTracerProvider();
const tracer = tracerProvider.getTracer(metadata.name, metadata.version);
return await tracer.startActiveSpan("ld_signatures.verify", async (span) => {
try {
const object = await Object.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.debug("Key {keyId} has no owner.", { keyId: key.id?.href });
return false;
}
attributions.delete(key.ownerId.href);
if (attributions.size > 0) {
logger.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 dntShim.crypto.subtle.digest("SHA-256", encoder.encode(canon));
return encodeHex(hash);
}
// cSpell: ignore URGNA2012