import forge from "node-forge" // import crypto from "node:crypto" import { ACTOR, PRIVATE_KEY } from "./env"; export const activityMimeTypes:string[] = ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'] export function reqIsActivityPub(req:Request) { const contentType = req.headers.get("Accept") const activityPub = contentType?.includes('application/activity+json') || contentType?.includes('application/ld+json; profile="https://www.w3.org/ns/activitystreams"') || contentType?.includes('application/ld+json') return activityPub } // this function adds / modifies the appropriate headers for signing a request, then calls fetch export function signedFetch(url: string | URL | Request, init?: FetchRequestInit): Promise { const urlObj = typeof url === 'string' ? new URL(url) : url instanceof Request ? new URL(url.url) : url if(!init) init = {} const headers:any = init.headers || {} const method = init.method || (url instanceof Request ? url.method : 'GET') const path = urlObj.pathname + urlObj.search + urlObj.hash const body = init.body ? (typeof init.body === 'string' ? init.body : JSON.stringify(init?.body)) : null const digest = body ? new Bun.CryptoHasher("sha256").update(body).digest("base64") : null const d = new Date(); const key = forge.pki.privateKeyFromPem(PRIVATE_KEY) const dataObj:any = { } dataObj['(request-target)'] = `${method.toLowerCase()} ${path}` dataObj.host = urlObj.hostname dataObj.date = d.toUTCString() if(digest) dataObj.digest = `SHA-256=${digest}` const data = Object.entries(dataObj).map(([key, value]) => `${key}: ${value}`).join('\n') const signature = forge.util.encode64(key.sign(forge.md.sha256.create().update(data))) const signatureObj = { keyId: `${ACTOR}#main-key`, headers: Object.keys(dataObj).join(' '), signature: signature } headers.host = urlObj.hostname headers.date = d.toUTCString() if(digest) headers.digest = `SHA-256=${digest}` headers.signature = Object.entries(signatureObj).map(([key,value]) => `${key}="${value}"`).join(',') headers.accept = 'application/ld+json; profile="http://www.w3.org/ns/activitystreams"' if(body) headers["Content-Type"] = 'application/ld+json; profile="http://www.w3.org/ns/activitystreams"' return fetch(url, {...init, headers }) } /** Fetches and returns an actor at a URL. */ async function fetchActor(url:string) { return await (await fetchObject(ACTOR, url)).json() } /** Fetches and returns an object at a URL. */ export async function fetchObject(sender:string, object_url:string) { console.log(`fetch ${object_url}`) const res = await fetch(object_url); 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) // TODO: revisit fetch actor to use webfinger to get the inbox maybe? const actor = await fetchActor(recipient) const body = JSON.stringify(message) const res = await signedFetch(actor.inbox, { method: "POST", 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; }