10 releases

0.1.9 Sep 15, 2023
0.1.8 Sep 14, 2023

#677 in Cryptography

Download history 12/week @ 2023-08-29 136/week @ 2023-09-05 66/week @ 2023-09-12 13/week @ 2023-09-19 13/week @ 2023-09-26 1/week @ 2023-10-03 33/week @ 2023-10-17 20/week @ 2023-10-31 12/week @ 2023-11-14 32/week @ 2023-11-21 32/week @ 2023-11-28

76 downloads per month

MIT license

1.5K SLoC

SofaPub: A minimally functional ActivityPub Server

$ sofapub
A minimally functional ActivityPub implementation

Usage: sofapub <COMMAND>

  help       Print this message or the help of the given subcommand(s)

  -h, --help  Print help

I had the thought earlier this evening that it sure would be nice to have a tool that I could use to make properly signed requests to remote ActivityPub servers so that I could evaluate their responses and build interfaces to work effectively. This project stems from that thought. The initial working prototype was built this evening while I was sitting on the sofa.


I used certbot to generate certificates for my test domain name (sofa.jdt.dev) manually. This is the command I used.

certbot certonly --manual -d sofa.jdt.dev --agree-tos --preferred-challenges dns-01 --server https://acme-v02.api.letsencrypt.org/directory --register-unsafely-without-email --rsa-key-size 4096 --config-dir certbot/config --work-dir certbot/work --logs-dir certbot/logs

Change that for your own domain name, obviously. You'll need to be ready to update your DNS settings to add in a verification TXT record for LetsEncrypt.


You'll need to forward port 443/tcp to port 8086 (the default I chose) on your server. You'll also need to configure your DNS appropriately to point your domain name at the external address you're forwarding from.


You can download this repository and cargo build and cargo run --bin sofapub as you'd like. This package is also present on https://crates.io, so you can skip all that and install using cargo install sofapub. This will also download and compile the latest release for updates.


This is the setup command I used (adjust for your purposes). This will create the RSA certificates and basic configuration.

sofapub setup \
    --username justin \
    --display-name "Justin Thomas" \
    --summary "This is my test account" \
    --domain sofa.jdt.dev \
    --tls-private-key-path /opt/certbot/config/live/sofa.jdt.dev/privkey.pem \
    --tls-certificate-path /opt/certbot/config/live/sofa.jdt.dev/fullchain.pem

The server will work if you omit the --tls-private-key-path and --tls-certificate-path, but you'll need to handle the certificates somewhere else and proxy the connection to your local machine as HTTP on port 8086/tcp. This might be useful if you use something like ngrok to expose the service (I plan to test and document that use-case at some point).

The configuration is stored in $HOME/.sofapub and a suitable directory structure will be created there to support the server's operations. Templates will be written to $HOME/.sofapub/templates.


RUST_LOG=debug sofapub server will start the SofaPub server with debug logging.

Messages sent to inbox will be captured in the ~/.sofapub/data/inbox folder with server-generated UUID filenames. Here is what a Follow request from my user at infosec.exchange looks like:

$ RUST_LOG=debug sofapub server

[2023-09-02T01:05:32Z DEBUG server] igniting Configuration
[2023-09-02T01:05:32Z INFO  rocket::launch] 🔧 Configured for debug.
[2023-09-02T01:05:32Z INFO  rocket::launch::_] address:
[2023-09-02T01:05:32Z INFO  rocket::launch::_] port: 8086
[2023-09-02T01:05:32Z INFO  rocket::launch::_] workers: 16
[2023-09-02T01:05:32Z INFO  rocket::launch::_] max blocking threads: 512
[2023-09-02T01:05:32Z INFO  rocket::launch::_] ident: Rocket
[2023-09-02T01:05:32Z INFO  rocket::launch::_] IP header: X-Real-IP
[2023-09-02T01:05:32Z INFO  rocket::launch::_] limits: bytes = 8KiB, data-form = 2MiB, file = 1MiB, form = 32KiB, json = 1MiB, msgpack = 1MiB, string = 8KiB
[2023-09-02T01:05:32Z INFO  rocket::launch::_] temp dir: /var/folders/z6/y2vfsg3j739fbx4hwzf_m7700000gn/T/
[2023-09-02T01:05:32Z INFO  rocket::launch::_] http/2: true
[2023-09-02T01:05:32Z INFO  rocket::launch::_] keep-alive: 5s
[2023-09-02T01:05:32Z INFO  rocket::launch::_] tls: enabled w/o mtls
[2023-09-02T01:05:32Z INFO  rocket::launch::_] shutdown: ctrlc = true, force = true, signals = [SIGTERM], grace = 2s, mercy = 3s
[2023-09-02T01:05:32Z INFO  rocket::launch::_] log level: normal
[2023-09-02T01:05:32Z INFO  rocket::launch::_] cli colors: true
[2023-09-02T01:05:32Z INFO  rocket::launch] 📬 Routes:
[2023-09-02T01:05:32Z INFO  rocket::launch::_] (inbox_post) POST /inbox
[2023-09-02T01:05:32Z INFO  rocket::launch::_] (profile) GET /<handle>
[2023-09-02T01:05:32Z INFO  rocket::launch::_] (activity_pub) GET /users/<_username>
[2023-09-02T01:05:32Z INFO  rocket::launch::_] (webfinger) GET /.well-known/webfinger?<resource> application/jrd+json
[2023-09-02T01:05:32Z INFO  rocket::launch] 📡 Fairings:
[2023-09-02T01:05:32Z INFO  rocket::launch::_] Shield (liftoff, response, singleton)
[2023-09-02T01:05:32Z INFO  rocket::launch::_] SofaPub Configuration (ignite)
[2023-09-02T01:05:32Z INFO  rocket::shield::shield] 🛡 Shield:
[2023-09-02T01:05:32Z INFO  rocket::shield::shield::_] X-Frame-Options: SAMEORIGIN
[2023-09-02T01:05:32Z INFO  rocket::shield::shield::_] X-Content-Type-Options: nosniff
[2023-09-02T01:05:32Z INFO  rocket::shield::shield::_] Permissions-Policy: interest-cohort=()
[2023-09-02T01:05:32Z WARN  rocket::launch] 🚀 Rocket has launched from
[2023-09-02T01:05:39Z DEBUG rustls::server::hs] decided upon suite TLS13_AES_256_GCM_SHA384
[2023-09-02T01:05:39Z INFO  rocket::server] POST /inbox application/activity+json:
[2023-09-02T01:05:39Z INFO  rocket::server::_] Matched: (inbox_post) POST /inbox
[2023-09-02T01:05:39Z INFO  rocket::server::_] Outcome: Success
[2023-09-02T01:05:39Z INFO  rocket::server::_] Response succeeded.
^C[2023-09-02T01:05:43Z WARN  rocket::server] Received SIGINT. Requesting shutdown.
[2023-09-02T01:05:43Z INFO  rocket::server] Shutdown requested. Waiting for pending I/O...
[2023-09-02T01:05:43Z DEBUG rustls::conn] Sending warning alert CloseNotify
[2023-09-02T01:05:43Z INFO  rocket::server] Graceful shutdown completed successfully.

$ ls -la ~/.sofapub/data/inbox/
total 24
drwxr-xr-x@ 6 justin  staff  192 Sep  1 18:05 .
drwxr-xr-x@ 7 justin  staff  224 Sep  1 17:55 ..
-rw-r--r--@ 1 justin  staff  227 Sep  1 18:03 1b7a3159-dcac-49f8-b687-b1defa06ff79.json
-rw-r--r--@ 1 justin  staff  360 Sep  1 18:05 f7c0ca70-49a9-4901-8928-f7bae48b8d1b.json

$ cat ~/.sofapub/data/inbox/*.json | jq
  "@context": "https://www.w3.org/ns/activitystreams",
  "actor": "https://infosec.exchange/users/jdt",
  "id": "https://infosec.exchange/a9941fe3-1051-490c-8cb8-8793a1a9bcf3",
  "object": "https://sofa.jdt.dev/users/justin",
  "type": "Follow"
  "@context": "https://www.w3.org/ns/activitystreams",
  "actor": "https://infosec.exchange/users/jdt",
  "id": "https://infosec.exchange/users/jdt#follows/2830232/undo",
  "object": {
    "actor": "https://infosec.exchange/users/jdt",
    "id": "https://infosec.exchange/a9941fe3-1051-490c-8cb8-8793a1a9bcf3",
    "object": "https://sofa.jdt.dev/users/justin",
    "type": "Follow"
  "type": "Undo"

Technically, I issued the Follow earlier, and the messages in the log above are showing you the Undo. Nonetheless, you can see both messages are captured by SofaPub for your review.

Client Usage

There is some client functionality built-in to the sofapub binary. I'll be expanding this significantly as I progress. For right now, you can issue Follow and Undo actions (for the Follow actions) in this way:

$ sofapub client follow \
  --id https://infosec.exchange/users/jdt \
  --inbox https://infosec.exchange/users/jdt/inbox
ACTIVITY ID: https://sofa.jdt.dev/objects/4720e9f7-40d8-4c77-bfd4-42e59dc4f962

$ sofapub client follow \
  --id https://infosec.exchange/users/jdt \
  --inbox https://infosec.exchange/users/jdt/inbox \
  --undo https://sofa.jdt.dev/objects/4720e9f7-40d8-4c77-bfd4-42e59dc4f962
ACTIVITY ID: https://sofa.jdt.dev/objects/0b3e0473-2d4a-4e21-b5ae-7fe3ddbe949f

What you see above is me issuing a Follow command for my user @jdt@infosec.exchange. That commend emits an ACTIVITY ID that I then use to Undo that Follow. I also find the Accept activity from https://infosec.exchange in my inbox here:


Currently, the followers.json that is used to provide responses to /followers is updated immediately on the Follow and Undo commands. I should adjust the Follow side of that to wait for the Accept eventually.

Demonstration Using Simple Get Functionality

Assuming you have SofaPub up and running and you can query your user from another instance successfully, you should be able to use the included tools to interrogate and interact with ActivityPub instances in interesting ways. Here is one example where I use sofapub webfinger and sofapub get to request profile data from a server that requires request signing for all operations (https://firefish.social).

First I use the included webfinger tool to retrieve the WebFinger record for my user at Firefish.

$ sofapub webfinger @jdt@firefish.social | jq
  "subject": "acct:jdt@firefish.social",
  "links": [
      "rel": "self",
      "type": "application/activity+json",
      "href": "https://firefish.social/users/9j54zro9qetnjhza"
      "rel": "http://webfinger.net/rel/profile-page",
      "type": "text/html",
      "href": "https://firefish.social/@jdt"
      "rel": "http://ostatus.org/schema/1.0/subscribe",
      "template": "https://firefish.social/authorize-follow?acct={uri}"

Next I use the self href value with a type of application/activity+json to try to retrieve the record with curl. This would work on most Mastodon systems (which do not require request signing by default).

$ curl -H "Accept: application/activity+json" https://firefish.social/users/9j54zro9qetnjhza

Bummer. No worries, though. Here I process the request through sofapub get which uses my private RSA key in my configuration to sign the request. My sofapub server is running, to allow the target server to retrieve my public key to verify the signature.

$ sofapub get https://firefish.social/users/9j54zro9qetnjhza | jq
  "@context": [
      "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
      "movedToUri": "as:movedTo",
      "sensitive": "as:sensitive",
      "Hashtag": "as:Hashtag",
      "quoteUri": "fedibird:quoteUri",
      "quoteUrl": "as:quoteUrl",
      "toot": "http://joinmastodon.org/ns#",
      "Emoji": "toot:Emoji",
      "featured": "toot:featured",
      "discoverable": "toot:discoverable",
      "schema": "http://schema.org#",
      "PropertyValue": "schema:PropertyValue",
      "value": "schema:value",
      "misskey": "https://misskey-hub.net/ns#",
      "_misskey_content": "misskey:_misskey_content",
      "_misskey_quote": "misskey:_misskey_quote",
      "_misskey_reaction": "misskey:_misskey_reaction",
      "_misskey_votes": "misskey:_misskey_votes",
      "_misskey_talk": "misskey:_misskey_talk",
      "isCat": "misskey:isCat",
      "fedibird": "http://fedibird.com/ns#",
      "vcard": "http://www.w3.org/2006/vcard/ns#"
  "type": "Person",
  "id": "https://firefish.social/users/9j54zro9qetnjhza",
  "inbox": "https://firefish.social/users/9j54zro9qetnjhza/inbox",
  "outbox": "https://firefish.social/users/9j54zro9qetnjhza/outbox",
  "followers": "https://firefish.social/users/9j54zro9qetnjhza/followers",
  "following": "https://firefish.social/users/9j54zro9qetnjhza/following",
  "featured": "https://firefish.social/users/9j54zro9qetnjhza/collections/featured",
  "sharedInbox": "https://firefish.social/inbox",
  "endpoints": {
    "sharedInbox": "https://firefish.social/inbox"
  "url": "https://firefish.social/@jdt",
  "preferredUsername": "jdt",
  "name": null,
  "summary": null,
  "icon": null,
  "image": null,
  "tag": [],
  "manuallyApprovesFollowers": false,
  "discoverable": true,
  "publicKey": {
    "id": "https://firefish.social/users/9j54zro9qetnjhza#main-key",
    "type": "Key",
    "owner": "https://firefish.social/users/9j54zro9qetnjhza",
    "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAyzcWqxqXMH+tsPhIIZhU\nc9kHQHN+quayOAw+FtdX7bNmo+fY2Bndy5wRHymdcF/fFIXxCfeN6aO0FqBsPCrt\nO7XBsRkHi4LPSaZN730q+Q/FZmf6SVy943WWf8LgXOkt2VjJRO52w0seGrPR1/Dd\nB/6rFTDOVWUUyASL8+E1X2yQJ/veHRFrwpLPwYnfjJypCzhd2z3++y1PzjeHwygE\nHJx7EIcmFsiw7F+xDkEY4RWA/vV7bTajsij1P+DRkJJN+eNoK8y58Oxx2hf20tko\nf4cFnuawyLCRquixNlTHNqHxXR87nLEMP4rZrjuOjf5aIG7kxbyBnNuPtZ8ASrb/\nyprlsWkhkOY22K+XwvTWGDyo8Fduxh5ntWUB97fV1gDzD2NoN2bhXA4giUGCbo5V\nTj2Sbgsvk/DrF21whQjCJvVCThZwKfX7hZaaTWljNE1UEOTyS16WM+1i/ZWlOE5V\nQ7IbgImC+0rsbE9XeQaBJK5OhrOgO1nUeQkR4DwSaSORWuLf5xewJ6ZYxT474M+l\nytmrvCJFLUriDqFk8zjr6gon7fLt2yKagLaEU5DXduJQRMkpJ4hajpuZE2YNQwGj\n+ZNtpc6+OYfSbyxo2D/+HfVf1emQ1tSKo/w0chLzbokpIEFuR/4pmg1Etr5XG3XY\nP2nwPARDmUE/dzJ9Avq8Qy8CAwEAAQ==\n-----END PUBLIC KEY-----\n"
  "isCat": false


~1.5M SLoC