Acknowledging & Signing Fediverse Activity Requests
Tags: php, programming, webdev, fediverse
Published: 2023-08-30 23:03:16

Read this post first: [How to Put Your Blog on the Fediverse](https://dev.to/wadecodez/how-to-join-the-fediverse-wout-mastodon-1agh)

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!

* [ActivityPub Vocabulary (Data Structures)](https://www.w3.org/TR/activitystreams-vocabulary/#activity-types)
* [ActivityPub Spec](https://www.w3.org/TR/activitypub/)
* [HTTP Signatures Draft RFC](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12#appendix-C.2)
* [Mastodon's Signature Verification](https://github.com/mastodon/mastodon/blob/e263db276fdc05dab743400977606efe98f1e949/app/controllers/concerns/signature_verification.rb)
* [RFC 7231 Date Format](https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.1.1)
* [Base64 RFC](https://datatracker.ietf.org/doc/html/rfc4648#section-3.1)

## 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/d9z84lu47qq07kt1tiu7.png)

### 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:

```json
{
  "@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"
}
```

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:

```php
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'),
        ];
    }
}
```

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](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12#appendix-C.2). I will be using these variables throughout the signing process:

```php
$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();
```

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.

```php
$document_str = json_encode($document);
$sha256 = hash('sha256', $document_str, true);
$digest = 'SHA-256=' . base64_encode($sha256);
```

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

```php
$dataToSign = "(request-target): {$method} {$path}\nhost: {$host}\ndate: {$date}\ndigest: {$digest}";
```

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==
```

Before we can sign the message, you must generate a key pair. The public key will be distributed to the fediverse.
```bash
openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -outform PEM -pubout -out public.pem
```

> **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:

```php
$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');
}
```

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.

```php
# 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 );
```

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
```

### Sending the Acknowledgement

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

```php
$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);
```

### 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!