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$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$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.
keyID
- a link to the user's public key.algorithm
- the algorithm used by this signature.headers
- the headers which make up the string to be signed.signature
- the signature string.
Let's split them up so they can be used:
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$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:
I don't know how to automatically find the key, so I've hard-coded its location.
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$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// 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$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// 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.
Terence Eden says:
@marxjohnson @blog I don't know.
Best place to ask is https://github.com/swicg/activitypub-http-signature/issues/
david turgeon says:
@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
More comments on Mastodon.