I've been trying to integrate ActivityPub (basically Mastodon) into my static site with varying levels of success. The most difficult part is the inbox which is obviously not static - there are 3rd party services to help, like fed.brid.gy, but I had limited success with that unfortuately, so in the end I've rolled my own basic inbox. The next major hurdle was responding to activities (like follow requests for example) -- it is not at all obvious how to sign the request so Mastodon will accept the response. Finally got it working (with the help of a lot of searching and, funnily enough, guidance from ChatGPT - which wasn't perfect, but definitely helped).

The most important part (and the bit that I really struggled to find examples for) is the signature string, which needs to be formatted like this:

signature_string = f'''(request-target): post {url}
host: {headers['Host']}
date: {now.strftime('%a, %d %b %Y %H:%M:%S GMT')}
digest: {headers['Digest']}'''

Where the digest is the base64-encoded SHA-256 digest of the encoded content:

hash_algorithm = hashlib.sha256()
hash_algorithm.update(json_data.encode())
body_hash = base64.b64encode(hash_algorithm.digest()).decode()
headers['Digest'] = f'SHA-256={body_hash}'

Here's the full example code (note you need a public key on your actor profile, with the matching private key pem file stored locally in .ssh/private.pem). The id of the Accept activity response is simply a pre-generated UUID (at some point, I'll put some more effort into storing the stuff locally in a sqlite db, I guess).

from datetime import datetime
import time
import hashlib
import json
import base64
import sys

import requests
from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256
from Crypto.PublicKey import RSA

now = datetime.utcnow()

base_url = 'https://mastodon.social' 
url = '/users/jasonrbriggs/inbox'

data = {
  "@context": "https://www.w3.org/ns/activitystreams",
  "type": "Accept",
  "actor": "https://jasonrbriggs.com/@me/actor",
  "object": {
    "type": "Follow",
    "actor": "https://mastodon.social/users/jasonrbriggs",
    "object": "https://jasonrbriggs.com/@me/actor"
  },
  "id": "https://jasonrbriggs.com/response/e958f9a9-4330-4df5-8ffa-4a4a1657c0db",
  "summary": "Accepted follow request",
  "published": "2023-04-14T22:12:00Z"
}
json_data = json.dumps(data)

key_id = 'https://jasonrbriggs.com/@me/actor#main-key'
timestamp = now.strftime('%Y-%m-%dT%H:%M:%SZ')

headers = {
    'Host': 'mastodon.social',
    'Date': now.strftime('%a, %d %b %Y %H:%M:%S GMT'),
    'Accept': 'application/json',
    'Content-Type': 'application/activity+json'
}

hash_algorithm = hashlib.sha256()
hash_algorithm.update(json_data.encode())
body_hash = base64.b64encode(hash_algorithm.digest()).decode()
headers['Digest'] = f'SHA-256={body_hash}'

private_key_data = open('.ssh/private.pem', 'rb').read()
private_key = RSA.import_key(private_key_data)

signature_template = 'keyId="{0}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="{1}"'
signature_headers = 'host date digest'
signature_string = f'''(request-target): post {url}
host: {headers['Host']}
date: {now.strftime('%a, %d %b %Y %H:%M:%S GMT')}
digest: {headers['Digest']}'''

signature = pkcs1_15.new(private_key).sign(SHA256.new(signature_string.encode()))
signature_b64 = base64.b64encode(signature).decode()
http_signature = signature_template.format(key_id, signature_b64)
headers['Signature'] = http_signature

response = requests.post(base_url + url, data=json_data, headers=headers)
print(response.status_code, response.content)

(Note: this is an Accept response to a Follow request from my mastodon.social account to my static site - searchable via mastodon.social using this link)