Initial commit; nothing works yet

This commit is contained in:
Simon Brooke 2022-12-19 13:23:38 +00:00
commit a599d133f4
20 changed files with 954 additions and 0 deletions

View file

@ -0,0 +1,3 @@
# NOTE
Files in this directory are copied from [Jahfer's clj-activitypub library](https://github.com/jahfer/clj-activitypub). If and when Jahfer issues a release of that library, this directory will be deleted and a dependency on that library will be added to the project.

View file

@ -0,0 +1,147 @@
(ns clj-activitypub.core
(:require [clj-activitypub.internal.crypto :as crypto]
[clj-activitypub.internal.thread-cache :as thread-cache]
[clj-activitypub.internal.http-util :as http]
[clj-http.client :as client]
[clojure.string :as str]))
(defn config
"Creates hash of computed data relevant for most ActivityPub utilities."
[{:keys [domain username username-route public-key private-key]
:or {username-route "/users/"
public-key nil
private-key nil}}]
(let [base-url (str "https://" domain)]
{:domain domain
:base-url base-url
:username username
:user-id (str base-url username-route username)
:public-key public-key
:private-key (when private-key
(crypto/private-key private-key))}))
(defn parse-account
"Given an ActivityPub handle (e.g. @jahfer@mastodon.social), produces
a map containing {:domain ... :username ...}."
[handle]
(let [[username domain] (filter #(not (str/blank? %))
(str/split handle #"@"))]
{:domain domain :username username}))
(def ^:private user-cache (thread-cache/make))
(defn fetch-user
"Fetches the customer account details located at user-id from a remote
server. Will return cached results if they exist in memory."
[user-id]
((:get-v user-cache)
user-id
#(:body
(client/get user-id {:as :json-string-keys
:throw-exceptions false
:ignore-unknown-host? true
:headers {"Accept" "application/activity+json"}}))))
(defn actor
"Accepts a config, and returns a map in the form expected by the ActivityPub
spec. See https://www.w3.org/TR/activitypub/#actor-objects for reference."
[{:keys [user-id username public-key]}]
{"@context" ["https://www.w3.org/ns/activitystreams"
"https://w3id.org/security/v1"]
:id user-id
:type "Person"
:preferredUsername username
:inbox (str user-id "/inbox")
:outbox (str user-id "/outbox")
:publicKey {:id (str user-id "#main-key")
:owner user-id
:publicKeyPem (or public-key "")}})
(def signature-headers ["(request-target)" "host" "date" "digest"])
(defn- str-for-signature [headers]
(let [headers-xf (reduce-kv
(fn [m k v]
(assoc m (str/lower-case k) v)) {} headers)]
(->> signature-headers
(select-keys headers-xf)
(reduce-kv (fn [coll k v] (conj coll (str k ": " v))) [])
(interpose "\n")
(apply str))))
(defn gen-signature-header
"Generates a HTTP Signature string based on the provided map of headers."
[config headers]
(let [{:keys [user-id private-key]} config
string-to-sign (str-for-signature headers)
signature (crypto/base64-encode (crypto/sign string-to-sign private-key))
sig-header-keys {"keyId" user-id
"headers" (str/join " " signature-headers)
"signature" signature}]
(->> sig-header-keys
(reduce-kv (fn [m k v]
(conj m (str k "=" "\"" v "\""))) [])
(interpose ",")
(apply str))))
(defn auth-headers
"Given a config and request map of {:body ... :headers ...}, returns the
original set of headers with Signature and Digest attributes appended."
[config {:keys [body headers]}]
(let [digest (http/digest body)
h (-> headers
(assoc "Digest" digest)
(assoc "(request-target)" "post /inbox"))]
(assoc headers
"Signature" (gen-signature-header config h)
"Digest" digest)))
(defmulti obj
"Produces a map representing an ActivityPub object which can be serialized
directly to JSON in the form expected by the ActivityStreams 2.0 spec.
See https://www.w3.org/TR/activitystreams-vocabulary/ for reference."
(fn [_config object-data] (:type object-data)))
(defmethod obj :note
[{:keys [user-id]}
{:keys [id published inReplyTo content to]
:or {published (http/date)
inReplyTo ""
to "https://www.w3.org/ns/activitystreams#Public"}}]
{"id" (str user-id "/notes/" id)
"type" "Note"
"published" published
"attributedTo" user-id
"inReplyTo" inReplyTo
"content" content
"to" to})
(defmulti activity
"Produces a map representing an ActivityPub activity which can be serialized
directly to JSON in the form expected by the ActivityStreams 2.0 spec.
See https://www.w3.org/TR/activitystreams-vocabulary/ for reference."
(fn [_config activity-type _data] activity-type))
(defmethod activity :create [{:keys [user-id]} _ data]
{"@context" ["https://www.w3.org/ns/activitystreams"
"https://w3id.org/security/v1"]
"type" "Create"
"actor" user-id
"object" data})
(defmethod activity :delete [{:keys [user-id]} _ data]
{"@context" ["https://www.w3.org/ns/activitystreams"
"https://w3id.org/security/v1"]
"type" "Delete"
"actor" user-id
"object" data})
(defn with-config
"Returns curried forms of the #activity and #obj multimethods in the form
{:activity ... :obj ...}, with the initial parameter set to config."
[config]
(let [f (juxt
#(partial activity %)
#(partial obj %))
[activity-fn obj-fn] (f config)]
{:activity activity-fn
:obj obj-fn}))

View file

@ -0,0 +1,36 @@
(ns clj-activitypub.internal.crypto
(:require [clojure.java.io :as io])
(:import (java.util Base64)
(java.security MessageDigest SecureRandom Signature)))
(java.security.Security/addProvider
(org.bouncycastle.jce.provider.BouncyCastleProvider.))
(defn- keydata [reader]
(->> reader
(org.bouncycastle.openssl.PEMParser.)
(.readObject)))
(defn- pem-string->key-pair [string]
(let [kd (keydata (io/reader (.getBytes string)))]
(.getKeyPair (org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter.) kd)))
(defn private-key [private-pem-str]
(-> private-pem-str
(pem-string->key-pair)
(.getPrivate)))
(defn base64-encode [bytes]
(.encodeToString (Base64/getEncoder) bytes))
(defn sha256-base64 [data]
(let [digest (.digest (MessageDigest/getInstance "SHA-256") (.getBytes data))]
(base64-encode digest)))
(defn sign [data private-key]
(let [bytes (.getBytes data)
signer (doto (Signature/getInstance "SHA256withRSA")
(.initSign private-key (SecureRandom.))
(.update bytes))]
(.sign signer)))

View file

@ -0,0 +1,25 @@
(ns clj-activitypub.internal.http-util
(:require [clj-activitypub.internal.crypto :as crypto])
(:import (java.net URLEncoder)
(java.time OffsetDateTime ZoneOffset)
(java.time.format DateTimeFormatter)))
(defn encode-url-params [params]
(->> params
(reduce-kv
(fn [coll k v]
(conj coll
(str (URLEncoder/encode (name k)) "=" (URLEncoder/encode (str v)))))
[])
(interpose "&")
(apply str)))
(defn date []
(-> (OffsetDateTime/now (ZoneOffset/UTC))
(.format DateTimeFormatter/RFC_1123_DATE_TIME)))
(defn digest
"Accepts body from HTTP request and generates string
for use in HTTP `Digest` request header."
[body]
(str "sha-256=" (crypto/sha256-base64 body)))

View file

@ -0,0 +1,44 @@
(ns clj-activitypub.internal.thread-cache)
(defn- current-time
"Returns current time using UNIX epoch."
[]
(System/currentTimeMillis))
(defn- update-read-at [store k v]
(dosync
(commute store assoc k
(merge v {:read-at (current-time)}))))
(defn make
"Creates a thread-local cache."
([] (make false))
([cache-if-nil]
(let [store (ref {})]
(letfn [(cache-kv ([k v]
(dosync
(commute store assoc k
{:write-at (current-time)
:read-at (current-time)
:value v})
v)))
(get-v ([k]
(when-let [data (get @store k)]
(update-read-at store k data)
(:value data)))
([k compute-fn]
(let [storage @store]
(if (contains? storage k)
(get-v k)
(let [v (compute-fn)]
(when (or (not (nil? v)) cache-if-nil)
(cache-kv k v)
(get-v k)))))))
(lru ([]
(mapv
(fn [[k v]] [k (:value v)])
(sort-by #(-> % val :read-at) < @store))))]
{:cache-kv cache-kv
:get-v get-v
:cache-if-nil cache-if-nil
:lru lru}))))

View file

@ -0,0 +1,32 @@
(ns clj-activitypub.webfinger
(:require [clj-http.client :as client]
[clj-activitypub.internal.http-util :as http]
[clj-activitypub.internal.thread-cache :as thread-cache]))
(def remote-uri-path "/.well-known/webfinger")
(defn- resource-str [domain username]
(str "acct:" username "@" domain))
(defn resource-url
"Builds a URL pointing to the user's account on the remote server."
[domain username & [params]]
(let [resource (resource-str domain username)
query-str (http/encode-url-params (merge params {:resource resource}))]
(str "https://" domain remote-uri-path "?" query-str)))
(def ^:private user-id-cache
(thread-cache/make))
(defn fetch-user-id
"Follows the webfinger request to a remote domain, retrieving the ID of the requested
account. Typically returns a string in the form of a URL."
[domain username]
((:get-v user-id-cache)
(str domain "@" username) ;; cache key
(fn []
(let [response (some-> (resource-url domain username {:rel "self"})
(client/get {:as :json :throw-exceptions false :ignore-unknown-host? true}))]
(some->> response :body :links
(some #(when (= (:type %) "application/activity+json") %))
:href)))))

View file

@ -0,0 +1,63 @@
(ns dog-and-duck.quack.quack
"Validator for ActivityPub objects: if it walks like a duck, and it quacks like a duck..."
;;(:require [clojure.spec.alpha as s])
(:import [java.net URI URISyntaxException]))
(defn object?
"Return `true` iff `x` is recognisably an ActivityStreams object.
**NOTE THAT** The ActivityStreams spec
[says](https://www.w3.org/TR/activitystreams-core/#object):
> All properties are optional (including the id and type)
But we are *just not having that*, because otherwise we're flying blind.
We *shall* reject objects lacking at least `:type`. Missing `:id` keys are
tolerable because they represent transient objects, which we expect to
handle."
[x]
(and (map? x) (:type x) true))
(object? nil)
(object? {:type "test"})
(defn persistent-object?
"`true` iff `x` is a persistent object.
Transient objects in ActivityPub are not required to have an `id` key, but persistent
ones must have a key, and it must be an IRI (but normally a URI)."
[x]
(try
(and (object? x) (uri? (URI. (:id x))))
(catch URISyntaxException _ false)))
(persistent-object? {:type "test" :id "https://mastodon.scot/@barfilfarm"})
(defn actor?
"TODO!"
[x]
true)
(def verb?
"The set of types we will accept as verbs.
There's an [explicit set of allowed verbs]
(https://www.w3.org/TR/activitystreams-vocabulary/#activity-types)."
#{"Accept" "Add" "Announce" "Arrive" "Block" "Create" "Delete" "Dislike"
"Flag" "Follow" "Ignore" "Invite" "Join" "Leave" "Like" "Listen" "Move"
"Offer" "Question" "Reject" "Read" "Remove" "TentativeAccept"
"TentativeReject" "Travel" "Undo" "Update" "View"})
(defn activity?
"`true` iff `x` is an activity, else false.
see "
[x]
(try
(and (object? x)
(uri? (URI. ((keyword "@context") x)))
(string? (:summary x))
(actor? (:actor x))
(verb? (:type x))
(or (object? (:object x)) (uri? (URI. x))))
(catch URISyntaxException _ false)))

View file

@ -0,0 +1,6 @@
(ns dog-and-duck.scratch.core)
(defn foo
"I don't do a whole lot."
[x]
(println x "Hello, World!"))

View file

@ -0,0 +1,20 @@
(ns dog-and-duck.scratch.parser
(:require [clojure.walk :refer [keywordize-keys]]
[clojure.data.json :as json]
[dog-and-duck.quack.quack :as q]))
(defn clean
"Take this `json` input, and return a sequence of ActivityPub objects
represented by it."
[json]
(let [feed (json/read-str json)]
(filter
q/object?
(cond (map? feed) (list (keywordize-keys feed))
(coll? feed) (map keywordize-keys feed)))))
(map :type (map keywordize-keys (json/read-str (slurp "resources/feed.json"))))
(keys (first (map keywordize-keys (json/read-str (slurp "resources/feed.json")))))
(q/object? (first (map keywordize-keys (json/read-str (slurp "resources/feed.json")))))

View file

@ -0,0 +1,44 @@
(ns dog-and-duck.scratch.scratch
"Scratchpad where I try to understand how to do this stuff."
(:require [clj-activitypub.core :as activitypub]
[clj-activitypub.webfinger :as webfinger]
[clj-pgp.core :as pgp]
[clj-pgp.keyring :as keyring]
[clj-pgp.generate :as pgp-gen]
[clojure.walk :refer [keywordize-keys]]
[clojure.pprint :refer [pprint]]))
;;; Use any ActivityPub account handle you like - for example, your own
(def account-handle "@simon_brooke@mastodon.scot")
(def handle (activitypub/parse-account account-handle))
(webfinger/fetch-user-id "mastodon.scot" "simon_brooke")
(apply webfinger/fetch-user-id (map handle [:domain :username]))
;;; Retrieve the account details from its home server
;;; (`keywordize-keys` is not necessary here but produces a more idiomatic clojure
;;; data structure)
(def account
"Fetch my account to mess with"
(let [handle (activitypub/parse-account account-handle)]
(keywordize-keys
(activitypub/fetch-user
(apply webfinger/fetch-user-id (map handle [:domain :username]))))))
;;; examine what you got back!
(:outbox account)
(def rsa (pgp-gen/rsa-keypair-generator 2048))
(def kp (pgp-gen/generate-keypair rsa :rsa-general))
;; how we make a public/private key pair. But this key pair is not the one
;; known to mastodon.scot as my key pair, so that doesn't get us very far...
;; I think.
(let [rsa (pgp-gen/rsa-keypair-generator 2048)
kp (pgp-gen/generate-keypair rsa :rsa-general)
public (-> kp .getPublicKey .getEncoded)
private (-> kp .getPrivateKey .getPrivateKeyDataPacket .getEncoded)]
(println (str "Public key: " public))
(println (str "Private key: " private))
)