Back to Blogging in the Fediverse
Over the last few months, I've slowly been adding ActivityPub support to this blog, focusing on Mastodon compatibility. Two central tenets apply to the operation of the AP protocol as it applies specifically to Mastodon:
- An AP user (an "actor" in the protocol's terminology[1]Seb Jambor, "Understanding ActivityPub", May 2023) can send an activity (for example, a Like on a post) and it will be broadcast to any servers hosting that user's followers;
- Receiving servers can verify that the activity was generated by the user in question.
ActivityPub uses the HTTP Signature header for this second point, with public key encryption. It should be noted though, that there is a standard RFC for the Signature header[2]RFC 9421, "HTTP Message Signatures", Feb 2024 but AP was built before this RFC was released; instead, ActivityPub uses a draft version of the Signature specification[3]RFC 9421 draft 12, "Signing HTTP Messages", Oct 2019 which operates slightly differently.
In Figure 1 above, we see the two halves of the process ActivityPub employs using the Signature header: the sender of an activity creates a message signed with their private key, and any receivers verify the message signature with the sender's public key. Let's first look at what goes into generation of the signature.
Generating a signature
The first thing we'll need is a Note, a message created by an actor; in this case, we'll use the creation of a toot by alice@tech.lgbt
[A]As it turns out, the example accounts being used for this article are both extant accounts, but bob@mastodon.social
doesn't actually follow alice@tech.lgbt
as of the time of writing. which is represented by the following JSON object.
Note written by the sender
{ "@context": {...}, "id": "https://tech.lgbt/users/alice/statuses/12345678", "type": "Note", "url": "https://tech.lgbt/@alice/12345678", "attributedTo": "https://tech.lgbt/users/alice", "published": "2024-03-30T15:50:09Z", "to": ["https://www.w3.org/ns/activitystreams#Public"], "cc": ["https://tech.lgbt/users/alice/followers"], "content": "Hello followers!" }
In order to broadcast this Note, ActivityPub requires that it be wrapped in a Create activity informing the recipients of the creation of an object:
Message to be sent
{ "@context": {...}, "id": "https://tech.lgbt/users/alice/statuses/12345678/activity", "type": "Create", "actor": "https://tech.lgbt/users/alice", "published": "2024-03-30T15:50:09Z", "to": ["https://www.w3.org/ns/activitystreams#Public"], "cc": ["https://tech.lgbt/users/alice/followers"], "object": { "id": "https://tech.lgbt/users/alice/statuses/12345678", "type": "Note", "url": "https://tech.lgbt/@alice/12345678", "attributedTo": "https://tech.lgbt/users/alice", "published": "2024-03-30T15:50:09Z", "to": ["https://www.w3.org/ns/activitystreams#Public"], "cc": ["https://tech.lgbt/users/alice/followers"], "content": "Hello followers!" } }
Now we have the activity that will be broadcast to alice's followers, we can start the signature process; we'll need a fresh signature for each server that's receiving this message. For this example, alice has exactly one follower, on mastodon.social
, so we'll need to send this message to the server's "shared inbox" at: https://mastodon.social/inbox
[B]Mastodon servers, by convention, have their shared inbox at /inbox
but the canonical URL can be found in the actor's details, at the endpoints.sharedInbox
key. to be forwarded on to bob's personal inbox.
We first need a message digest, which by convention is an SHA-256 hash of the message content; in PHP, one might generate that as below.
PHP code to generate the Digest header
$message = json_encode([ '@context' => [...], ... ]); $digest = 'SHA-256=' . hash('sha256', $message);
The hash digest is one of the parts of the signature; other parts that we need to include are:
- Host of the remote server to which we're sending this message, in this case
mastodon.social
; - Request target is essentially the first line of the HTTP request we'll be making, in this case
post /inbox
; - Content type which isn't a requirement, but can be seen emitted by Mastodon hosts when communicating with each other. If this is part of the signature, it would be
application/activity+json
; - Date of the request, which can be verified by the other side to ensure this message isn't being replayed after the fact. The caveat here is that the date is in a particular format that's different to the date format in the activity JSON, for example:
Sat, 30 Mar 2024 15:50:09 GMT
Pulling these together doesn't take too much doing in PHP:
Collating signature parts
// We'll be sending the date separately, so take one fixed point$dt = date("D, d M Y H:i:s \G\M\T"); $url = 'https://mastodon.social/inbox'; $urlparts = parse_url($url); $sigparts = [ '(request-target)' => 'post ' . $urlparts['path'], 'host' => $urlparts['host'], 'date' => $dt, 'digest' => $digest, 'content-type' => 'application/activity+json', ]; $sigsrc = join("\n", array_map( fn($k, $v) => "{$k}: {$v}", array_keys($sigparts), array_values($sigparts) ));
Our collated signature source
(request-target): post /inbox host: mastodon.social date: Sat, 30 Mar 2024 15:50:09 GMT digest: SHA-256=ce9a290f805f....1bbcf5104ad7550191201 content-type: application/activity+json
Once we have the collated signature source, we can invoke OpenSSL to sign the string with the private part of alice's key pair. PHP's OpenSSL bindings expect a PEM-formatted file for this, which for this example we're storing in /etc/pki
somewhere:
Signing the collated signature source
openssl_sign($sigsrc, $signature, openssl_get_privatekey( file_get_contents('/etc/pki/activitypub/alice/private.pem'), 'key_passphrase' ), OPENSSL_ALGO_SHA256);
This leaves the signature in $signature
, in binary. Our last step in signing the request is to format the signature according to the signing spec, detailing what we've used to generate this signature, and which key was involved.
For the remote end to be able to verify the signature, we'll need to provide a URL at which alice's public key can be fetched; in the ActivityPub protocol, this is included as part of the details of an actor, as we'll see when we go through verification at the other end.
Final header generation
$sig_header_parts = [ 'keyId' => 'https://tech.lgbt/users/alice#main-key', 'algorithm' => 'rsa-sha256', 'headers' => join(' ', array_keys($sigparts)), 'signature' => base64_encode($signature), ]; $headers = [ 'Accept: application/activity+json', 'Content-Type: application/activity+json', 'Host: ' . $urlparts['host'], 'Date: ' . $dt, 'Digest: ' . $digest, 'Signature: ' . join(',', array_map( fn($k, $v) => "{$k}=\"{$v}\"", array_keys($sig_header_parts), array_values($sig_header_parts) )), ];
Final list of headers
Accept: application/activity+json Content-Type: application/activity+json Host: mastodon.social Date: Sat, 30 Mar 2024 15:50:09 GMT Digest: SHA-256=ce9a290f805f....1bbcf5104ad7550191201 Signature: keyId="https://tech.lgbt/users/alice#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest content-type",signature="xmpTJEEjodgUP5H....BrN3DwZGy7DoTfRQ=="
And we're ready to send alice's message over to bob's Mastodon instance, with the headers indicating that it's been signed by alice and a digest hash to allow the message content to also be verified.
Verifying a signature
As per Figure 1, when mastodon.social
receives alice's activity it will first verify that this is a both a valid activity and that it's appropriately signed, and then the server will drop the message into any of alice's followers that are recorded as active on the instance.
The first step is to extract the signature and its components. We've seen from the generation code that this is passed through as a HTTP header, so if we place a dumping script at /inbox
it might see something like this:
Dumping script to see what lands at /inbox
<?php $input = file_get_contents('php://input'); var_dump([$_SERVER, $message]);
Output of the dumping script
array(2) { [0]=> array(29) { ["USER"]=> string(8) "www-data" ... ["HTTP_CONTENT_TYPE"]=> string(25) "application/activity+json" ["HTTP_DATE"]=> string(29) "Sat, 30 Mar 2024 15:50:09 GMT" ["HTTP_DIGEST"]=> string(52) "SHA-256=ce9a290f805f..." ["HTTP_SIGNATURE"]=> string(502) "keyId="https://tech.lgbt/users/alice#main-key",algori..." ... ["REQUEST_METHOD"]=> string(4) "POST" ["REQUEST_URI"]=> string(6) "/inbox" } [1]=> string(1347) "{"@context":{..." }
As we'd expect for PHP, the HTTP headers come to the script in the $_SERVER
superglobal, with the HTTP_
prefix, so we can extract the parts of the signature that we need from $_SERVER['HTTP_SIGNATURE']
.
It should be noted that commas only appear in the signature to delineate the parts; each of the parts themselves is either a URL or a piece of text that won't contain a comma. This means we can treat the signature string as an INI file and parse it fairly simply, with some deft string replacement:
if (!isset($_SERVER['HTTP_SIGNATURE'])) { throw new Exception('No signature'); } $sigconf = parse_ini_string( strtr($_SERVER['HTTP_SIGNATURE'], ["," => "\n"]) ); if (!isset( $sigconf['keyId'], $sigconf['algorithm'], $sigconf['headers'], $sigconf['signature'] )) { throw new Exception('Malformed signature'); }
The first item in $sigconf
is the URL of the public half of the key pair used to generate this signature. In real-life usage we might expect to have this already cached or stored locally, but if we don't it will need to be fetched, which is something we can make happen through the curl
extension.
We've already seen that ActivityPub objects are passed back and forth with a MIME content type of application/activity+json
, so we'll need to set that as the MIME type we'll be expecting to Accept
. What we get back is an ActivityPub actor object:
Fetching the signing actor
$c = curl_init(); curl_setopt_array($c, [ CURLOPT_URL => $sigconf['keyId'], CURLOPT_TIMEOUT => 5, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Accept: application/activity+json', ], ]); $r = curl_exec($c); curl_close($c); if (!$r) { throw new Exception('User information not available'); } $actor = json_decode($r, true); if (!$actor) { throw new Exception('User information not decodable'); }
The actor object (formatted)
{ "@context": {...}, "id": "https://tech.lgbt/users/alice", "type": "Person", "following": "https://tech.lgbt/users/alice/following", "followers": "https://tech.lgbt/users/alice/followers", ... "url": "https://tech.lgbt/@alice", "publicKey": { "id": "https://tech.lgbt/users/alice#main-key", "owner": "https://tech.lgbt/users/alice", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhk..." }, "endpoints": { "sharedInbox": "https://tech.lgbt/inbox"[C]This is the key mentioned in note B. } }
The signing actor's public key comes through in the publicKey
block, so we'll hold onto that for the verification later. It's worth double-checking that the key we've been served as part of the actor is the same key that was used to sign the message, which we can do by comparing the key's id
with the keyId
that came through from the signature.
We'll be using the openssl
extension to perform verification, and we need to translate the PEM-formatted public key to a binary format for internal use by first calling openssl_get_publickey
:
if (!isset( $actor['publicKey'], $actor['publicKey']['id'], $actor['publicKey']['publicKeyPem'] )) { throw new Exception('Missing public key'); } if ($actor['publicKey']['id'] !== $sigconf['keyId']) { throw new Exception('Misattributed public key'); } $pubkey = openssl_get_publickey($actor['publicKey']['publicKeyPem']); if (!$pubkey) { throw new Exception('Malformed public key'); }
We're almost done with the first block of verification from Figure 1; the final stage before verifying the signature against alice's public key is to build the signature string based on the headers
key. We'll want to tack together the headers given to us, as specified in the order therein.
There are two things to watch out for here:
- HTTP headers are passed through
$_SERVER
as previously mentioned, but they're transformed into uppercase and hyphens are switched out for underscores; - The
(request-target)
signature part is a special case, as it consists of the request method (in lowercase) and the request URI, both of which come through in$_SERVER
separately.
$sigparts = []; foreach (explode(' ', $sigconf['headers']) as $hdr) { if ($hdr === '(request-target)') { $sigparts[] = sprintf( '%s: %s %s', $hdr, strtolower($_SERVER['REQUEST_METHOD']), $_SERVER['REQUEST_URI'] ); } else { $received_hdr = 'HTTP_' . strtoupper(strtr($hdr, ['-' => '_')); if (!isset($_SERVER[$received_hdr)) { throw new Exception('Missing signature part: ' . $hdr); } $sigparts[] = sprintf('%s: %s', $hdr, $_SERVER[$received_hdr]); } }
With the signature constructed, we can finally call out to OpenSSL to perform the verification against alice's public key, which we loaded into binary format earlier. Here, we're assuming that the algorithm
specified is a well-known value to OpenSSL like rsa-sha256
(the key algorithm used by Mastodon instances at time of writing), but it can be useful to verify this against a list of allowed values before proceeding if you're feeling particularly paranoid.
if (!openssl_verify( join("\n", $sigparts), base64_decode($sigconf['signature']), $pubkey, strtoupper($sigconf['algorithm']) )) { throw new Exception('Signature verification failed'); }
Finally, with the message's origin verified, we can proceed to verify the message content against the digest provided. Again in this case, we're supporting a limited subset of the possible hash algorithms that could be used for the digest, as Mastodon invariably sends messages with a SHA256 digest:
list($digest_algo, $digest_hash) = explode('=', $_SERVER['HTTP_DIGEST']); switch ($digest_algo) { case 'SHA256': $input_digest = hash('sha256', $input); break; default: throw new Exception('Unsupported digest algorithm'); } if ($input_digest !== $digest_hash) { throw new Exception('Digest verification failed'); }
And if we made it this far, past all the exception points, we have a valid ActivityPub message that's verified to have been signed by the owner of the originating key.
Further reading
It's always dangerous on this blog to promise a "next time", but: next time we'll look at the particular ActivityPub messages that we'd want to support when adding AP support to a blog, and the surrounding architecture of user and inbox handling.
More information about ActivityPub and the specifics of the Mastodon implementation can be found in the third part of Seb Jambor's series on Understanding ActivityPub[4]Seb Jambor, "The State of Mastodon", August 2023; the PHP implementation we've covered here is an expansion and explanation based on Terence Eden's single-file PHP integration of ActivityPub[5]Terence Eden, "AcitvityPub Server in a Single File", Feb 2024 which moves over the signature stage relatively quickly, and incorporates Eugen Rochko's insights on signature verification[6]Eugen Rochko, "How to make friends and verify requests", Jul 2018.