2023-09-16 00:28:06 +00:00
|
|
|
import forge from "node-forge" // import crypto from "node:crypto"
|
2023-09-28 01:15:25 +00:00
|
|
|
import { PRIVATE_KEY } from "./env";
|
|
|
|
|
import ACTOR from "../actor"
|
2023-09-16 00:28:06 +00:00
|
|
|
|
2023-09-26 23:49:32 +00:00
|
|
|
export const activityMimeTypes:string[] = ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"']
|
|
|
|
|
|
2023-09-16 00:28:06 +00:00
|
|
|
export function reqIsActivityPub(req:Request) {
|
|
|
|
|
const contentType = req.headers.get("Accept")
|
2023-09-26 23:49:32 +00:00
|
|
|
const activityPub = contentType?.includes('application/activity+json')
|
2023-09-16 00:28:06 +00:00
|
|
|
|| contentType?.includes('application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
|
|
|
|
|
|| contentType?.includes('application/ld+json')
|
2023-09-26 23:49:32 +00:00
|
|
|
return activityPub
|
2023-09-16 00:28:06 +00:00
|
|
|
}
|
|
|
|
|
|
2023-09-26 23:49:32 +00:00
|
|
|
// 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
|
2023-09-20 06:43:48 +00:00
|
|
|
|
2023-09-26 23:49:32 +00:00
|
|
|
if(!init) init = {}
|
|
|
|
|
const headers:any = init.headers || {}
|
|
|
|
|
const method = init.method || (url instanceof Request ? url.method : 'GET')
|
2023-09-20 06:43:48 +00:00
|
|
|
|
2023-09-26 23:49:32 +00:00
|
|
|
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();
|
2023-09-20 06:43:48 +00:00
|
|
|
|
2023-09-26 23:49:32 +00:00
|
|
|
const key = forge.pki.privateKeyFromPem(PRIVATE_KEY)
|
2023-09-20 06:43:48 +00:00
|
|
|
|
2023-09-26 23:49:32 +00:00
|
|
|
const dataObj:any = { }
|
|
|
|
|
dataObj['(request-target)'] = `${method.toLowerCase()} ${path}`
|
|
|
|
|
dataObj.host = urlObj.hostname
|
|
|
|
|
dataObj.date = d.toUTCString()
|
|
|
|
|
if(digest) dataObj.digest = `SHA-256=${digest}`
|
2023-09-20 06:43:48 +00:00
|
|
|
|
2023-09-26 23:49:32 +00:00
|
|
|
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 = {
|
2023-09-28 01:15:25 +00:00
|
|
|
keyId: `${ACTOR.id}#main-key`,
|
2023-09-26 23:49:32 +00:00
|
|
|
headers: Object.keys(dataObj).join(' '),
|
|
|
|
|
signature: signature
|
|
|
|
|
}
|
2023-09-20 06:43:48 +00:00
|
|
|
|
2023-09-26 23:49:32 +00:00
|
|
|
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"'
|
2023-09-20 06:43:48 +00:00
|
|
|
|
2023-09-26 23:49:32 +00:00
|
|
|
return fetch(url, {...init, headers })
|
|
|
|
|
}
|
2023-09-20 06:43:48 +00:00
|
|
|
|
2023-09-26 23:49:32 +00:00
|
|
|
/** Fetches and returns an actor at a URL. */
|
|
|
|
|
async function fetchActor(url:string) {
|
2023-09-28 01:15:25 +00:00
|
|
|
return await (await fetchObject(url)).json()
|
2023-09-26 23:49:32 +00:00
|
|
|
}
|
2023-09-20 06:43:48 +00:00
|
|
|
|
2023-09-26 23:49:32 +00:00
|
|
|
/** Fetches and returns an object at a URL. */
|
2023-09-28 01:15:25 +00:00
|
|
|
export async function fetchObject(object_url:string) {
|
2023-09-26 23:49:32 +00:00
|
|
|
console.log(`fetch ${object_url}`)
|
2023-09-20 06:43:48 +00:00
|
|
|
|
2023-09-26 23:49:32 +00:00
|
|
|
const res = await fetch(object_url);
|
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.
|
|
|
|
|
*/
|
2023-09-28 01:15:25 +00:00
|
|
|
export async function send(recipient:string, message:any, from:string=ACTOR.id) {
|
2023-09-20 06:43:48 +00:00
|
|
|
console.log(`Sending to ${recipient}`, message)
|
2023-09-21 07:04:27 +00:00
|
|
|
// TODO: revisit fetch actor to use webfinger to get the inbox maybe?
|
2023-09-16 00:28:06 +00:00
|
|
|
const actor = await fetchActor(recipient)
|
|
|
|
|
|
|
|
|
|
const body = JSON.stringify(message)
|
|
|
|
|
|
2023-09-26 23:49:32 +00:00
|
|
|
const res = await signedFetch(actor.inbox, {
|
2023-09-16 00:28:06 +00:00
|
|
|
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;
|
|
|
|
|
}
|