I’ve been working on adding ActivityPub support to my site for a few weeks now. ActivityPub is the protocol used by Mastodon, Pleroma, and other Fediverse applications. The documentation can be hard to follow because there are several different sets of standards involved (JSON-LD, “Linked Data Signatures,” HTTP Signatures, ActivityStreams, ActivityPub, and Webfinger). Adding to this confusion is that many of the standards have published new versions under different names—if you look for the specification for “linked data signatures,” for example, you get redirected to something called “verifiable credential data integrity” which doesn’t really help if you just want to know how to construct a signature that Mastodon will accept, or how to parse and validate a signature from Mastodon.
So this post is my notes on ActivityPub concepts as they are implemented by Mastodon.
Webfinger (service discovery, indieweb-style)
Webfinger is the first protocol / endpoint required to integrate with Mastodon. When you go into Mastodon’s
search field and type the handle of a user on a different instance (e.g. @raphael@example.com)
, the first thing Mastodon does is make a GET request to https://example.com/.well-known/webfinger?resource=acct:raphael@example.com
. The special path /.well-known/webfinger
must be a queryable resource that tells the caller where to find the Actor object. To find the
expected shape of the response, your best bet is to make the request directly to a Mastodon host
that you consider representative. When I do that, I see
{
"subject": "acct:raphaelluckom@indieweb.social",
"aliases": [
"https://indieweb.social/@raphaelluckom",
"https://indieweb.social/users/raphaelluckom"
],
"links": [
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": "https://indieweb.social/@raphaelluckom"
},
{
"rel": "self",
"type": "application/activity+json",
"href": "https://indieweb.social/users/raphaelluckom"
},
{
"rel": "http://ostatus.org/schema/1.0/subscribe",
"template": "https://indieweb.social/authorize_interaction?uri={uri}"
}
]
}
The most important part of this is the block with "rel": "self"—
it contains the location of the Actor object that represents me. So my Actor object is at
https://indieweb.social/users/raphaelluckom
, when retrieved with an Accept
header specifying 'application/activity+json'.
The takeaway: If you want a Mastodon server to be able to send messages to your ActivityPub thing,
or receive messages from it, you need this endpoint to exist on your domain; you need it to respond
when queried with your account handle; and you need it to include the correct
subject
and link
to your Actor object.
Actor Object
The Actor object is a JSON object that describes an ActivityPub account. You can find your actor object at the path
specified in the webfinger record. Curl with -H 'Accept: application/activity+json'
.
I’m not going to paste the whole thing from Mastodon here, but note that it includes a
publicKey
field containing some key metadata. The private key associated with that public key is used to sign
the requests. Mastodon uses a 2048-bit RSA key. A private key can be generated with
openssl genrsa -out KEY_NAME.pem 2048
and the public key can be extracted with openssl rsa -in KEY_NAME-private.pem -outform PEM -pubout -out KEY_NAME-public.pem.
Also note that the values in the Actor map are mostly URLs. These URLs are for interacting with the entity that controls the account, and include the inbox and, if the client-to-server protocol is being implemented, the outbox.
ActivityPub Requests
HTTP Signatures
Requests from one ActivityPub server to another are mostly POST
requests to actors’ inbox URLs. Mastodon authenticates these requests in two ways. First, the
request itself contains a Signature
header. This is an HTTP Signature. It most closely tracks the example from the section on RSA signatures. An example signature generated by Mastodon is:
"keyId=\"https://example.com.com/users/raphael#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"SxBYtZg3GUSI3JaJqCLwESg6vz/HJLkyLKqIoUzY2xRwp5kpEQKKUKT3Az4cEqdYZnSfdQP3Q1y04C7BEtWfSCTT05vzPuIUXk8ef5aK+2s3gHSHqp2Tf14ZAlhEgQIk5YQRfw0dwLWQTroPOCrQvGu6NkOumrWvfBUTjoyMte+kjaYXsQH/PwqQFOKlNsaDYIxCGBMPIKVCyNyhcnRPsWwyiAlmZxYahGeBbFfzc2OKqs4MnsCc5HzQdrlbzKPOkracM16FRMY2HF4uvuCdY2sFix8/+/gh5zL6Dv88Wj+zi3pmc9RXOaYy9yKkOzNKTzUz5QchRwU4/rXb1rUfyA==\""
For reference on how this is constructed, see the Mastodon SignatureVerification class. Note that the keyId
is the same URL as the Actor JSON with #main-key
on the end. This indicates that the message was signed with the private key that corresponds to the
public key in the sender’s Actor JSON, and the signature can be verified by this public key. Also
note that in resolving that key you are potentially making a request to an arbitrary domain in
response to an unauthenticated incoming request. Worth considering what kinds of limits and caching
are appropriate. The digest
parameter in the signature is a SHA256 hash of the body of the message in string form as it was
received (i.e. not a parsed and re-stringified version). The purpose of this signature is to
authenticate the message as coming from the actor, and via the digest to validate the integrity of
the message.
Linked Data Signatures
HTTP signatures authenticate that the request came from the actor, but they can’t be separated from the request and passed along with the object because they incorporate metadata from the request. If we want the object itself to be signed—allowing it to be passed on to other servers while still being verifiably written by the actor—then it needs a linked data signature. An example of a linked data signature on an object coming from mastodon is:
"signature": {
"type": "RsaSignature2017",
"creator": "https://example.com/users/raphael#main-key",
"created": "2022-12-03T03:08:16Z",
"signatureValue": "MEPZE5cUPJ7kgSw5/FpiPnWEtchhCHz7ArT2CeA65LhNGdhjlSogJukjiODXXj3h9ooq8RUsQX2AQJcRNjl1WANAS948KQQaaxSFsqlt8ZVeypZ+LiRXyy447V8k7Zh1xzakmoOeWCFFNjKQOECYLlKNM0+YZEKKIcCn8XiKNhVnAFF012SUXVW4lCMr6jkVsibMBJ5tP4H/eFxCvgh9mEP7p/ObK5zQfaNPrbjjx/jbWHmIvwKrQglqLFuTH6apyuPaMC7o/7fYhCkrbSvbKKa54C93oE7Fw0mr51XisGtqPd53jQiN9A4cF3x5lV71sdAmu6WvR2Z90PYYAwa3qg=="
}
ActivityPub objects use the JSON Linked Data grammar. This is important in this context because there is a way to turn a JSON-LD document, like the JSON representation of an Activity, into a canonical string representation. This allows JSON-LD documents to be hashed consistently across systems.
The Mastodon implementation of these signatures starts in the LinkedDataSignature class. The basic steps are:
-
Create the canonical string representation of the object.
-
Create the canonical string representation of the signing options.
-
Create a SHA256 hash of the canonical representation of the signing options.
-
Create a SHA256 hash of the canonical representation of the object
-
Concatenate them:
options_hash + document_hash
-
Take a SHA256 hash of the concatenation from step 4 and sign it with the actor’s private key.
-
Create the signature object with the appropriate metadata, putting the signature from step 6 in the
signatureValue
field
You will probably need to find a JSON-LD library in your language to “canonicalize” the objects.
These libraries will also potentially make requests to arbitrary domains unless you limit them to a specific set of context
documents with a custom loader. They will definitely need access to the contexts https://www.w3.org/ns/activitystreams
and https://w3id.org/identity/v1
.
ActivityPub Objects
Requests to an inbox take the form of an Activity object (basically a verb) wrapping some kind of noun object, usually a Note. You can see examples of Mastodon’s implementations of these by querying your own outbox on a Mastodon server.
Conclusion
These are my notes on the basics of talking to ActivityPub servers. I’m still thinking about how best to store and manipulate the objects within the server. There’s also a lot more detail about how Mastodon uses the various Activity and other objects to represent different sorts of user actions. These can mostly be found by looking at outbox contents.