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()