bun-activitypub/src/request.ts

132 lines
4.4 KiB
TypeScript
Raw Normal View History

import forge from "node-forge" // import crypto from "node:crypto"
import { 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) {
const res = await fetch(url, {
headers: { accept: "application/activity+json" },
});
if (res.status < 200 || 299 < res.status) {
throw new Error(`Received ${res.status} fetching actor.`);
}
return res.json();
}
/** 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) {
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;
}