~technomancy/pengbot

d72a3dd1620a754fff658143df53b693cbeb989e — Phil Hagelberg 5 months ago 6913eed fedi
Fix signatures, but it still doesn't work who knows why.
3 files changed, 106 insertions(+), 9 deletions(-)

A fedi/README.md
M fedi/inbox.fnl
A fedi/verify.rb
A fedi/README.md => fedi/README.md +32 -0
@@ 0,0 1,32 @@
# Fedi Pengbot

A CGI Adventure.

* `inbox.fnl`: the CGI script to reply to Create messages
* `pengbot`: the actor json
* `follow`: dummy follower/followed list
* `.well-known/webfinger`: where to find the actor json

As it currently stands, this code accepts "Create" messages from
ActivityPub servers and constructs a reply (currently with a canned
hard-coded message in it). It signs the outgoing message according to
the spec, but for unknown reasons, Mastodon rejects it:

```
{
    "error": "Verification failed for pengbot@pengbot.fennel-lang.org https://pengbot.fennel-lang.org/pengbot using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)",
    "signed_string": "(request-target): post /users/technomancy/inbox\nhost: icosahedron.website\ndate: Mon, 22 Apr 2024 20:32:04 GMT\ndigest: SHA-256=D41L3xKHq9anbgX7uvfhGBnJA6ebaHCuhLEZ3F53SFg=\ncontent-type: application/activity+json",
    "signature": "sG8sxd49hpFawvmoGCcUw6ENIKlB/kS9UuAORWT8gRrnBo4PS38dnMgAQRj9lFm3WPUFz+nLn8xsgI9Lb+T94nI+WoTki32SkDrzm7arjD9z+qwSX3iCo2N5I+MUfPZ0J9MuRbICLdi13b/9495iHA2AQyhQ+q8ufLY1Nt7kpJla7WicHP6TSkY0nUbAi5T96kHROyAEa2vj421IomfTltERQmCig7WphCuXmp7wY5H+YLVgvmcYl8YRxvEYf1a5Hq0TVvg9CGTDeVIZaJZeqblIZJSgPb9Y24+uhDRnxSBLV0ppOF1HH0raCSp8a0zvY9zdK45HoLYipD5XzIIJLw=="
}
```

And GotoSocial also rejects it, but only with a 401 response code and
no error message. However, according to `openssl pkeyutl -verify` the
signature is correct.

There is some sample code for validating signatures on the mastodon
blog: https://blog.joinmastodon.org/2018/07/how-to-make-friends-and-verify-requests/

Running it on the headers from this script causes it to fail, because
it produces the wrong sha256 checksum for the signed fields in the
headers for unknown reasons.

M fedi/inbox.fnl => fedi/inbox.fnl +33 -9
@@ 12,36 12,59 @@
                                 (: "-H %q" :format (.. k ": " v))) " ")
                 body uri))

(fn dbg [uri headers body]
  (assert (os.execute (string.format "ruby verify.rb %q" (json.encode headers))))
  (print (curl-command uri headers body)))

(fn post [uri headers body]
  (if (= "--debug" (. arg 1))
      (print (curl-command uri headers body))
      (dbg uri headers body)
      (let [curl (io.popen (curl-command uri headers body))]
        (curl:write body)
        (assert (curl:close)))))

(local openssl-command "openssl pkeyutl -sign -inkey keys/private.key -in ")

(fn verify-signature [signature data-file]
  (let [sig-file (os.tmpname)
        cmd (.. "openssl pkeyutl -verify -pubin -inkey keys/public.key "
                "-sigfile " sig-file " -in "data-file)]
    (with-open [t (io.open sig-file :w)] (t:write signature))
    (assert (os.execute cmd))))

(fn sign [data]
  (let [tmp (os.tmpname)]
    (with-open [t (io.open tmp :w)] (t:write data))
    (with-open [ossl (io.popen (.. openssl-command tmp))]
      (ossl:read "*all"))))
      (doto (ossl:read "*all") (verify-signature tmp)))))

(fn to-sign [hostname path now digest]
  (: "(request-target): post %s\nhost: %s\ndate: %s\ndigest: %s\ncontent-type: %s"
     :format path hostname now digest "application/activity+json"))

(fn to-sign [hostname path now]
  (: "(request-target): post %s\nhost: %s\ndate: %s" :format path hostname now))
(fn hex->bytes [str]
  (pick-values 1 (str:gsub ".." (fn [cc] (string.char (tonumber cc 16))))))

(fn send [body hostname path]
  (let [signature (-> (to-sign hostname path now)
  (let [digest (.. "SHA-256=" (-> (sha.sha256 body)
                                  (hex->bytes)
                                  (base64.encode)))
        signature (-> (to-sign hostname path now digest)
                      (sha.sha256)
                      (sign)
                      (base64.encode))
        signature-header (.. "keyId=\"" actor-id "#key\","
                             "headers=\"(request-target) host date\","
                             "signature=\"" signature "\"")]
        signature-header (: "keyId=%q,algorithm=%q,headers=%q,signature=%q"
                            :format
                            (.. actor-id "#key")
                            "rsa-sha256"
                            "(request-target) host date digest content-type"
                            signature)]
    (print (sha.sha256 (to-sign hostname path now digest)))
    (post (.. "https://" hostname path)
          {"Content-Type" "application/activity+json"
          {"Content-type" "application/activity+json"
           "Host" hostname
           "Date" now
           "Digest" digest
           "Signature" signature-header}
          body)))



@@ 61,6 84,7 @@
             :type "Create"
             :object reply}
        (hostname path) (actor:match "https://([^/]+)(/.*)")]
    ;; should path just be /inbox for mastodon?
    (send (json.encode msg) hostname (.. path "/inbox"))
    (print "Status: 202 Accepted\r\n\r\nOK.")))


A fedi/verify.rb => fedi/verify.rb +41 -0
@@ 0,0 1,41 @@
require 'json'
require 'http'
require 'digest'

# based on the example from the mastodon official blog
# https://blog.joinmastodon.org/2018/07/how-to-make-friends-and-verify-requests/
# modified to verify from command-line args instead of an actual HTTP request

request_headers = JSON.parse(ARGV[0])

signature_header = request_headers['Signature'].split(',').map do |pair|
  pair.gsub(/=+"$/, '"').split('=').map do |value|
    value.gsub(/\A"/, '').gsub(/"\z/, '') # "foo" -> foo
  end
end.to_h

key_id    = signature_header['keyId']
headers   = signature_header['headers']
signature = Base64.decode64(signature_header['signature'])

actor = JSON.parse(HTTP.get(key_id).to_s)
key   = OpenSSL::PKey::RSA.new(actor['publicKey']['publicKeyPem'])

comparison_string = headers.split(' ').map do |signed_header_name|
  if signed_header_name == '(request-target)'
    '(request-target): post /users/technomancy/inbox'
  else
    "#{signed_header_name}: #{request_headers[signed_header_name.capitalize]}"
  end
end

# despite the fact that the comparison_string has the exact same checksum as
# the actual string, and the fact that openssl successfully verifies the
# signature, this fails every time with no indication as to why.
puts Digest::SHA256.hexdigest(comparison_string.join("\n"))
if key.verify(OpenSSL::Digest::SHA256.new, signature, comparison_string.join("\n"))
  puts "OK"
else
  puts "Verification failed"
  exit 1
end