A simple(ish) guide to verifying HTTP Message Signatures in PHP


Mastodon makes heavy use of HTTP Message Signatures. They're a newish almost-standard which allows a server to verify that a request made to it came from the person who sent it.

This is a quick example to show how to verify these signatures using PHP. I don't claim that it covers every use-case, and it is no-doubt missing some weird edge cases. But it successfully verifies messages sent by multiple Fediverse servers.

Let's step through it with an example of a message sent from Mastodon to my server.

Headers

The HTTP request starts with these headers:

User-Agent:  http.rb/5.1.1 (Mastodon/4.3.0-nightly.2024-02-23; +https://mastodon.social/)
Host:  example.com
Date:  Sun, 25 Feb 2024 10:48:22 GMT
Accept-Encoding:  gzip
Digest:  SHA-256=Hqu/6MR2imi8DTzbNp5PNEAFSyk0poN7+x5F+Z4vZMg=
Content-Type:  application/activity+json
Signature:  keyId="https://mastodon.social/users/Edent#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest content-type",signature="P07V5I2zflR8FRsDMHshHmhgOwSkjWevujEbOyKMwjycrdVXjTD0ACiLuc5lTqDEXZ/...4eg=="
Connection:  Keep-Alive
Content-Length:  2857

Some of those you may be familiar with, some not. The first thing we'll do is a sanity check; was this message sent recently? Because clocks drift in and out of synchronisation, we'll check if the message was within ±30 seconds.

PHP PHP$headers = getallheaders();
if ( !isset( $headers["Date"] ) ) { return null; }    //  No date set
$dateHeader = $headers["Date"];
$headerDatetime  = DateTime::createFromFormat('D, d M Y H:i:s T', $dateHeader);
$currentDatetime = new DateTime();

// Calculate the time difference in seconds
$timeDifference = abs( $currentDatetime->getTimestamp() - $headerDatetime->getTimestamp() );
return ( $timeDifference < 30 );

That was easy! On to the next bit.

Digest

A message posted to the server usually has a body. In this case it is a long string of JSON data. To ensure the message hasn't been altered in transit, one of the headers is:

Digest:  SHA-256=Hqu/6MR2imi8DTzbNp5PNEAFSyk0poN7+x5F+Z4vZMg=

That says, if you do a SHA-256 hash of the JSON you received, and convert that hash to Base64, it should match the digest in the header.

PHP PHP$digestString = $headers["Digest"];
//  Usually in the form `SHA-256=Hqu/6MR2imi8DTzbNp5PNEAFSyk0poN7+x5F+Z4vZMg=`
//  The Base64 encoding may have multiple `=` at the end. So split this at the first `=`
$digestData = explode( "=", $digestString, 2 );
$digestAlgorithm = $digestData[0];
$digestHash = $digestData[1];

//  There might be many different hashing algorithms
//  TODO: Find a way to transform these automatically
if ( "SHA-256" == $digestAlgorithm ) {
    $digestAlgorithm = "sha256";
} else if ( "SHA-512" == $digestAlgorithm ) {
    $digestAlgorithm = "sha512";
}

$json = file_get_contents( "php://input" );

//  Manually calculate the digest based on the data sent
$digestCalculated = base64_encode( hash( $digestAlgorithm, $json, true ) );

return ( $digestCalculated == $digestHash );

But, of course, if someone has manipulated the JSON, they may also have manipulated the digest. So it is time to look at the signature.

The Signature

Let's take a look at the signature header:

Signature:
  keyId="https://mastodon.social/users/Edent#main-key",
  algorithm="rsa-sha256",
  headers="(request-target) host date digest content-type",
  signature="P07V5I2zflR8FRsDMHshHmhgOwSkjWevujEbOyKMwjycrdVXjTD0ACiLuc5lTqDEXZ/...4eg=="

This contains 4 pieces of information.

  1. keyID - a link to the user's public key.
  2. algorithm - the algorithm used by this signature.
  3. headers - the headers which make up the string to be signed.
  4. signature - the signature string.

Let's split them up so they can be used:

PHP PHP//  Examine the signature
$signatureHeader = $headers["Signature"];

// Extract key information from the Signature header
$signatureParts = [];
//  Converts 'a=b,c=d e f' into ["a"=>"b", "c"=>"d e f"]
               // word="text"
preg_match_all('/(\w+)="([^"]+)"/', $signatureHeader, $matches);
foreach ($matches[1] as $index => $key) {
    $signatureParts[$key] = $matches[2][$index];
}

Let's tackle each part in order.

Get the user's public key

You might think you can just get https://mastodon.social/users/Edent#main-key - but you would be wrong.

Firstly, you need to tell the key server that you want the JSON representation of the URl - otherwise you'll end up with HTML.

PHP PHP$publicKeyURL = $signatureParts["keyId"];
$context   = stream_context_create(
    [ "http" => [ "header" => "Accept: application/activity+json" ] ]
);
$userJSON  = file_get_contents( $publicKeyURL, false, $context );

That gets you the JSON representation of the user. On Mastodon, the key can be found at: Screenshot of JSON. As described in text.

I don't know how to automatically find the key, so I've hard-coded its location.

PHP PHP$userData  = json_decode( $userJSON, true );
$publicKey = $userData["publicKey"]["publicKeyPem"];

Get the algorithm

This is rather straightforward. It's just the text in the signature header:

PHP PHP$algorithm = $signatureParts["algorithm"];

Reconstruct the signing header

Let's take a look at the third piece of the puzzle:

headers="(request-target) host date digest content-type"

This says "The signature is based on the following parts in order". So we only care about the headers which make up the request, the host, the date, the digest, and the content type. Other servers may require different parts of the headers.

Again, let's tackle them in order.

(request-target)

This means the method of the request and the target it was sent to. In our example, this is a POST sent to the path /inbox.

host

This is the HTTP host the message was sent to. This should be retrieved from the server, not taken from the sent headers.

date digest content-type

These are the values from the headers which were sent with the request.

Putting it all together

Annoyingly, the HTTP headers are written in Title-Case whereas the headers in the Signature are in lower-case. So some conversion is necessary:

PHP PHP//  Manually reconstruct the header string
$signatureHeaders = explode(" ", $signatureParts["headers"] );
$signatureString = "";
foreach ($signatureHeaders as $signatureHeader) {
    if ( "(request-target)" == $signatureHeader ) {
        $method = strtolower( $_SERVER["REQUEST_METHOD"] );
        $target = strtolower( $_SERVER["REQUEST_URI"] );
        $signatureString .= "(request-target): {$method} {$target}\n";
    } else if ( "host" == $signatureHeader ) {
        $host = strtolower( $_SERVER["HTTP_HOST"] );  
        $signatureString .= "host: {$host}\n";
    } else {
        //  In the HTTP header, the keys use Title Case
        $signatureString .= "{$signatureHeader}: " . $headers[ ucwords( $signatureHeader, "-" ) ] . "\n";
    }
}

//  Remove trailing newline
$signatureString = trim( $signatureString );

This results in a string like this:

(request-target): post /inbox
host: example.com
date: Sun, 25 Feb 2024 10:48:22 GMT
digest: SHA-256=Hqu/6MR2imi8DTzbNp5PNEAFSyk0poN7+x5F+Z4vZMg=
content-type: application/activity+json

Get the signature

The signature that we are sent is in Base64.

signature="P07V5I2zflR8FRsDMHshHmhgOwSkjWevujEbOyKMwjycrdVXjTD0ACiLuc5lTqDEXZ/...4eg=="

It needs to be decoded before we can use it.

PHP PHP$signature = base64_decode( $signatureParts["signature"] );

Verify the signature

We're nearly there! Luckily, we don't have to do any crazy cryptography by hand. We use PHP's openssl_verify():

PHP PHP//  Finally! Calculate whether the signature is valid
$verified = openssl_verify(
    $signatureString,
    $signature,
    $publicKey,
    $algorithm
);

That takes the reconstructed string based on the headers, the signature which was sent, the public key we retrieved, and the algorithm.

If it all matches, it will return true. If not... time for some debugging!

But what about...?

This is not a complete solution. My code almost certainly contains bugs, unforeseen edge-cases, memory leaks, black holes, and poisonous frogs. This is intended to step you through the practical process of verifying an HTTP Message Signature.

Then you should get a properly tested and validated library and use that instead.


Share this post on…

  • Mastodon
  • Facebook
  • LinkedIn
  • BlueSky
  • Threads
  • Reddit
  • HackerNews
  • Lobsters
  • WhatsApp
  • Telegram

2 thoughts on “A simple(ish) guide to verifying HTTP Message Signatures in PHP”

  1. @marxjohnson @blog the best info i could find is that there's no way to guess the algorithm from a public key so all implementations default hs2019 to rsa-sha256, which is not ideal but true in all(?) known cases.

    anyway that's how i ended up coding my own implementation, with an added comment indicating that this is bound to change at some point.

    the info i found is here: https://github.com/friendica/friendica/issues/8839#issuecomment-653787501

    | Reply to original comment on mastodon.top

What links here from around this blog?

What are your reckons?

All comments are moderated and may not be published immediately. Your email address will not be published.

Allowed HTML: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong> <p> <pre> <br> <img src="" alt="" title="" srcset="">