Implementing HTTP Signatures with PHP and OpenSSL

Display mode

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:

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.

Flowchart of message signing and verification, for delivery of a Note to a follower
Figure 1: Signing and verification of an ActivityPub message

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:

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:

$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.


1 like