How to make friends and verify requests
Implementing an ActivityPub inbox
Eugen Rochko
CEO / Founder
In the previous tutorial we have learned how to send a reply to another ActivityPub server, and we have used mostly static parts to do it. Now it’s time to talk about how to subscribe to other people and receive messages.
The inbox
Primarily this means having a publicly accessible inbox and validating HTTP signatures. Once that works, everything else is just semantics. Let’s use a Sinatra web server to implement the inbox.
In fact, I intend to omit persistence from this tutorial. How you would want to store data in a real application is very much up for debate and depends on your goals and requirements. So, we’re going to store data in a variable and implement a simple way to inspect it.
require 'sinatra'
INBOX = []
get '/inspect' do
[200, INBOX.join("\n\n")]
end
post '/inbox' do
request.body.rewind
INBOX << request.body.read
[200, 'OK']
end
That’s an absolutely basic implementation. Save it in server.rb
. You can run the server with ruby server.rb
(you need the Sinatra gem installed before that: gem install sinatra
). Now on this server you can navigate to /inspect
to see the contents of your inbox, and you (and anyone, really) can POST to the /inbox
to add something there.
Of course, anyone being able to put anything in there is not ideal. We need to check the incoming POST requests for a HTTP signature and validate it. Here is what a HTTP signature header looks like:
Signature: keyId="https://my-example.com/actor#main-key",headers="(request-target) host date",signature="Y2FiYW...IxNGRiZDk4ZA=="
We need to read the Signature
header, split it into its parts (keyId
, headers
and signature
), fetch the public key linked from keyId
, create a comparison string from the plaintext headers we got in the same order as was given in the signature header, and then verify that string using the public key and the original signature.
require 'json'
require 'http'
post '/inbox' do
signature_header = request.headers['Signature'].split(',').map do |pair|
pair.split('=').map do |value|
value.gsub(/\A"/, '').gsub(/"\z/, '') # "foo" -> foo
end
end.to_h
key_id = signature_header['keyId']
headers = signature_header['headers']
signature = Base64.decode64(signature_header['signature'])
actor = JSON.parse(HTTP.get(key_id).to_s)
key = OpenSSL::PKey::RSA.new(actor['publicKey']['publicKeyPem'])
comparison_string = headers.split(' ').map do |signed_header_name|
if signed_header_name == '(request-target)'
'(request-target): post /inbox'
else
"#{signed_header_name}: #{request.headers[signed_header_name.capitalize]}"
end
end
if key.verify(OpenSSL::Digest::SHA256.new, signature, comparison_string)
request.body.rewind
INBOX << request.body.read
[200, 'OK']
else
[401, 'Request signature could not be verified']
end
end
The code above is somewhat simplified and missing some checks that I would advise implementing in a serious production application. For example:
- The request contains a
Date
header. Compare it with current date and time within a reasonable time window to prevent replay attacks. - It is advisable that requests with payloads in the body also send a
Digest
header, and that header be signed along in the signature. If it’s present, it should be checked as another special case within the comparison string: Instead of taking the digest value from the received header, recompute it from the received body. - While this proves the request comes from an actor, what if the payload contains an attribution to someone else? In reality you’d want to check that both are the same, otherwise one actor could forge messages from other people.
Still, now you have a reasonably secure toy inbox. Moving on.
Following people
To register as a follower of someone, you need to send them a Follow
activity. The receiver may manually decide whether to allow that or not, or their server may do it automatically, but in the case of success you will receive an Accept
activity back referring to your Follow
. Here is how a Follow
may look like, if you would like to follow the official Mastodon project account, the URI of which is https://mastodon.social/users/Mastodon
:
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://my-example.com/my-first-follow",
"type": "Follow",
"actor": "https://my-example.com/actor",
"object": "https://mastodon.social/users/Mastodon"
}
Make sure your actor JSON points to your inbox, and your inbox server is running and publicly accessible under that URL, then deliver that activity to the target user’s inbox, in our example it would be https://mastodon.social/inbox
.
If everything works correctly, inspecting your inbox you should find an Accept
activity. Afterwards, you will find other activities in there from the person you followed, like Create
, Announce
and Delete
.
Ideally, you’d follow your own Mastodon account, just so you can control when to post, otherwise you may end up waiting for your inbox to fill for a long time.
Conclusion
This brings you almost all the way to a fully functioning ActivityPub server. You can send and receive verified messages and subscribe to other people. As mentioned at the start, everything else is semantics. To support other people subscribing to you, you would listen for incoming Follow
activities, send back an appropriately formatted Accept
activity, write down the follower somewhere and send them every new post you create.
Read more on: