2023-09-16 00:28:06 +00:00
|
|
|
import forge from "node-forge" // import crypto from "node:crypto"
|
2023-09-20 06:43:48 +00:00
|
|
|
import { ACTOR, PRIVATE_KEY } from "./env";
|
2023-09-16 00:28:06 +00:00
|
|
|
|
|
|
|
|
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) {
|
2023-09-20 06:43:48 +00:00
|
|
|
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",
|
|
|
|
|
}
|
2023-09-16 00:28:06 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (res.status < 200 || 299 < res.status) {
|
2023-09-20 06:43:48 +00:00
|
|
|
throw new Error(res.statusText + ": " + (await res.text()));
|
2023-09-16 00:28:06 +00:00
|
|
|
}
|
2023-09-20 06:43:48 +00:00
|
|
|
|
|
|
|
|
return res;
|
2023-09-16 00:28:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 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) {
|
2023-09-20 06:43:48 +00:00
|
|
|
console.log(`Sending to ${recipient}`, message)
|
2023-09-16 00:28:06 +00:00
|
|
|
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;
|
|
|
|
|
}
|