Messing with ActivityPub
Messing with ActivityPub
In general, I’m more of a lurker than a poster on social media. I never really got into twitter, but I have been enjoying the conversations on the federated twitter clone Mastdodon. Specifically, I’ve been on the philadelphia based jawns.club server. It’s active, well maintained and kind of the right size for me.
Mastodon, and a lot of applications in the “Fediverse” are built on top of the ActivityPub protocol. Doing some handwaving, it is a common, json-ld based protocol
for messaging different social activities between servers with routing rules similar to what you might expect an
email server to follow. So if I, at @alexcurtin@jawns.club
message someone else, @otherperson@madeupdomain.social
, then
the jawns.club server is going to send that message over to the madeupdomain.social server.
There’s a whole lot of nuance missing in that example, and I’m not really even going to get into the object tree, but it supports all kinds of events that you might expect on a twitter or instagram-like platform. Favoriting, reposting, subscribing, etc.
Basic activitypub profile/posting - Gargron’s tutorial
I love the idea of being able to build/host my own implementation of activitypub, but getting started can be a bit overwhelming. I happened to come accross this article by the maintainer of mastodon, and it’s proved to be a pretty helpful starting point.
The tutorial consists of two parts:
- Setting up the required endpoints for creating a discoverable activitypub account on your own domain
- Using that information to post a reply from your home box.
My Implementation
Static files for profile discovery
The tutorial walks you through the steps better than I ever could, but the resulting files for profile discovery can be found here:
-
webfinger - https://curtin.cool/.well-known/webfinger (I haven’t messed with mime types so this will just attempt to download)
{ "subject": "acct:alex@curtin.cool", "links": [ { "rel": "self", "type": "application/activity+json", "href": "https://curtin.cool/actor" } ] }
Basically, this just helps other severs know where to look to find all the activitypub information about my account.
-
actor - https://curtin.cool/actor (once again - MIME types, so no reason to click on it)
{ "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1" ], "id": "https://curtin.cool/actor", "type": "Person", "preferredUsername": "alex", "inbox": "https://curtin.cool/inbox", "publicKey": { "id": "https://curtin.cool/actor#main-key", "owner": "https://curtin.cool/actor", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5fynqNQ2NZZqC2nCotMT\ncU5J/nNIAdbuEGkBHxycKh9YiCYQnTCpG23djsw17/7gy3q4Q5VLSsS0ukwsKFTY\naWzrXCzKm0O/iTow14Buue3CJCniLwT17OJxXBF7eQ6sd2liSD4afRanxYPdKKpf\ndhp//WH+fU5fWiScKbD7Tttf5DdutCYqBWg6s9Z6D3d2Kh8j4y2EwQWPSnCdX+6h\nwjGWq+gPJWA4DLima4OF8bRA24IvbapAjNIc/WRrcJ/uGwbvL+SFiLciYFyoXTNz\n5HDMQYlMjMCHAVuKldfRZh74wM6emhGM3Sxt3PkM7kxjgzclr8VchbmraJ4EKRmD\nDwIDAQAB\n-----END PUBLIC KEY-----" } }
The inbox 100% doesn’t exist, but if I could accept POST requests, that’s where it would go. Kind of gets messy on a statically hosted site.
Creating a signed post - node script
I’ve been thinking that the fastest way to implement a real activitypub server would be to do so in node/express, mostly just because of the json juggling that has to be done for the different objects. So I attempted to create a node version of the signing script.
…Whoo buddy, did I make a mess of it.
Before you look
The code is listed below, but basically, it accepts 3 arguments to reply to a post on a mastodon server. Technically, it might work with other AP implementations, but it makes assumptions about the location of the inbox relative to the other posts.
So if I reply to https://jawns.club/@aaronesilvers/110584208843171766, it’s going to post to https://jawns.club/inbox.
I also needed to tweak the script to post the signed hash of the post itself, which was apparently a requirement added to mastodon servers after the post. It goes into the “Digest” header and tweaks the signature header slightly. More info can be found here
Calling the script looked something like this, since I called it through a custom package.json script that rebuilt the typescript file.
npm run post "Hey aaron, this is a test reply cause I'm messing with activitypub" ../apkeys/private.pem https://jawns.club/@aaronesilvers/110584208843171766
The arguments are:
- The message being sent in the reply
- The path to the signing key (private key paired to the one in the actor file listed above)
- The post that I’m replying to
Admittedly the order of the arguments makes zero sense, but I’m not changing it.
The code - eww.
Yes, I’m just leaving it like this. Pretty much the moment it worked, I stopped messing with it.
import * as crypto from "crypto";
import * as fs from "fs";
// Parse the arguments and create the message body
const [message, keyPath, inReplyTo] = process.argv.slice(2);
const inReplyToUrl = new URL(inReplyTo);
const inbox = `https://${inReplyToUrl.hostname}/inbox`;
const post = {
"@context": "https://www.w3.org/ns/activitystreams",
id: `https://curtin.cool/activity/${crypto.randomUUID()}`,
type: "Create",
actor: "https://curtin.cool/actor",
object: {
id: `https://curtin.cool/${crypto.randomUUID()}`,
type: "Note",
published: `${new Date().toISOString}`,
attributedTo: "https://curtin.cool/actor",
inReplyTo: `${inReplyTo}`,
content: `<p>${message}</p>`,
to: "https://www.w3.org/ns/activitystreams#Public",
},
};
// Calculate the signatures
const postContents = JSON.stringify(post);
const date = new Date().toUTCString();
const host = inReplyToUrl.hostname;
const path = "/inbox";
// Calculate the hash of the post for the "Digest" header that is needed be newer mastodon versions
const digest = crypto
.createHash("sha256")
.update(postContents)
.digest("base64");
const digestPart = `SHA-256=${digest}`;
// Calculate the signature for this weird request-target string
// The server being posted reconstructs it and validates the signature using the public key
const sign = crypto.createSign("RSA-SHA256");
const signedString = `(request-target): post ${path}\nhost: ${host}\ndate: ${date}\ndigest: ${digestPart}`;
sign.update(signedString);
const key = fs.readFileSync(keyPath);
const signature = sign.sign(key, "base64");
// This part tells the server how to reconstruct the signed string for validation
const signatureHeader = `keyId="https://curtin.cool/actor",headers="(request-target) host date digest",signature="${signature}"`;
// Finally, post and just dump whatever you get back to the console. Good luck!
fetch(inbox, {
method: "POST",
body: postContents,
headers: {
Date: date,
Host: host,
Signature: signatureHeader,
Digest: digestPart,
},
})
.then((x) => x.text())
.then((x) => console.log(x));
What’s next?
I might do all, or absolutely none of these things.
- Updating my profile to contain my avatar
- Researching existing inbox implementations
- Figuring out how to route to functioning inbox on a budget without bringing down this cloudflare page. I really like the simplicity of the blog setup as it is.
- Somehow integrating blog posts as activitypub articles that people can comment on. This is a biiig reach.