Python Mastodon Server POST with HTTP Signature

This was an important read for me in starting work on an ActivityPub server:

The doc could use an update, critically one must now include the “digest” header (computed from a hash of the body json) in the HTTP signature.

I don’t know ruby, I was able to do some debugging by cramming logging statements into my Mastodon server, and the error messages returned from Mastodon are pretty good, make sure you look in response.text.

Here is a simple, verbose python script that POSTs successfully. I dispensed with the available HTTP Signature libs, they are appreciated but for me obscured the logic, it’s picky but straightforward.

I’d love to save the next python programmer some time.

import requests
import time
import email.utils
import base64
import json

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

date = email.utils.formatdate(usegmt=True)

# grab keys from PEM files
#
private_key = RSA.import_key(open("/home/carty/keys/private.pem").read())
public_key  = RSA.import_key(open("/home/carty/keys/public.pem").read())

# build the note document, the body of the POST request
#
note =      {"@context":"https://www.w3.org/ns/activitystreams"}
note.update({"id": "https://farmer.roundpond.net/note"})
note.update({"type": "Create"})
note.update({"actor": "https://farmer.roundpond.net/actor"})

note_obj =      {"id": "https://farmer.roundpond.net/note"}
note_obj.update({"type": "Note"})
note_obj.update({"published": date})
note_obj.update({"attributedTo": "https://farmer.roundpond.net/actor"})
note_obj.update({"inReplyTo": "https://mastodon.roundpond.net/@CartyBoston/109389086134482031"})
note_obj.update({"content": "<p>Carty's blood pressure is 138/88 y'all"})
note_obj.update({"to": "https://www.w3.org/ns/activitystreams#Public"})

# prepare the note body digest
# 
note.update({"object": note_obj})
note_j = json.dumps(note)
note_hash = SHA256.new(note_j.encode("utf-8"))
note_hash_base64 = base64.b64encode(note_hash.digest()).decode()

# build signature string
#
to_be_signed_str = "(request-target): post /inbox\n"\
                 + "host: mastodon.roundpond.net\n"\
                 + "date: " + date + "\n"\
                 + "digest: SHA-256=" + note_hash_base64

# hash sig string and encrypt using private key
#
to_be_signed_str_bytes = bytes(to_be_signed_str, "utf-8")
to_be_signed_str_hash = SHA256.new(bytes(to_be_signed_str_bytes))
sig = pkcs1_15.new(private_key).sign(to_be_signed_str_hash)

# grab the public key from the actor file and verify that it decrypts the sig hash
#
f_response = requests.get("https://farmer.roundpond.net/actor")
f_actor = json.loads(f_response.text)
f_key_str = f_actor["publicKey"]["publicKeyPem"]
f_key = RSA.import_key(f_key_str)


try:
    pkcs1_15.new(public_key).verify(to_be_signed_str_hash, sig)
    print ("sig is valid with public key from PEM file")
except (ValueError, TypeError):
   print ("sig invalid with public key from PEM file")

try:
    pkcs1_15.new(f_key).verify(to_be_signed_str_hash, sig)
    print ("sig is valid with key from actor file")
except (ValueError, TypeError):
   print ("sig is invalid with key from actor file")

# prepare POST request headers
#
sig_base64 = base64.b64encode(sig).decode()
signature_header = 'keyId="https://farmer.roundpond.net/actor#main-key", algorithm="rsa-sha256", headers="(request-target) host date digest", signatu
re="' + sig_base64 + '"'
request_headers = {"host" : "mastodon.roundpond.net",
                   "date" : date,
                   "digest" :  'SHA-256=' + note_hash_base64,
                   "content-type": "application/activity+json",
                   "signature" : signature_header}

# POST
#
response = requests.post("https://mastodon.roundpond.net/inbox", data = note_j, headers = request_headers)
exit()
3 Likes

I am writing an ActivityPub-based medical records server! Wanna help?

Thank you so much!

I wasn’t able to spend time debugging the new signing requirements during my last attempt at implementing one for a side project, and you have done me a big favour by sharing your findings.

1 Like

If you are ok with async code, my implementation for bovine is available here.

I’m not 100% happy with the implementation, e.g. I just noticed I set a content-type on a GET (which is pointless as a GET has no body). However, I think that the interface of the two methods should stay stable as I continue to resolve problems. Actually, no I should have both methods return the aiohttp.ClientResponse object.

As far as I can tell, the code works fine. mymath.rocks running this bovine code is able to interact with Mastodon instances with AUTHORIZED_FETCH enabled and post stuff. I also haven’t noticed any problems when interacting with non-Mastodon instances.

Note: Missing still is support for hs2019 for the signature validation.