import forge from "node-forge" // import crypto from "node:crypto" import { ACTOR, PRIVATE_KEY } from "./env"; export function reqIsActivityPub(req:Request) { const contentType = req.headers.get("Accept") return contentType?.includes('application/activity+json') || contentType?.includes('application/ld+json; profile="https://www.w3.org/ns/activitystreams"') || contentType?.includes('application/ld+json') } /** Fetches and returns an actor at a URL. */ async function fetchActor(url:string) { return (await fetchObject(ACTOR, url)).json() } /** Fetches and returns an object at a URL. */ // export async function fetchObject(url:string) { // const res = await fetch(url, { // headers: { accept: "application/activity+json" }, // }); // if (res.status < 200 || 299 < res.status) { // throw new Error(`Received ${res.status} fetching object.`); // } // return res.json(); // } /** Fetches and returns an object at a URL. */ export async function fetchObject(sender:string, object_url:string) { console.log(`fetch ${object_url}`) const url = new URL(object_url) const path = url.pathname const d = new Date(); const key = forge.pki.privateKeyFromPem(PRIVATE_KEY) const data = [ `(request-target): get ${path}`, `host: ${url.hostname}`, `date: ${d.toUTCString()}` ].join("\n") const signature = forge.util.encode64(key.sign(forge.md.sha256.create().update(data))) const res = await fetch(object_url, { method: "GET", headers: { host: url.hostname, date: d.toUTCString(), "content-type": "application/json", signature: `keyId="${sender}#main-key",headers="(request-target) host date",signature="${signature}"`, accept: "application/json", } }); if (res.status < 200 || 299 < res.status) { throw new Error(res.statusText + ": " + (await res.text())); } return res; } /** Sends a signed message from the sender to the recipient. * @param sender The sender's actor URL. * @param recipient The recipient's actor URL. * @param message the body of the request to send. */ export async function send(sender:string, recipient:string, message:any) { console.log(`Sending to ${recipient}`, message) const url = new URL(recipient) const actor = await fetchActor(recipient) const path = actor.inbox.replace("https://" + url.hostname, "") const body = JSON.stringify(message) const digest = new Bun.CryptoHasher("sha256").update(body).digest("base64") const d = new Date(); const key = forge.pki.privateKeyFromPem(PRIVATE_KEY) const data = [ `(request-target): post ${path}`, `host: ${url.hostname}`, `date: ${d.toUTCString()}`, `digest: SHA-256=${digest}`, ].join("\n") const signature = forge.util.encode64(key.sign(forge.md.sha256.create().update(data))) const res = await fetch(actor.inbox, { method: "POST", headers: { host: url.hostname, date: d.toUTCString(), digest: `SHA-256=${digest}`, "content-type": "application/json", signature: `keyId="${sender}#main-key",headers="(request-target) host date digest",signature="${signature}"`, accept: "application/json", }, body, }); if (res.status < 200 || 299 < res.status) { throw new Error(res.statusText + ": " + (await res.text())); } return res; } /** Verifies that a request came from an actor. * Returns the actor's ID if the verification succeeds; throws otherwise. * @param req An Express request. * @returns The actor's ID. */ export async function verify(req:Request, body:string) { // get headers included in signature const included:any = {} for (const header of req.headers.get("signature")?.split(",") ?? []) { const [key, value] = header.split("=") if (!key || !value) continue included[key] = value.replace(/^"|"$/g, "") } /** the URL of the actor document containing the signature's public key */ const keyId = included.keyId if (!keyId) throw new Error(`Missing "keyId" in signature header.`) /** the signed request headers */ const signedHeaders = included.headers if (!signedHeaders) throw new Error(`Missing "headers" in signature header.`) /** the signature itself */ const signature = Buffer.from(included.signature ?? "", "base64") if (!signature) throw new Error(`Missing "signature" in signature header.`) // ensure that the digest header matches the digest of the body const digestHeader = req.headers.get("digest") if (digestHeader) { const digestBody = new Bun.CryptoHasher("sha256").update(body).digest("base64") if (digestHeader !== "SHA-256=" + digestBody) { throw new Error(`Incorrect digest header.`) } } // get the actor's public key const actor = await fetchActor(keyId); if (!actor.publicKey) throw new Error("No public key found.") const key = forge.pki.publicKeyFromPem(actor.publicKey.publicKeyPem) // reconstruct the signed header string const url = new URL(req.url) const comparison:string = signedHeaders .split(" ") .map((header:any) => { if (header === "(request-target)") return "(request-target): post " + url.pathname return `${header}: ${req.headers.get(header)}` }) .join("\n") const data = Buffer.from(comparison); // verify the signature against the headers using the actor's public key const verified = key.verify(forge.sha256.create().update(comparison).digest().bytes(), signature.toString('binary')) if (!verified) throw new Error("Invalid request signature.") // ensure the request was made recently const now = new Date(); const date = new Date(req.headers.get("date") ?? 0); if (now.getTime() - date.getTime() > 30_000) throw new Error("Request date too old."); return actor.id; }