Some quick thoughts on Unit Testing ActivityPub
https://shkspr.mobi/blog/2026/10/some-thoughts-on-unit-testing-activitypub/I'll be radically honest here - I never really got the idea of "Test Driven Development". I'm much more of a "telnet into production and damn the consequences" kind of guy. Look, it's much more fun and my cardiologist recommends severe shocks every now and again.
The essence of unit testing is pretty simple:
- Feed a function some data
- See how it responds
Send it good data and if it returns "true" the test passes. Send it bad data and make sure it returns false. Easy peasy lemon squeezy.
As part of my NLnet funded work on ActivityBot, I'm writing some proper tests for my bot framework. I am hopeful that some of them will be useful to other projects doing similar things.
There are four major classes of tests - here are the problems I've found while writing them.
Basic text validation
A basic test might be how to validate that an ActivityPub username is valid. Typically it would be something like @edent@mastodon.social - what assumptions do we make with that? It starts with an @ and has another @ somewhere in it, dividing into two parts, the user and the server. The server has to be a valid domain name - but that can include non-ASCII Internationalised Domain Names and Emoji domains.
What about the user part? Is it just A-Z and 0-9? Can it have dots? What about apostrophes like an email address?
Basically, how easy is it find the specifications for all the various component parts of ActivityPub? Not very!
Message validation
What does a WebFinger response look like? Which parts are mandatory and what values can they contain?
With any message that your project generates, how can you be sure that other ActivityPub servers will understand it?
Similarly, when we receive a follow message from an external server, what should it look like?
There are some JSON Schemas which can be used to validate some type of messages.
But, again, there are many scattered specifications.
Signature verification
As I've written about before, HTTP Signatures are difficult. There are two main problems. The first is verifying signatures.
In order to do that, you need to gather a bunch of signatures and then find a way to replay them into the test. So I'm in the process of saving signatures which I can then share with others.
The second problem is generating signatures. Sure, you can run tests on the signatures - but there's only one real way to test where's they're acceptable…
Communicating with others
Passing tests means nothing if you can't communicate with others and they can't communicate with you.
Some tests are easier than others. Sending a signed request should be easy and repeatable. But receiving messages isn't easily automated. It requires being able to control an external server and asking that to send messages.
With the Mastodon API it is possible to make requests which will interact with your server - but only if it is a public server.
Putting it all together
I'm putting all my tests online. I'm also sharing a collection of messages and signatures which you can use in your tests.
I'd love to know if you find them useful.
#ActivityBot #ActivityPub #phpA reasonably practical guide to validating RFC 9421 HTTP Signatures for ActivityPub in PHP
https://shkspr.mobi/blog/2026/09/a-reasonably-practical-guide-to-validating-rfc-9421-http-signatures-for-activitypub-in-php/If you're reading this, you've probably been hitting your head against a brick wall trying to parse and decipher the new HTTP Signatures sent by Mastodon and other Fediverse servers.
This is a basic and somewhat incomplete guide to accepting these signatures. I'm sure there are various gotchas, but it works with the signatures I've seen in the wild.
Shut Up And Show Me The Code!
OK, wow, no need to be a dick about it! Here's how I validated a real signature that my server received.
PHP
$verified = openssl_verify( data: '"@method": POST"@target-uri": https://example.viii.fi/inbox"content-digest": sha-256=:tFdB/ENGczHMlZMDb66pXoUi2d0OqH2iBHdnN/WV1mc=: "@signature-params": ("@method" "@target-uri" "content-digest");created=1787780262;keyid="https://mastodon.social/users/Edent#main-key"', signature: base64_decode( "sIfmNsM/Q8iG6AJlne1IkZVjQSVFDEYIPsnoSOXQY+W3Eb4+SOn9o4J5SQmFOP+Jecjf3ioFwUdsrFjAGkUUOHPvSbNWkGKtNuGm+C6r3aI3JBCFGPqX3ITgZYV76CF7JJJ5hPGaG8YH/XdmxVIeFfD3M39FQCncMyyq7xJJvwKKP1mzS5s1vNQie8hbQ9owRjtqvoWcmM9GEYCUHNcMPLjZc+CBrj8sfBbNTYgIFI4UtirOaRJvYymxXjmXuzeVYxQujMjAjgobxQ8QFv0zlYsHk+gS5EYyafpJG9zmfCFSoF9+ZwqKNADmuADbISD9LZIH/bmkPoNXhxaeFPqYog==" ), public_key: "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsYMEs4waqk/6gaS+xn1T\nYygElTtNIFNkBcEdEBMaeoGVhyZiVKtSjJCS4z+X+394PKvcfSTcFILIt2GI2jOB\nHD0M2fFgxc8mmdSdCQkgEh9jF3bFI3kopDvzYf726iioYKlHXKpfPKvFt7EJgKH7\naCtS25NQkek3YUd6y3VBcT3R6Xhze9P3QNoZMIsFXklgXDKj+EllfbUqLf1vxt3s\nmD9ETxy2bJi9FheE0uY2WhARn49XAvwczM5Wzt+zqxVEtgpi5v2+ZZAVKhDnJkiC\nCCuI6hrSnKNIx/5mSlX0a0S5h5d03djrCkYsqmwelu01rhOXP2grsz4BXp0y2wrO\n3QIDAQAB\n-----END PUBLIC KEY-----\n", algorithm: "sha256" ); echo $verified;
Copy and paste that into PHP and you should see that $verified is true.
NOW EXPLAIN THE CODE
Say please.
PLEASE!!!
Along with the message sent to your server, you will have received HTTP headers like this:
![]()
content-digest: sha-256=:tFdB/ENGczHMlZMDb66pXoUi2d0OqH2iBHdnN/WV1mc=: signature: sig1=:sIfmNsM/Q8iG6AJlne1IkZVjQSVFDEYIPsnoSOXQY+W3Eb4+SOn9o4J5SQmFOP+Jecjf3ioFwUdsrFjAGkUUOHPvSbNWkGKtNuGm+C6r3aI3JBCFGPqX3ITgZYV76CF7JJJ5hPGaG8YH/XdmxVIeFfD3M39FQCncMyyq7xJJvwKKP1mzS5s1vNQie8hbQ9owRjtqvoWcmM9GEYCUHNcMPLjZc+CBrj8sfBbNTYgIFI4UtirOaRJvYymxXjmXuzeVYxQujMjAjgobxQ8QFv0zlYsHk+gS5EYyafpJG9zmfCFSoF9+ZwqKNADmuADbISD9LZIH/bmkPoNXhxaeFPqYog==: signature-input: sig1=("@method" "@target-uri" "content-digest");created=1787780262;keyid="https://mastodon.social/users/Edent#main-key"
The signature-input tells you how to construct a "Signature Base". You have to build a text string which places the various components in the order specified and separated with a newline:
![]()
"@method": POST "@target-uri": https://example.viii.fi/inbox "content-digest": sha-256=:tFdB/ENGczHMlZMDb66pXoUi2d0OqH2iBHdnN/WV1mc=: "@signature-params": ("@method" "@target-uri" "content-digest");created=1787780262;keyid="https://mastodon.social/users/Edent#main-key"
Where @method is the HTTP method used to send data to your server (usually GET or POST), and @target-uri is the URl the message was sent to (usually your inbox).
The publicKey is slightly trickier. As you can see, the signature-input ends with keyid="https://mastodon.social/users/Edent#main-key
If you make a signed request to that URl, you'll get back an ActivityPub Actor document. It will look something like this:
JSON
{ "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", ], "id": "https://mastodon.social/users/Edent", "webfinger": "Edent@mastodon.social", "type": "Person", "name": "Terence Eden", "publicKey": { "id": "https://mastodon.social/users/Edent#main-key", "owner": "https://mastodon.social/users/Edent", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsYMEs4waqk/6gaS+xn1T\nYygElTtNIFNkBcEdEBMaeoGVhyZiVKtSjJCS4z+X+394PKvcfSTcFILIt2GI2jOB\nHD0M2fFgxc8mmdSdCQkgEh9jF3bFI3kopDvzYf726iioYKlHXKpfPKvFt7EJgKH7\naCtS25NQkek3YUd6y3VBcT3R6Xhze9P3QNoZMIsFXklgXDKj+EllfbUqLf1vxt3s\nmD9ETxy2bJi9FheE0uY2WhARn49XAvwczM5Wzt+zqxVEtgpi5v2+ZZAVKhDnJkiC\nCCuI6hrSnKNIx/5mSlX0a0S5h5d03djrCkYsqmwelu01rhOXP2grsz4BXp0y2wrO\n3QIDAQAB\n-----END PUBLIC KEY-----\n" },
The publicKeyPem is the string you need. There's no need to convert the \n to literal newlines.
Is that it?
Not quite! All we've done so far is verify the headers. It is possible that these are genuine headers but attached to a fraudulent body.
This takes us back to the header "content-digest": sha-256=:tFdB/ENGczHMlZMDb66pXoUi2d0OqH2iBHdnN/WV1mc=:
That says that the body of the message sent has a Base64 encoded SHA256 hash of tFdB/ENGczHMlZMDb66pXoUi2d0OqH2iBHdnN/WV1mc=.
To calculate your own content digest in PHP:
PHP
$input = file_get_contents( "php://input" ); $digestCalculated = base64_encode( hash( algo: "sha256", data: $input, binary: true ) );
Does your digest match the one sent along with the headers? If not, something dodgy is going on.
Putting it all together
The steps are:
- Get the headers.
- Get the body.
- From the headers'
content-digestextract the algorithm and hash. - Using the body, calculate your own hash using the algorithm from
content-digest. - Does your hash match the sent hash? If not, stop. If so, proceed.
- From the headers'
signatureextract the base64 encoded signature. - From the headers'
signature-inputextract the signature-input string. - From the signature-input string extract the order of the Signature Base.
- Construct the Signature Base.
- From the signature-input string extract the keyid.
- Get the Public Key from the keyid.
- Use
openssl_verify()to verify the Signature Base and the base64 decoded signature, against the Public Key using SHA256.
Note, Mastodon only uses SHA256. I think it should explicitly say which algorithm it is using and have raised the issue.
In Code Form
This is how you do it in PHP. Please read this carefully as there are some hard-coded assumptions.
PHP
<?php// Validate the Digest.// It is the hash of the raw input string, in binary, encoded as base64.// The format is content-digest => <algorithm>=:<base64 encoded hash>:$digestString = $headers["content-digest"]; // The Base64 encoding may have multiple `=` at the end. So split this at the first `=`.$digestData = explode( separator: "=", string: $digestString, limit: 2 ); // Hashes are in lowercase, but have a `-` in their name.// This is not what hash_algos() expects.$digestAlgorithm = str_replace( search: "-", replace: "", subject: $digestData[0] ); // The hash is surrounded by `:` characters.$digestHash = str_replace( search: ":", replace: "", subject: $digestData[1] ); // Check if the hash algorithm is one known about to PHP.// If not, reject and record an error.if ( !in_array( needle:$digestAlgorithm, haystack: hash_algos() ) ) { return false; } // Manually calculate the digest based on the data sent.$digestCalculated = base64_encode( hash( algo: $digestAlgorithm, data: $input, binary: true ) ); // Does our calculation match what was sent?if ( !( $digestCalculated == $digestHash ) ) { return false; } // The signature format is signature => <signature name>=:<base64 encoded hash>:$signatureString = $headers["signature"]; // The Base64 encoding may have multiple `=` at the end. So split this at the first `=`.$signatureData = explode( separator: "=", string: $signatureString, limit: 2 ); $signatureName = $signatureData[0]; // The signature is surrounded by `:` characters.$signatureB64 = str_replace( search: ":", replace: "", subject: $signatureData[1] ); // The signature-input format is complicated!$signatureInputString = $headers["signature-input"]; // Get the parameters. Assume there is only one signature.$signatureParamsString = explode( separator: "=", string: $signatureInputString, limit: 2 )[1]; // Get the different elements of the signature.$signatureInputData = explode( separator: ";", string: $signatureInputString ); // Construct the data.$signatureInput = []; foreach( $signatureInputData as $signatureInputParts ) { $partsData = explode( separator: "=", string: $signatureInputParts ); // Strip quotes from keyid and parentheses from sig1. if ( "keyid" == $partsData[0] ) { $partsData[1] = str_replace( search: "\"", replace: "", subject: $partsData[1] ); } if ( $signatureName == $partsData[0] ) { $partsData[1] = str_replace( search: ["(", ")"], replace: "", subject: $partsData[1] ); } $signatureInput[ $partsData[0] ] = $partsData[1] ; } $signatureStructure = $signatureInput[$signatureName]; $signatureKeyID = $signatureInput["keyid"]; // Remove quotes.$signatureStructure = str_replace( search: "\"", replace: "", subject: $signatureStructure ); $signatureStructureData = explode( separator: " ", string: $signatureStructure ); // https://www.rfc-editor.org/info/rfc9421/#section-2.5$signatureBase = ""; foreach ( $signatureStructureData as $signatureStructureParts ) { if ( "@method" == $signatureStructureParts ) { // https://www.rfc-editor.org/info/rfc9421/#name-method $signatureBase .= "\"@method\": " . $_SERVER["REQUEST_METHOD"] . "\n"; } if ( "@target-uri" == $signatureStructureParts ) { // https://www.rfc-editor.org/info/rfc9421/#section-2.2.2 // Change the domain name to your own. $signatureBase .= "\"@target-uri\": https://EXAMPLE.COM" . $_SERVER["REQUEST_URI"] . "\n"; } if ( "content-digest" == $signatureStructureParts ) { $signatureBase .= "\"content-digest\": $digestString\n"; } } // https://victoronsoftware.com/posts/http-message-signatures/#how-the-signature-is-created$signatureBase .= "\"@signature-params\": $signatureParamsString"; // Get the signing user's public key.// This is usually in the form `https://example.com/user/username#main-key`// This is to differentiate if the user has multiple keys.// This may need to be a signed request. You will need to write your own getDataFromURl() function to get the sending user's key.$userData = getDataFromURl( $signatureKeyID ); $publicKey = $userData["publicKey"]["publicKeyPem"]; // Verify the request$verified = openssl_verify( data: $signatureBase, signature: base64_decode( $signatureB64 ), public_key: $publicKey, algorithm: $digestAlgorithm ); // Convert the result to boolean.if ( $verified === 1 ) { $verified = true; } elseif ( $verified === 0 ) { $verified = false; } else { $verified = null; } return $verified;
Further Reading
- RFC 9421 HTTP Message Signatures
- Understanding HTTP message signatures: A developer's guide
- Sign and verify HTTP messages (RFC 9421)
- Verification of HTTP Message Signatures
- HTTP-Message-Signer in PHP
Thanks to NLnet
This blog post was funded in part by the work I'm doing for my NLnet NGI0 grant. Thanks!
#ActivityBot #ActivityPub #mastodon #php #webdev
