Initial commit

Code based on (but not identical to) https://github.com/jakelazaroff/activitypub-starter-kit
Instead of using node/express, it's using Bun/bun's inbuilt server
Right now it can follow/unfollow (be followed/unfollowed) and create notes which get sent to followers
No UI yet
Stores posts as markdown with YAML frontmatter
Stores activities for creating those posts as json
Stores following/followers in json files
This commit is contained in:
Gordon Pedersen 2023-09-16 10:28:06 +10:00
commit 6f3e2f65ad
15 changed files with 881 additions and 0 deletions

169
.gitignore vendored Normal file
View file

@ -0,0 +1,169 @@
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
\*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
\*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
\*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
\*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.\*

53
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,53 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "bun",
"request": "launch",
"name": "Debug Bun",
// The path to a JavaScript or TypeScript file to run.
"program": "src/index.ts",
// The arguments to pass to the program, if any.
"args": [],
// The working directory of the program.
"cwd": "${workspaceFolder}",
// The environment variables to pass to the program.
"env": {},
// If the environment variables should not be inherited from the parent process.
"strictEnv": false,
// If the program should be run in watch mode.
// This is equivalent to passing `--watch` to the `bun` executable.
// You can also set this to "hot" to enable hot reloading using `--hot`.
"watchMode": false,
// If the debugger should stop on the first line of the program.
"stopOnEntry": false,
// If the debugger should be disabled. (for example, breakpoints will not be hit)
"noDebug": false,
// The path to the `bun` executable, defaults to your `PATH` environment variable.
"runtime": "bun",
// The arguments to pass to the `bun` executable, if any.
// Unlike `args`, these are passed to the executable itself, not the program.
"runtimeArgs": ["--watch"],
},
{
"type": "bun",
"request": "attach",
"name": "Attach to Bun",
// The URL of the WebSocket inspector to attach to.
// This value can be retreived by using `bun --inspect`.
"url": "ws://localhost:6499/",
}
]
}

15
README.md Normal file
View file

@ -0,0 +1,15 @@
# bun-activitypub
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.0.0. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.

View file

@ -0,0 +1 @@
[]

View file

@ -0,0 +1 @@
[]

0
_content/posts/.gitkeep Normal file
View file

BIN
bun.lockb Executable file

Binary file not shown.

21
package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "bun-activitypub",
"module": "index.ts",
"type": "module",
"scripts": {
"start": "bun run --watch src/index.ts"
},
"devDependencies": {
"@types/node-forge": "^1.3.5",
"@types/yaml": "^1.9.7",
"bun-types": "latest"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"gray-matter": "^4.0.3",
"node-forge": "^1.3.1",
"yaml": "^2.3.2"
}
}

187
src/activitypub.ts Normal file
View file

@ -0,0 +1,187 @@
import { ACCOUNT, ACTOR, HOSTNAME, PUBLIC_KEY } from "./env"
import * as db from "./db"
import { reqIsActivityPub, send, verify } from "./request"
export default (req: Request): Response | Promise<Response> | undefined => {
const url = new URL(req.url)
let match
if(req.method === "GET" && url.pathname === "/test") return new Response("", { status: 204 })
else if(req.method == "POST" && (match = url.pathname.match(/^\/([^\/]+)\/inbox\/?$/i))) return postInbox(req, match[1])
else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/outbox\/?$/i))) return getOutbox(req, match[1])
else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/followers\/?$/i))) return getFollowers(req, match[1])
else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/following\/?$/i))) return getFollowing(req, match[1])
else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/?$/i))) return getActor(req, match[1])
else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/posts\/([^\/]+)\/?$/i))) return getPost(req, match[1], match[2])
else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/posts\/([^\/]+)\/activity\/?$/i))) return getActivity(req, match[1], match[2])
return undefined
}
const postInbox = async (req:Request, account:string):Promise<Response> => {
console.log("PostInbox", account)
if (ACCOUNT !== account) return new Response("", { status: 404 })
const bodyText = await req.text()
/** If the request successfully verifies against the public key, `from` is the actor who sent it. */
let from = "";
try {
// verify the signed HTTP request
from = await verify(req, bodyText);
} catch (err) {
console.error(err);
return new Response("", { status: 401 })
}
const body = JSON.parse(bodyText)
// ensure that the verified actor matches the actor in the request body
if (from !== body.actor) return new Response("", { status: 401 })
console.log(body)
// TODO: add support for more types! we want replies, likes, boosts, etc!
switch (body.type) {
case "Follow": await follow(body);
case "Undo": await undo(body);
case "Accept": await accept(body);
}
return new Response("", { status: 204 })
}
const follow = async (body:any) => {
await send(ACTOR, body.actor, {
"@context": "https://www.w3.org/ns/activitystreams",
id: `https://${HOSTNAME}/${crypto.randomUUID()}`, // TODO: de-randomise this?
type: "Accept",
actor: ACTOR,
object: body,
});
await db.createFollower(body.actor, body.id);
}
const undo = async (body:any) => {
switch (body.object.type) {
case "Follow": await db.deleteFollower(body.actor); break
}
}
const accept = async (body:any) => {
switch (body.object.type) {
case "Follow": await db.acceptFollowing(body.actor); break
}
}
const getOutbox = async (req:Request, account:string):Promise<Response> => {
console.log("GetOutbox", account)
if (ACCOUNT !== account) return new Response("", { status: 404 })
const posts = await db.listActivities()
return Response.json({
"@context": "https://www.w3.org/ns/activitystreams",
id: `${ACTOR}/outbox`,
type: "OrderedCollection",
totalItems: posts.length,
orderedItems: posts.map((post) => ({
...post,
actor: ACTOR
})).sort( (a,b) => new Date(b.published).getTime() - new Date(a.published).getTime())
}, { headers: { "Content-Type": "application/activity+json"} })
}
const getFollowers = async (req:Request, account:String):Promise<Response> => {
console.log("GetFollowers", account)
if (ACCOUNT !== account) return new Response("", { status: 404 })
const url = new URL(req.url)
const page = url.searchParams.get("page")
const followers = await db.listFollowers()
if(!page) return Response.json({
"@context": "https://www.w3.org/ns/activitystreams",
id: `${ACTOR}/followers`,
type: "OrderedCollection",
totalItems: followers.length,
first: `${ACTOR}/followers?page=1`,
})
else return Response.json({
"@context": "https://www.w3.org/ns/activitystreams",
id: `${ACTOR}/followers?page=${page}`,
type: "OrderedCollectionPage",
partOf: `${ACTOR}/followers`,
totalItems: followers.length,
orderedItems: followers.map(follower => follower.actor)
})
}
const getFollowing = async (req:Request, account:String):Promise<Response> => {
console.log("GetFollowing", account)
if (ACCOUNT !== account) return new Response("", { status: 404 })
const url = new URL(req.url)
const page = url.searchParams.get("page")
const following = await db.listFollowing()
if(!page) return Response.json({
"@context": "https://www.w3.org/ns/activitystreams",
id: `${ACTOR}/following`,
type: "OrderedCollection",
totalItems: following.length,
first: `${ACTOR}/following?page=1`,
})
else return Response.json({
"@context": "https://www.w3.org/ns/activitystreams",
id: `${ACTOR}/following?page=${page}`,
type: "OrderedCollectionPage",
partOf: `${ACTOR}/following`,
totalItems: following.length,
orderedItems: following.map(follow => follow.actor)
})
}
const getActor = async (req:Request, account:string):Promise<Response> => {
console.log("GetActor", account)
if (ACCOUNT !== account) return new Response("", { status: 404 })
if(reqIsActivityPub(req)) return Response.json({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
],
id: ACTOR,
type: "Person",
preferredUsername: ACCOUNT,
url: ACTOR,
manuallyApprovesFollowers: false,
discoverable: true,
published: "2023-09-14T00:00:00Z",
inbox: `${ACTOR}/inbox`,
outbox: `${ACTOR}/outbox`,
followers: `${ACTOR}/followers`,
following: `${ACTOR}/following`,
publicKey: {
id: `${ACTOR}#main-key`,
owner: ACTOR,
publicKeyPem: PUBLIC_KEY,
},
}, { headers: { "Content-Type": "application/activity+json"}})
else return Response.json(await db.listPosts())
}
const getPost = async (req:Request, account:string, id:string):Promise<Response> => {
console.log("GetPost", account, id)
if (ACCOUNT !== account) return new Response("", { status: 404 })
if(reqIsActivityPub(req)) return Response.json((await db.getActivity(id)).object, { headers: { "Content-Type": "application/activity+json"}})
else return Response.json(await db.getPost(id))
}
const getActivity = async (req:Request, account:string, id:string):Promise<Response> => {
console.log("GetActivity", account, id)
if (ACCOUNT !== account) return new Response("", { status: 404 })
return Response.json((await db.getActivity(id)), { headers: { "Content-Type": "application/activity+json"}})
}

115
src/admin.ts Normal file
View file

@ -0,0 +1,115 @@
import { createFollowing, deleteFollowing, doActivity, getFollowing, listFollowers } from "./db"
import { ACTOR, ADMIN_PASSWORD, ADMIN_USERNAME, BASE_URL } from "./env"
import { send } from "./request"
export default (req: Request): Response | Promise<Response> | undefined => {
const url = new URL(req.url)
if(!url.pathname.startsWith('/admin')) return undefined
url.pathname = url.pathname.substring(6)
if(ADMIN_USERNAME && ADMIN_PASSWORD && !checkAuth(req.headers)) return new Response("", { status: 401 })
let match
if(req.method === "GET" && (match = url.pathname.match(/^\/test\/?$/i))) return new Response("", { status: 204 })
else if(req.method == "POST" && (match = url.pathname.match(/^\/create\/?$/i))) return create(req)
else if(req.method == "POST" && (match = url.pathname.match(/^\/follow\/([^\/]+)\/?$/i))) return follow(req, match[1])
else if(req.method == "DELETE" && (match = url.pathname.match(/^\/follow\/([^\/]+)\/?$/i))) return unfollow(req, match[1])
return undefined
}
const checkAuth = (headers: Headers): Boolean => {
// check the basic auth header
const auth = headers.get("Authorization")
const split = auth?.split("")
if(!split || split.length != 2 || split[0] !== "Basic") return false
const decoded = atob(split[1])
const [username, password] = decoded.split(":")
return username === ADMIN_USERNAME && password === ADMIN_PASSWORD
}
// create an activity
const create = async (req:Request):Promise<Response> => {
const body = await req.json()
// create the object, merging in supplied data
const date = new Date()
const id = date.getTime().toString(16)
const object = {
attributedTo: ACTOR,
published: date.toISOString(),
to: ["https://www.w3.org/ns/activitystreams#Public"],
cc: [`${ACTOR}/followers`],
url: `${ACTOR}/post/${id}`,
id: `${ACTOR}/post/${id}`,
...body.object
}
const activity = {
"@context": "https://www.w3.org/ns/activitystreams",
id: `${ACTOR}/post/${id}/activity`,
type: "Create",
published: date.toISOString(),
actor: ACTOR,
to: ["https://www.w3.org/ns/activitystreams#Public"],
cc: [`${ACTOR}/followers`],
...body,
object: { ...object }
}
// TODO: actually create the object (and the activity??)
await doActivity(activity, id)
// loop through the list of followers
for (const follower of await listFollowers()) {
// send the activity to each follower
send(ACTOR, follower.actor, {
...activity,
cc: [follower.actor],
});
}
// return HTTP 204: no content (success)
return new Response("", { status: 204 })
}
const follow = async (req:Request, handle:string):Promise<Response> => {
const id = BASE_URL + '@' + handle
// send the follow request to the supplied actor
await send(ACTOR, handle, {
"@context": "https://www.w3.org/ns/activitystreams",
id,
type: "Follow",
actor: ACTOR,
object: handle,
});
await createFollowing(handle, id)
return new Response("", { status: 204 })
}
const unfollow = async (req:Request, handle:string):Promise<Response> => {
// check to see if we are already following. If not, just return success
const existing = await getFollowing(handle)
if (!existing) return new Response("", { status: 204 })
// TODO: send the unfollow request (technically an undo follow activity)
await send(ACTOR, handle, {
"@context": "https://www.w3.org/ns/activitystreams",
id: existing.id + "/undo",
type: "Undo",
actor: ACTOR,
object: {
id: existing.id,
type: "Follow",
actor: ACTOR,
object: handle,
},
});
// delete the following reference from the database
deleteFollowing(handle);
return new Response("", { status: 204 })
}

94
src/db.ts Normal file
View file

@ -0,0 +1,94 @@
import { ACTIVITY_PATH, ACTOR, BASE_URL, DATA_PATH, POSTS_PATH } from "./env";
import path from "path"
import { readdir } from "fs/promises"
const matter = require('gray-matter')
export async function doActivity(activity:any, object_id:string|null|undefined) {
if(activity.type === "Create" && activity.object) {
if(!object_id) object_id = new Date(activity.object.published).getTime().toString(16)
const file = Bun.file(path.join(POSTS_PATH, `${object_id}.md`))
const { content, published, id, attributedTo } = activity.object
//TODO: add appropriate content for different types (e.g. like-of, etc)
await Bun.write(file, matter.stringify(content || "", { id, published, attributedTo }))
const activityFile = Bun.file(path.join(ACTIVITY_PATH, `${object_id}.activity.json`))
await Bun.write(activityFile, JSON.stringify(activity))
}
}
export async function getPost(id:string) {
const file = Bun.file(path.join(POSTS_PATH, `${id}.md`))
const { data, content } = matter(await file.text())
return {
...data,
content: content.trim()
}
}
export async function listPosts() {
return await Promise.all((await readdir(POSTS_PATH)).filter(v => v.endsWith('.md')).map(async filename => await getPost(filename.slice(0, -3))))
}
export async function getActivity(id:string) {
const file = Bun.file(path.join(ACTIVITY_PATH, `${id}.activity.json`))
return await file.json()
}
export async function listActivities() {
return await Promise.all((await readdir(ACTIVITY_PATH)).filter(v => v.endsWith('.activity.json')).map(async filename => await Bun.file(path.join(ACTIVITY_PATH, filename)).json()))
}
export async function createFollowing(handle:string, id:string) {
const file = Bun.file(path.join(DATA_PATH, `following.json`))
const following_list = await file.json() as Array<any>
if(!following_list.find(v => v.id === id || v.handle === handle)) following_list.push({id, handle, createdAt: new Date().toISOString()})
await Bun.write(file, JSON.stringify(following_list))
}
export async function deleteFollowing(handle:string) {
const file = Bun.file(path.join(DATA_PATH, `following.json`))
const following_list = await file.json() as Array<any>
await Bun.write(file, JSON.stringify(following_list.filter(v => v.handle !== handle)))
}
export async function getFollowing(handle:string) {
const file = Bun.file(path.join(DATA_PATH, `following.json`))
const following_list = await file.json() as Array<any>
return following_list.find(v => v.handle === handle)
}
export async function listFollowing() {
const file = Bun.file(path.join(DATA_PATH, `following.json`))
return await file.json() as Array<any>
}
export async function acceptFollowing(handle:string) {
const file = Bun.file(path.join(DATA_PATH, `following.json`))
const following_list = await file.json() as Array<any>
const following = following_list.find(v => v.handle === handle)
if(following) following.accepted = new Date().toISOString()
await Bun.write(file, JSON.stringify(following_list))
}
export async function createFollower(actor:string, id:string) {
const file = Bun.file(path.join(DATA_PATH, `followers.json`))
const followers_list = await file.json() as Array<any>
if(!followers_list.find(v => v.id === id || v.actor === actor)) followers_list.push({id, actor, createdAt: new Date().toISOString()})
await Bun.write(file, JSON.stringify(followers_list))
}
export async function deleteFollower(actor:string) {
const file = Bun.file(path.join(DATA_PATH, `followers.json`))
const followers_list = await file.json() as Array<any>
await Bun.write(file, JSON.stringify(followers_list.filter(v => v.actor !== actor)))
}
export async function getFollower(actor:string) {
const file = Bun.file(path.join(DATA_PATH, `followers.json`))
const followers_list = await file.json() as Array<any>
return followers_list.find(v => v.actor === actor)
}
export async function listFollowers() {
const file = Bun.file(path.join(DATA_PATH, `followers.json`))
return await file.json() as Array<any>
}

38
src/env.ts Normal file
View file

@ -0,0 +1,38 @@
import forge from "node-forge" // import crypto from "node:crypto"
import path from "path"
// change "activitypub" to whatever you want your account name to be
export const ACCOUNT = Bun.env.ACCOUNT || "activitypub"
// set up username and password for admin actions
export const ADMIN_USERNAME = process.env.ADMIN_USERNAME || "";
export const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "";
// get the hostname (`PROJECT_DOMAIN` is set via glitch, but since we're using Bun now, this won't matter)
export const HOSTNAME = /*(Bun.env.PROJECT_DOMAIN && `${Bun.env.PROJECT_DOMAIN}.glitch.me`) ||*/ Bun.env.HOSTNAME || "localhost"
export const NODE_ENV = Bun.env.NODE_ENV || "development"
export const PORT = Bun.env.PORT || "3000"
export const BASE_URL = (HOSTNAME === "localhost" ? "http://" : "https://") + HOSTNAME + "/"
export const ACTOR = BASE_URL + ACCOUNT
// in development, generate a key pair to make it easier to get started
const keypair =
NODE_ENV === "development"
? forge.pki.rsa.generateKeyPair({bits: 4096}) //crypto.generateKeyPairSync("rsa", { modulusLength: 4096 })
: undefined
export const PUBLIC_KEY =
process.env.PUBLIC_KEY ||
(keypair && forge.pki.publicKeyToPem(keypair.publicKey)) || //keypair?.publicKey.export({ type: "spki", format: "pem" }) ||
""
export const PRIVATE_KEY =
process.env.PRIVATE_KEY ||
(keypair && forge.pki.privateKeyToPem(keypair.privateKey)) || //keypair?.privateKey.export({ type: "pkcs8", format: "pem" }) ||
""
export const CONTENT_PATH = path.join('.', '_content')
export const POSTS_PATH = path.join(CONTENT_PATH, "posts")
export const ACTIVITY_PATH = path.join(CONTENT_PATH, "posts")
export const DATA_PATH = path.join(CONTENT_PATH, "_data")

33
src/index.ts Normal file
View file

@ -0,0 +1,33 @@
import { ACCOUNT, ACTOR, HOSTNAME, PORT } from "./env";
import admin from './admin'
import activitypub from "./activitypub";
const server = Bun.serve({
port: 3000,
fetch(req): Response | Promise<Response> {
const url = new URL(req.url)
console.log(`${new Date().toISOString()} ${req.method} ${req.url}`)
if(req.method === "GET" && url.pathname === "/.well-known/webfinger") {
const resource = url.searchParams.get("resource")
if(resource !== `acct:${ACCOUNT}@${HOSTNAME}`) return new Response("", { status: 404 })
return Response.json({
subject: `acct:${ACCOUNT}@${HOSTNAME}`,
links: [
{
rel: "self",
type: "application/activity+json",
href: ACTOR,
},
],
}, { headers: { "content-type": "application/activity+json" }})
}
return admin(req) || activitypub(req) || new Response("How did we get here?", { status: 404 })
},
});
console.log(`Listening on http://localhost:${server.port} ...`);

132
src/request.ts Normal file
View file

@ -0,0 +1,132 @@
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;
}

22
tsconfig.json Normal file
View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"module": "esnext",
"target": "esnext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"allowImportingTsExtensions": true,
"noEmit": true,
"composite": true,
"strict": true,
"downlevelIteration": true,
"skipLibCheck": true,
"jsx": "preserve",
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"allowJs": true,
"types": [
"bun-types" // add Bun global
]
}
}