2 min read

Mastodon and http sig

Mastodon and http sig

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 unfortunately, 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)