DEV Community

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

Posted on • Edited 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.

First, you must learn to acknowledge AP requests.

Resources

The Fediverse is still in its early stages. Automated tests will help find minor defects. For example, little things like date formats, JSON encoding, and base64 can all invalidate signed requests!

Understanding the request flow (aka the handshake)

ActivityPub, at its core, specifies that each user must have an inbox and an outbox. All read/write requests are handled using GET/POST methods respectively. The request flow is almost identical to email.

ActivityPub inbox/outbox flow

Difference between Inbox and Outbox

The outbox supports read and write operations. Reading the outbox gives remote servers a chance to backfill. Any action made on a remote server is queued by writing to the outbox via a RESTful API. A dequeued action is then dispatched to other inboxes to be processed. Inboxes tend to be for private use only.

Acknowledging Requests FAQ

To process actions, you must establish endpoints for the 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 not respond to requests by default. Any acknowledgment (even a reject) 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 (a string) 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)