DEV Community

Cover image for Acknowledging & Signing Fediverse Activity Requests
Wade Zimmerman
Wade Zimmerman

Posted on • Updated on

Acknowledging & Signing Fediverse Activity Requests

Read this post first: How to Put Your Blog on the Fediverse

Once you read that post, you may wonder how to participate in Fediverse activities such as sharing content, gaining followers, or accepting comments.

The answer is you need to first learn how to acknowledge requests.

Resources

The Fediverse is still in its early stages. If you want to do things right, ensure you are writing unit tests according to spec, and don't be afraid to dig through other people's code. I did a lot of reverse engineering with ChatGPT because I was unfamiliar with Ruby. Little things like date formats, JSON encoding, and base64 can all throw off your signed requests!

Understanding the request flow (aka the handshake)

ActivityPub, at its core, is each user has an inbox and an outbox, supporting GET and POST requests. When someone from the internet requests a mutation, they send a POST request. They use a GET request when they want to fetch the current state. Keep email as your mental model, and it will make sense.

ActivityPub inbox/outbox flow

Difference between Inbox and Outbox

The outbox serves GET requests so other servers can choose to backfill posts. Sending POST requests to the outbox is used like a REST API. Clients POST to the outbox, and the server processes it later, sending a POST to other inboxes. The GET for inboxes is mainly intended for private use.

Acknowledging Requests FAQ

Since most interactions occur through the inbox, the first thing to do is to establish your user's inbox.

What mime type or content type should I use?

ActivityPub requests/responses should be application/ld+json; profile="https://www.w3.org/ns/activitystreams or application/activity+json unless specified otherwise. They should be treated equally.

My HTTP response contains the ActivityPub document; why is nothing happening?

To acknowledge an ActivityPub request, you MUST return a POST request to the Actor's inbox. This is how ActivityPub servers remain asynchronous.

POST requests are not working as expected!

Double-check you are attaching a Content-Type header when sending requests, and send your request as a raw string. Some libraries/frameworks will send your data as a form request or JSON.

Example Signed Acceptance/Rejection of Follow Request

In the previous tutorial, you should have defined an endpoint for handling inbox requests and included that on your user's ActivityPub profile response. That response is critical because servers use that to find your inbox.

Once you set up your server to handle incoming POST requests for your inbox. You will start to receive application/activity+json type requests like the following:

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://mastodon.social/96cb3649-7a75-49c5-b246-xxxxxxxxxxxx",
  "type": "Follow",
  "actor": "https://mastodon.social/users/codoxuba",
  "object": "https://example.com/activityPub/users/1"
}
Enter fullscreen mode Exit fullscreen mode

In the case of a Follow request. The Actor is the person/thing initiating the request. And the Object is the person/thing that they want to follow. The id is created by the server commencing the request. Most servers use the ID to track the request status. I will not be covering that in this post.

However, it's important to note that actor, object, and id can be unresolved URLs, as you see above, or they can be nested JSON-LD objects. I will not be covering that in this post.

Acknowledging the Incoming Activity

This is where you should brush up on your TCP/IP protocols because the concept is similar. I will mention upfront that it's wise to ignore requests by default. Any acknowledgment can be used against you in an attack. I only want to process follow requests, so I will ignore all other requests:

class InboxController extends Controller
{
    public function receiveFromInternet(Request $request, User $user)
    {
    // todo: better validation
        $activityType = $request->input('type');

        if ($requestType !== 'Follow') {
            return;
        }

        $acknowledgment = [
            "@context" => "https://www.w3.org/ns/activitystreams",
            "summary" => "Alice accepted a follow",
            "type" => "Accept",
            "actor" => "https://example.com/activityPub/users/1",
            "object" => $request->input('id'),
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

The acknowledgment is as simple as that. In the case of a follow request, you can choose to Accept, Reject, TenativeAccept, TenativeReject, or ignore. The problematic part is signing the acknowledgment.

Signing the Acknowledgment

Signed requests are based on HTTP Signatures Draft RFC. I will be using these variables throughout the signing process:

$url = parse_url($inboxUrl);
$host = data_get($url, 'host');
$path = data_get($url, 'path');

$publicKeyId = 'https://example.com/activityPub/users/1#main-key';

$date = now()->toRfc7231String();
Enter fullscreen mode Exit fullscreen mode

Next, encode your acknowledgment as JSON, and create a digest. You MUST encode the hash using base64 and prepend the value with the algorithm you use to generate the hash. Most Fediverse servers only support SHA-256.

$document_str = json_encode($document);
$sha256 = hash('sha256', $document_str, true);
$digest = 'SHA-256=' . base64_encode($sha256);
Enter fullscreen mode Exit fullscreen mode

You must use that digest to create a signed Signature header. Failing to include the digest will generate an invalid signature:

$dataToSign = "(request-target): {$method} {$path}\nhost: {$host}\ndate: {$date}\ndigest: {$digest}";
Enter fullscreen mode Exit fullscreen mode

The data to be signed should look something like this. No trailing new lines. No carriage returns.

(request-target): post /users/@bob/inbox
host: mastadon.social
date: Wed, 30 Aug 2023 12:01:01 GMT
digest: SHA-256=MmNmMjRkYmE1ZmIwYTMwZTI2ZTgzYjJhYzViOWUyOWUxYjE2MWU1YzFmYTc0MjVlNzMwNDMzNjI5MzhiOTgyNA==
Enter fullscreen mode Exit fullscreen mode

Before we can sign the message, you must generate a key pair. The public key will be distributed to the fediverse.

openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -outform PEM -pubout -out public.pem
Enter fullscreen mode Exit fullscreen mode

NOTE You must include the public key on your user's ActivityPub profile.

You must sign the headers using the same algorithm to create the digest. Pull in the OpenSSL library, depending on the programming language. With PHP, this is typically already installed as a PHP extension:

$privateKey = openssl_pkey_get_private(Storage::get('private.pem'));

$signature = null; // mutated by openssl
$enodedSignature = null;
if (openssl_sign($data, $signature, $privateKey, OPENSSL_ALGO_SHA256)) {
    $encodedSignature = base64_encode($signature);
} else {
    throw new Exception('Could not sign activityPub data');
}
Enter fullscreen mode Exit fullscreen mode

After signing, you will get a base64 encoded string longer than the digest generated earlier. Before including the signed data, we must build the Signature header so other servers can verify the headers. If you sign extra headers, modify the (request-target) section of the Signature header.

# signature header used when signing host, date, and digest.
$signatureHeader = sprintf( 'keyId="%s",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="%s"', $id, $base64 );
Enter fullscreen mode Exit fullscreen mode

The raw HTTP headers will look something like this when they are ready to send:

Host: mastodon.social
Date: Wed, 18 Aug 2022 22:00:00 GMT
Digest: SHA-256=6ccPqhz2TJmLL08E8AFny1/Wube60hOH3g6zzwv/Ttg=
Signature: keyId="https://example.com/activityPub/users/1#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="Cdih8iQQQPeDInLCN4H94Lm/hTKSNOjSnjleI8gZfndRsTwO1CqG41s+BRF2Oh51yETWEsR2ezceDgUgH+ME4jdrgUIMPm/Ox4B6c5QEASPPlFpcOfWcLryCCvEkQOVd3tbMITeY+uY6WITuZKsXREAidmDopJ2pZ3Wvk4rXuTYHZEW2vsreLYCrXDkTCm4ySL2THlOrzc0JQh/4EYRaQx+v3VqVBJvY9+qPLIm1Y9RuRoN35SMNN/IcTkxHVue+mDu6I8IIq/QVmg8kKDbwQ/ywQGzegYt+P2lKujdx0sR3gbXAHX2sTDHCKncVu/PYLJF5/LoxhVxNc3s3QEo5Bw=="
Content-Type: application/activity+json
Enter fullscreen mode Exit fullscreen mode

Sending the Acknowledgement

Finally, we can update our headers and post the message to the user's inbox:

$headers = [
    'Host' => $host,
    'Date' => $date,
    'Digest' => $digest,
    'Signature' => $signatureHeader,
    'Content-Type' => 'application/activity+json',
];

$activityResponse = Http::withHeaders($headers)
    ->withBody($document_str)
    ->contentType('application/activity+json')
    ->post($inboxUrl, $document);
Enter fullscreen mode Exit fullscreen mode

Wrapping Up

After the POST request is sent successfully, you should be able to accept followers. However, keeping track of your followers will be up to you!

Top comments (0)