diff --git a/.vscode/launch.json b/.vscode/launch.json index d8ad498..759635f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,7 +7,7 @@ { "type": "node", "request": "launch", - "name": "Generate", + "name": "Run", "program": "${workspaceFolder}/lib/index.ts", "preLaunchTask": "tsc: build - tsconfig.json", "outFiles": [ diff --git a/lib/activitypub/activity.ts b/lib/activitypub/activity.ts index 3c0d13d..365fded 100644 --- a/lib/activitypub/activity.ts +++ b/lib/activitypub/activity.ts @@ -37,4 +37,7 @@ export interface Note extends Activity { export interface Actor { id: string; inbox: string; + publicKey: { + publicKeyPem: string; + }; } \ No newline at end of file diff --git a/lib/activitypub/actor.ts b/lib/activitypub/actor.ts index a259547..7d193e8 100644 --- a/lib/activitypub/actor.ts +++ b/lib/activitypub/actor.ts @@ -3,9 +3,7 @@ import { promises as fs } from "fs"; const domain = process.env.DOMAIN; -export default async function actor(): Promise { - const router = Router(); - +export default async function actor(router: Router) { const pubKeyPem = (await fs.readFile(process.env.PUB_KEY_PEM!)).toString(); const actorObj = { "@context": [ @@ -39,6 +37,4 @@ export default async function actor(): Promise { res.redirect("/"); } }); - - return router; } \ No newline at end of file diff --git a/lib/activitypub/articles.ts b/lib/activitypub/articles.ts index 00f47c4..8608db1 100644 --- a/lib/activitypub/articles.ts +++ b/lib/activitypub/articles.ts @@ -54,9 +54,7 @@ export async function toFederate(db: Database): Promise<[string, Article][]> { }); } -export function router(): Router { - const router = Router(); - +export function route(router: Router) { router.use("/:category/:year/:slug/", (req, res, next) => { if (req.accepts("text/html")) { next(); @@ -74,6 +72,4 @@ export function router(): Router { }); } }); - - return router; } \ No newline at end of file diff --git a/lib/activitypub/federate.ts b/lib/activitypub/federate.ts index 517f75d..5f45dc5 100644 --- a/lib/activitypub/federate.ts +++ b/lib/activitypub/federate.ts @@ -49,9 +49,8 @@ export async function signAndSend(activity: Activity, inbox: string) { const stringToSign = `(request-target): post ${inboxFragment}\nhost: ${targetDomain}\ndate: ${date.toUTCString()}`; signer.update(stringToSign); signer.end(); - const signature = signer.sign(privKey); - const base64Signature = signature.toString("base64"); - const header = `keyId="https://${domain}/ap/actor#main-key",headers="(request-target) host date",signature="${base64Signature}"`; + const signature = signer.sign(privKey, "base64"); + const header = `keyId="https://${domain}/ap/actor#main-key",headers="(request-target) host date",signature="${signature}"`; console.log("Sending:", activity); console.log("stringToSign:", stringToSign); console.log("Signature: " + header); diff --git a/lib/activitypub/followers.ts b/lib/activitypub/followers.ts index 13d7c9a..df09ae3 100644 --- a/lib/activitypub/followers.ts +++ b/lib/activitypub/followers.ts @@ -3,9 +3,7 @@ import { Database } from "sqlite3"; const domain = process.env.DOMAIN; -export default function followers(): Router { - const router = Router(); - +export default function followers(router: Router) { router.get("/ap/actor/followers", (req, res) => { const db = req.app.get("db"); @@ -22,6 +20,4 @@ export default function followers(): Router { res.end(); }); }); - - return router; } \ No newline at end of file diff --git a/lib/activitypub/inbox.ts b/lib/activitypub/inbox.ts index 5839013..e960b37 100644 --- a/lib/activitypub/inbox.ts +++ b/lib/activitypub/inbox.ts @@ -7,13 +7,9 @@ import { URL } from "url"; const domain = process.env.DOMAIN; -export default function inbox(): Router { - const router = Router(); - +export default function inbox(router: Router) { router.post("/ap/inbox", handleInbox); router.post("/inbox", handleInbox); - - return router; } async function handleInbox(req: Request, res: Response) { diff --git a/lib/activitypub/middleware/http-signature.ts b/lib/activitypub/middleware/http-signature.ts new file mode 100644 index 0000000..bb534be --- /dev/null +++ b/lib/activitypub/middleware/http-signature.ts @@ -0,0 +1,44 @@ +import crypto, { createVerify } from "crypto"; +import { Request, Response, NextFunction } from "express"; +import { fetchActor } from "../federate"; +import { IncomingHttpHeaders } from "http"; + +export = async (req: Request, res: Response, next: NextFunction) => { + if (req.method !== "POST") { + next(); + return; + } + const actor = await fetchActor(req.body.actor as string); + if (validate(req, actor.publicKey.publicKeyPem)) { + next(); + } else { + console.log(`Could not validate HTTP signature for ${req.body.actor}`); + res.status(401).end("Could not validate HTTP signature"); + } +}; + +function validate(req: Request, publicKeyPem: string): boolean { + const signature = parseSignature(req.header("signature")!); + const usedHeaders = signature.get("headers")!.split(/\s/); + const signingString = usedHeaders.map(header => { + const value = header === "(request-target)" ? `${req.method} ${req.path}`.toLowerCase() : req.header(header); + return `${header}: ${value}`; + }).join("\n"); + const verifier = crypto.createVerify("sha256"); + verifier.update(signingString); + verifier.end(); + return verifier.verify(publicKeyPem, signature.get("signature")!, "base64"); +} + +function parseSignature(signature: string): Map { + const map = new Map(); + map.set("headers", "date"); + for (const part of signature.split(",")) { + const index = part.indexOf("="); + const key = part.substring(0, index); + const value = part.substring(index + 1); + const unquoted = value.replace(/^"+|"+$/g, ""); // strip leading and trailing quotes + map.set(key, unquoted); + } + return map; +} \ No newline at end of file diff --git a/lib/activitypub/webfinger.ts b/lib/activitypub/webfinger.ts index 9d9b2ef..383a3d2 100644 --- a/lib/activitypub/webfinger.ts +++ b/lib/activitypub/webfinger.ts @@ -2,9 +2,7 @@ import express, { Router } from "express"; const domain = process.env.DOMAIN; -export default function webfinger(): Router { - const router = Router(); - +export default function webfinger(router: Router) { router.get("/.well-known/webfinger", (req, res) => { res.json({ "subject": `acct:shadowfacts@${domain}`, @@ -18,6 +16,4 @@ export default function webfinger(): Router { }); res.end(); }); - - return router; } \ No newline at end of file diff --git a/lib/index.ts b/lib/index.ts index 892d13f..9be5390 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,10 +1,11 @@ import { Page } from "./metadata"; import generators from "./generate"; -import express from "express"; +import express, { Router } from "express"; import morgan from "morgan"; import bodyParser from "body-parser"; import activitypub from "./activitypub"; +import validateHttpSig from "./activitypub/middleware/http-signature"; import sqlite3 from "sqlite3"; @@ -43,12 +44,14 @@ app.use(bodyParser.json({ type: "application/activity+json" })); await activitypub.articles.setup(posts, db); const toFederate = await activitypub.articles.toFederate(db); - - app.use(await activitypub.actor()); - app.use(activitypub.followers()); - app.use(activitypub.inbox()); - app.use(activitypub.webfinger()); - app.use(activitypub.articles.router()); + const apRouter = Router(); + apRouter.use(validateHttpSig); + await activitypub.actor(apRouter); + activitypub.followers(apRouter); + activitypub.inbox(apRouter); + activitypub.webfinger(apRouter); + activitypub.articles.route(apRouter); + app.use(apRouter); app.use(express.static("out")); const port = process.env.PORT || 8083;