bun-activitypub/src/request.ts

152 lines
5.4 KiB
TypeScript
Raw Normal View History

import forge from "node-forge" // import crypto from "node:crypto"
import { PRIVATE_KEY } from "./env";
import ACTOR from "../actor"
// this function adds / modifies the appropriate headers for signing a request, then calls fetch
export function signedFetch(url: string | URL | Request, init?: FetchRequestInit): Promise<Response>
{
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.id}#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(url)).json()
}
/** Fetches and returns an object at a URL. */
export async function fetchObject(object_url:string) {
console.log(`fetch ${object_url}`)
const res = await signedFetch(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(recipient:string, message:any, from:string=ACTOR.id) {
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;
}