A bare-minimum ActivityPub server from scratch

This is a translation of the article I originally wrote in Russian a year ago.

Lately, after Elon Musk bought Twitter, people have started looking for its alternatives – and many found one in Mastodon.

Mastodon is a decentralized social media platform that works on the federation model, like email. The federation protocol is called ActivityPub and is a W3C standard, and Mastodon is far from being its only implementation, albeit it is the most popular one. Different implementations of this protocol are generally compatible with each other, as much as the overlaps in their user-facing functionality permit. I have my own ActivityPub server project, Smithereen. It’s basically VKontakte, but it’s decentralized and comes in green, and I’ll bring the wall back someday.

In this article, we’ll look at the basics of the ActivityPub protocol and will write a minimally-viable server implementation that allows sending posts out into the network (“the fediverse”), following other people, and receiving their updates.

So what is ActivityPub?

Strictly speaking, according to the spec, ActivityPub comes in two types: server-to-server and client-server. The client-server variety is just odd and not very usable on a non-ideal connection. It’s also not much widely implemented, so we’ll skip it.

ActivityPub, as it pertains to federation, consists of the following core concepts:

  • Actors are objects (or subjects?) that can perform some actions. For example, users or groups. They are uniquely globally identified by their URL that points to a JSON object describing that actor.
  • Activities are objects that represent those actions, like “Sam published a post”.
  • Inbox is an endpoint on the server that accepts these activities. Its URL is specified in the inbox field of an actor.

All objects are just JSON following a defined schema. In reality it’s slightly cursed JSON-LD with namespaces, but for our needs, this can be ignored. ActivityPub objects have the mime type of application/ld+json; profile="https://www.w3.org/ns/activitystreams" or application/activity+json.

Here’s how it works: an actor sends an activity to another actor’s inbox, and its recipient accepts it, validates it, and does something with it. For example, they may put a new post into their database, or create a follow relationship and send a “follow accepted” activity in response. Activities themselves are delivered as POST requests signed with an actor key (each actor has an RSA key pair for authentication).

In addition to that, you’ll have to implement the WebFinger protocol to convert human-readable usernames like @sam@example.social to true actor IDs like https://example.social/users/sam. Mastodon refuses to work with servers that don’t implement this, even if you give it a direct URL of the actor ¯\_(ツ)_/¯

What should a server be capable of to participate in the fediverse?

In order for your server to be meaningfully interactable with from Mastodon and other similar software, it needs to support the following:

  1. Return an actor object with a minimal set of fields: ID, inbox, public key, username, Person type.
  2. Respond to WebFinger requests from the /.well-known/webfinger endpoint. Mastodon will refuse to see your actor without this.
  3. Send its correctly signed activities to the followers and whoever else might be interested in them – for example, those mentioned in the post.
  4. Accept POST requests to the inbox and verify their signatures. For starters, it’ll only be enough to support 4 activity types: Follow, Undo{Follow}, Accept{Follow}, and Create{Note}.
  5. Upon receiving a Follow activity, save the data about the new follower to some persistent storage, send them an Accept{Follow}, and, going forward, send them all activities, for example, about new posts.
  6. And upon receiving an Undo{Follow}, you would remove them from that list (no need to send anything this time).
  7. While not strictly required, it’s strongly recommended to have URLs for existing posts that return a Note object so that they could be loaded on another server by pasting the URL into the search field.

Practical part

Now let’s look at each point in detail and with code examples. My implementation will be in Java because it’s my native programming language. You can either poke into my example, or write your own thing loosely based on this one in your preferred stack. You will also need a domain and an HTTPS server or a proxy – either something like ngrok, or your own.

My example has two dependencies: the Spark micro framework to accept incoming requests, and Gson for working with JSON. The entirety of the code is available on my GitHub.

Before you begin, generate a pair of RSA keys and put them into your project directory:

openssl genrsa 2048 | openssl pkcs8 -topk8 -nocrypt -out private.pem
openssl rsa -in private.pem -outform PEM -pubout -out public.pem

Returning the actor object

At any URL of your convenience, serve a JSON object of this form:

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    "https://w3id.org/security/v1"
  ],
  "type": "Person",
  "id": "https://example.social/users/sam",
  "preferredUsername": "sam",
  "inbox": "https://example.social/inbox",
  "publicKey": {
    "id": "https://example.social/users/sam#main-key",
    "owner": "https://example.social/users/sam",
    "publicKeyPem": "-----BEGIN PUBLIC KEY-----\n...\n----END PUBLIC KEY----"
  }
}

It needs to come with Content-Type: application/ld+json; profile="https://www.w3.org/ns/activitystreams" header. Field meaning:

  • @context: JSON-LD context. Ignore it for now, but remember that it needs to be there. If you’re curious to find out more anyway, see here and here.
  • type: object type. Person stands for someone’s personal profile. Can also be Group, Organization, Application, and Service.
  • id: a global ID for this object, and also the URL where you can get it (yes, it’s a self-reference).
  • preferredUsername: user’s username that is shown in the UI and is used for search and mentions.
  • inbox: URL for that all-important inbox endpoint that accepts incoming activities.
  • publicKey: a public RSA key that is used for verifying this actor’s activity signatures:
    • id: key ID. In theory, there can be multiple keys per actor, but in practice everyone has just one. Just add #main-key after the actor ID.
    • owner: the key owner ID, just the ID of your actor.
    • publicKey: the key itself in the PEM format.

Additional optional fields that you might want to add:

  • followers and following: URLs for followers and following collections. These can return 403 or 404, but some servers just need these fields to be present in the object.
  • outbox: the inverse of inbox, a collection of some activities that this user has sent. Usually only contains Create{Note} and Announce{Note}.
  • url: a link to this user’s profile in their server’s web interface.
  • name: a display name, for example, John Appleseed.
  • icon and image: avatar and cover. Objects of Image type containing type, mediaType and url fields. Avatars are usually square.
  • summary: “about” or “bio” field. Usually contains HTML.

To look at an actor (or post, or some other) object from another server, send a GET request with Accept: application/ld+json; profile="https://www.w3.org/ns/activitystreams" header to the same URL on which you’re seeing the profile in your browser:

$ curl -H 'Accept: application/ld+json; profile="https://www.w3.org/ns/activitystreams"' https://mastodon.social/@Gargron
{"@context":["https://www.w3.org/ns/activitystreams", ...
Code for fetching an actor
private static final String AP_CONTENT_TYPE = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"";

/**
 * Fetch an actor from a remote server
 * @param id actor ID
 * @throws IOException if a network error occurs
 */
private static JsonObject fetchRemoteActor(URI id) throws IOException {
  try {
    HttpRequest req = HttpRequest.newBuilder()
        .GET()
        .uri(id)
        .header("Accept", AP_CONTENT_TYPE)
        .build();
    HttpResponse<String> resp = HTTP_CLIENT.send(req, HttpResponse.BodyHandlers.ofString());
    return JsonParser.parseString(resp.body()).getAsJsonObject();
  } catch(InterruptedException x) {
    throw new RuntimeException(x);
  }
}
Code for serving an actor
private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create();

get("/actor", (req, res) -> {
  Map<String, Object> actorObj = Map.of(
      "@context", List.of("https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"),
      "type", "Person",
      "id", ACTOR_ID,
      "preferredUsername", USERNAME,
      "inbox", "https://" + LOCAL_DOMAIN + "/inbox",
      "publicKey", Map.of(
          "id", ACTOR_ID + "#main-key",
          "owner", ACTOR_ID,
          "publicKeyPem", publicKey
      )
  );
  res.type(AP_CONTENT_TYPE);
  return GSON.toJson(actorObj);
});

Responding to webfinger requests

The request and a response (reduced to the bare minimum) look like this:

$ curl -v https://mastodon.social/.well-known/webfinger?resource=acct:gargron@mastodon.social
...
< HTTP/2 200 
< content-type: application/jrd+json; charset=utf-8
...

{
  "subject":"acct:Gargron@mastodon.social",
  "links":[
    {
      "rel":"self",
      "type":"application/activity+json",
      "href":"https://mastodon.social/users/Gargron"
    }
  ]
}

It’s just a way of saying “actor ID that corresponds to username gargron on server mastodon.social is https://mastodon.social/users/Gargron”.

These two endpoints are already enough for your actor to be visible on other servers – try entering the URL of your actor object into the search field in Mastodon to see its profile.

Sending activities

Activities are sent via POST requests to inboxes. They are authenticated with HTTP signatures. It’s a header that signs other headers using the actor key. It looks like this:

Signature: keyId="https://example.social/actor#main-key",headers="(request-target) host date digest",signature="..."

Where keyId is the key ID from the actor object, headers are the headers that we signed, and signature is the signature itself encoded as base64. Signed headers must include Host, Date, and the “pseudo header” (request-target), it’s the method and path (for example, post /inbox). Time in the Date header must differ from the recipient server’s clock by no more than 30 seconds, this is necessary for preventing replay attacks. Modern Mastodon versions also require the Digest header, it’s base64-encoded SHA-256 of the request body:

Digest: SHA-256=n4hdMVUMmFN+frCs+jJiRUSB7nVuJ75uVZbr0O0THvc=

The string that you need to sign is composed of the names and values of the headers in the same order in which they appear in the headers field. The names are lowercase and are separated from the values with a colon and a space. Each header, except the last one, ends with a line break (\n):

(request-target): post /users/1/inbox
host: friends.grishka.me
date: Sun, 05 Nov 2023 01:23:45 GMT
digest: SHA-256=n4hdMVUMmFN+frCs+jJiRUSB7nVuJ75uVZbr0O0THvc=
Code for sending an activity
/**
 * Send an activity to someone's inbox
 * @param activityJson activity JSON itself
 * @param inbox inbox URL
 * @param key private key for signing
 * @throws IOException if a network error occurs
 */
private static void deliverOneActivity(String activityJson, URI inbox, PrivateKey key) throws IOException {
  try {
    byte[] body = activityJson.getBytes(StandardCharsets.UTF_8);
    String date = DateTimeFormatter.RFC_1123_DATE_TIME.format(Instant.now().atZone(ZoneId.of("GMT")));
    String digest = "SHA-256="+Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(body));
    String toSign = "(request-target): post " + inbox.getRawPath() + "\nhost: " + inbox.getHost() + "\ndate: " + date + "\ndigest: " + digest;

    Signature sig = Signature.getInstance("SHA256withRSA");
    sig.initSign(key);
    sig.update(toSign.getBytes(StandardCharsets.UTF_8));
    byte[] signature = sig.sign();

    HttpRequest req = HttpRequest.newBuilder()
        .POST(HttpRequest.BodyPublishers.ofByteArray(body))
        .uri(inbox)
        .header("Date", date)
        .header("Digest", digest)
        .header("Signature", "keyId=\""+ACTOR_ID+"#main-key\",headers=\"(request-target) host date digest\",signature=\""+Base64.getEncoder().encodeToString(signature)+"\",algorithm=\"rsa-sha256\"")
        .header("Content-Type", AP_CONTENT_TYPE)
        .build();
    HttpResponse<String> resp = HTTP_CLIENT.send(req, HttpResponse.BodyHandlers.ofString());
    System.out.println(resp);
  } catch(InterruptedException | NoSuchAlgorithmException | InvalidKeyException | SignatureException x) {
    throw new RuntimeException(x);
  }
}

We’ve got everything ready to send our first activity! Try leaving a comment on my post about this article – send this to my inbox, https://friends.grishka.me/users/1/inbox, replacing example.social with the domain that your server runs on:

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://example.social/createHelloWorldPost",
  "type": "Create",
  "actor": "https://example.social/actor",
  "to": "https://www.w3.org/ns/activitystreams#Public",
  "object": {
    "id": "https://example.social/helloWorldPost",
    "type": "Note",
    "published": "2023-11-05T12:00:00Z",
    "attributedTo": "https://example.social/actor",
    "to": "https://www.w3.org/ns/activitystreams#Public",
    "inReplyTo": "https://friends.grishka.me/posts/884435",
    "content": "<p>Hey, fediverse!</p>"
  }
}

If you’ve done everything right, your comment will appear under my post.

Here: Create is the activity type, we’ve created something. actor is who created it, object is what they created, to is who this activity is addressed to (the entire world). We created a “note” (this is how ActivityPub calls posts) with the text “Hey, fediverse”, that is a reply to my post. A basic set of HTML tags is supported in the text for formatting, but the exact details of what is supported depend on the particular server.

Accepting activities and verifying signatures

We’ve just sent and activity, and now we need to learn to accept them. There’s no point in saying it all again, it’s all the same. To verify the signature, you need:

  • Parse the Date header. If the time differs from the current time by more than 30 seconds, reject the request (by returning a 400, for example).
  • Parse the request body. Retrieve the actor object from the URL in actor.
  • Check that keyId in the Signature header matches the actor’s key ID.
  • Parse the public key, compose the signature string (see above) and verify the signature.
Code for receiving an activity with signature verification
post("/inbox", (req, res) -> {
  // Time in the Date header must be within 30 seconds from now
  long timestamp = DateTimeFormatter.RFC_1123_DATE_TIME.parse(req.headers("Date"), Instant::from).getEpochSecond();
  if (Math.abs(timestamp - Instant.now().getEpochSecond()) > 30) {
    res.status(400);
    return "";
  }

  // Retrieving actor
  JsonObject activity = JsonParser.parseString(req.body()).getAsJsonObject();
  URI actorID = new URI(activity.get("actor").getAsString());
  JsonObject actor = fetchRemoteActor(actorID);

  // Parsing the header and verifying the signature
  Map<String, String> signatureHeader = Arrays.stream(req.headers("Signature").split(","))
      .map(part->part.split("=", 2))
      .collect(Collectors.toMap(keyValue->keyValue[0], keyValue->keyValue[1].replaceAll("\"", "")));
  if (!Objects.equals(actor.getAsJsonObject("publicKey").get("id").getAsString(), signatureHeader.get("keyId"))) {
  	// Key ID that the request is signed with does not match the actor key
    res.status(400);
    return "";
  }
  List<String> signedHeaders = List.of(signatureHeader.get("headers").split(" "));
  if (!new HashSet<>(signedHeaders).containsAll(Set.of("(request-target)", "host", "date"))) {
  	// One or more of the required headers are not present in the signature
    res.status(400);
    return "";
  }
  String toSign = signedHeaders.stream()
      .map(header -> {
        String value;
        if ("(request-target)".equals(header)) {
          value="post /inbox";
        } else {
          value=req.headers(header);
        }
        return header+": "+value;
      })
      .collect(Collectors.joining("\n"));
  PublicKey actorKey = Utils.decodePublicKey(actor.getAsJsonObject("publicKey").get("publicKeyPem").getAsString());
  Signature sig = Signature.getInstance("SHA256withRSA");
  sig.initVerify(actorKey);
  sig.update(toSign.getBytes(StandardCharsets.UTF_8));
  if (!sig.verify(Base64.getDecoder().decode(signatureHeader.get("signature")))) {
  	// Signature verification failed
    res.status(400);
    return "";
  }

  // We did it - remember the activity to later show it to the user
  receivedActivities.addFirst(activity);

  return ""; // A 200 response is enough
});

Following people

Now you have all parts necessary to follow another user. Try following your own Mastodon account or, for example, https://mastodon.social/@Mastodon. Send an activity like this to the corresponding actor (don’t forget to replace example.social with your own domain and object with the ID of the target actor):

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://example.social/oh-wow-i-followed-someone",
  "type": "Follow",
  "actor": "https://example.social/actor",
  "object": "https://mastodon.social/users/Mastodon"
}

Soon after that, you should receive an Accept activity, with your Follow inside as its object (I write such nested activity types as Accept{Follow}). This means that the other server has accepted your follow request, and will send you Create, Announce, and Delete about any future posts posts that this actor will create, boost, and delete. To unfollow, send Undo{Follow}.

What’s next?

If you’ve read this far and have done everything according to these instructions, congratulations, you’ve got a working ActivityPub server! You can try the obvious improvements:

  • Add the ability for others to follow your actor
  • Not just put activities into an array, but process them depending on their types
  • The UI can be much improved by replacing the huge field for JSON with buttons that do things
  • Add a database for storing users, posts, and follow relationships
  • …and support more than one user on the server
  • Build a complete web interface and/or an API for client apps
  • Add authentication of some sort, after all
  • Cache objects from other servers locally to avoid making the same requests over and over

More ActivityPub servers: