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)