GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — GitHub - simongray/httpsig-clj: HTTP Message Signatures for Clojure(Script).

BranchesTags
Open more actions menu

Latest commit

 

History

24 Commits
24 Commits

Folders and files

NameName
Last commit message
Last commit date
.clj-kondo
.clj-kondo
 
 
.github/workflows
.github/workflows
 
 
dev
dev
 
 
doc
doc
 
 
src/dk/simongray
src/dk/simongray
 
 
test
test
 
 
.gitignore
.gitignore
 
 
LICENSE
LICENSE
 
 
README.md
README.md
 
 
build.clj
build.clj
 
 
deps.edn
deps.edn
 
 
package-lock.json
package-lock.json
 
 
package.json
package.json
 
 
shadow-cljs.edn
shadow-cljs.edn
 
 

Repository files navigation

httpsig-clj

Clojars Project

This library signs and verifies HTTP requests and responses in Clojure and ClojureScript with HTTP Message Signatures (RFC 9421). It also implements Digest Fields and structured fields, which those signatures build on, as well as the older draft-cavage signatures that most of the fediverse still sends.

  • The whole standard. It has every component and parameter except those of trailers, every algorithm in the registry, and several signatures in one message. It verifies the RFC's own examples, and reproduces those signed with HMAC, Ed25519 and RSA byte for byte.
  • The fediverse as it is. It signs and verifies draft-cavage the way Mastodon and the servers that talk to it do. Its sender can fall back to RFC 9421 when a server refuses the draft, as Mastodon does.
  • Safe by default. The algorithm comes from the key, not the signature. A signature has to be at most an hour old, and a request's has to cover its method, its URL and a digest of its body.
  • Errors as data. Verifying returns a map with the reason when a signature fails.
  • The same results everywhere. One codebase runs on the JVM, in Node and in browsers, using webcrypto-clj for the cryptography. Its structured fields pass the HTTP working group's test suite on every platform.

This library was proposed for podcast-clj, which reads comment threads from servers that answer only signed requests, and for federating a blog without a bridge. Like podcast-clj, it was developed with assistance from frontier LLMs.

Getting started

The library needs Clojure 1.11+ and Java 11+ (15+ for Ed25519). In ClojureScript it needs Node 22+ or a browser with WebCrypto. Add the latest release from Clojars to the :deps of your deps.edn:

dk.simongray/httpsig-clj {:mvn/version "0.1.1"}

For changes that aren't released yet, use the SHA of the latest commit on master instead. In shadow-cljs a Git dependency only works through deps.edn, so set :deps true in your shadow-cljs.edn too.

Signing

A message is a request or response map like those of wary-fetch. A key is a map with a :keyid, an :alg and the key material. Make a key with httpsig/generate-key, and publish its :public-pem, e.g. in an actor document:

(require '[dk.simongray.httpsig :as httpsig])

(def actor-key
  (httpsig/generate-key "rsa-v1_5-sha256"
                        "https://example.com/actor#main-key"))

(def signed
  (httpsig/sign {:method  :post
                 :url     "https://social.example/inbox"
                 :headers {"Content-Type" "application/activity+json"}
                 :body    "{\"type\": \"Follow\"}"}
                actor-key))

(:headers signed)
;; => {"Content-Type"    "application/activity+json"
;;     "Content-Digest"  "sha-256=:fVZGtwrwBZt50q8S3vzvkc1eYYz3eybgE…:"
;;     "Signature-Input" "sig1=(\"@method\" \"@target-uri\" \"content-digest\");created=…"
;;     "Signature"       "sig1=:fN+cY1uVYoT0RXJKjzUT80zgGPBPsf3/XeR5Z…:"}

The key material is one of these:

  • :pem, a key in PEM: SPKI or PKCS#8, or the older PKCS#1 and SEC1
  • :jwk, a JWK as a map
  • :secret, the bytes or the text of a shared secret for HMAC
  • :key, a webcrypto-clj key, e.g. a CryptoKey that a page keeps unextractable

You can hide a PEM or a secret with wary-fetch's secret/hide, so that it prints as #<secret>. Without an :alg, the algorithm follows from the kind of key, e.g. rsa-v1_5-sha256 for RSA as in Mastodon, and rsa-pss-sha512 for a key that OpenSSL marks as RSASSA-PSS.

By default the signature of a request covers its method and URL, and that of a response its status. When you pass the :request that a response answers, its signature covers the request's method and URL too, so that nobody can pass it off as the answer to another request. Both cover a digest of the body when there is one. Give :components to cover more, e.g. ["@method" "@target-uri" "content-type" "content-digest"]. The docstring of httpsig/sign has the other options.

Verifying

Verify a message with a function that takes the parameters of a signature and returns its key, or nil for a key that you don't know or trust. It can also return a future or a promise, e.g. of a key that it fetches:

(httpsig/verify signed (fn [{:keys [keyid]}] (key-of keyid)))
;; => {:label      "sig1"
;;     :keyid      "https://example.com/actor#main-key"
;;     :alg        "rsa-v1_5-sha256"
;;     :key        {…}
;;     :components ["@method" "@target-uri" "content-digest"]
;;     :created    #inst "2026-10-14T06:45:08.000-00:00"}

When a signature fails, the map has an :error instead, e.g. for a body that someone changed:

(httpsig/verify (assoc signed :body "{}") (constantly actor-key))
;; => {:label "sig1"
;;     :keyid "https://example.com/actor#main-key"
;;     :error :dk.simongray.httpsig/digest-mismatch}

The docstring of httpsig/verify lists the errors and the options, e.g. which components a signature has to cover. The defaults follow sections 3.2 and 7 of the RFC. Replay protection needs a store, so check each result's :nonce against your own.

The fediverse

Every fediverse server accepts draft-cavage signatures, but only some accept those of RFC 9421. The cavage namespace signs and verifies the draft as they use it, with the same key maps and errors:

(require '[dk.simongray.httpsig.cavage :as cavage])

(get-in (cavage/sign {:url "https://social.example/users/ann"} actor-key)
        [:headers "Signature"])
;; => "keyId=\"https://example.com/actor#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date\",signature=\"wMt3zh8hzdBoBaoXh67w…\""

To send requests that a server in authorized-fetch mode answers, wrap wary-fetch's send! in a sender. It signs each request, and signs each hop of a redirect again for that hop's URL. It signs a hop to another server only when it's a GET or a HEAD, so that a server can't redirect a signed POST wherever it likes. When the server answers 400 or 401, the sender signs the request with the next signer and sends it again:

(require '[dk.simongray.wary-fetch :as http])

(def send-signed!
  (httpsig/sender http/send! actor-key {:signers [cavage/sign httpsig/sign]}))

(send-signed! {:url     "https://social.example/users/ann/statuses/1"
               :headers {"Accept" "application/activity+json"}})

This order works without remembering which server accepts which signature. Trying RFC 9421 first would fail silently with Misskey and Sharkey, which answer a bad signature with a stripped-down actor and a 200. A server checks a signature by fetching the actor of its keyid, so publish that actor where anyone can read it without a signature. Mastodon and GoToSocial also look up an actor they don't know with WebFinger, so serve a WebFinger response whose self link leads to the actor.

Ring

The ring namespace verifies Ring requests. Use ring/verified in a Pedestal interceptor, or the middleware ring/wrap-verify, which answers 401 to a signature that fails:

(require '[dk.simongray.httpsig.ring :as ring])

(def inbox
  (ring/wrap-verify inbox-handler
                    actor-key-of
                    {:origin  "https://example.com"
                     :cavage? true}))

The handler gets the result under :dk.simongray.httpsig/signature. It can read the body again. Behind a proxy that ends TLS, the URL that Ring sees isn't the one the client signed, so give the public :origin. Without it, the URL comes from the Host header, which may hold only a host name and a port. With :cavage?, the middleware checks a request without a Signature-Input as draft-cavage.

Digest Fields

The digest namespace computes a Content-Digest or a Repr-Digest with sha-256 or sha-512, the algorithms that RFC 9530 doesn't deprecate, and checks a Content-Digest:

(require '[dk.simongray.httpsig.digest :as digest])

(digest/field-value "{\"hello\": \"world\"}")
;; => "sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:"

Use digest/verify to check a message's Content-Digest against its body, and digest/wanted for the algorithms that a Want-Content-Digest asks for.

Structured fields

The sfv namespace parses and serializes the structured fields of RFC 9651. A parsed field is plain data, with each item a pair of its value and its parameters:

(require '[dk.simongray.httpsig.sfv :as sfv])

(sfv/parse :dictionary "sig1=(\"@method\" \"@path\");created=1618884473")
;; => [["sig1" [[["@method" []] ["@path" []]] [["created" 1618884473]]]]]

A token is a symbol and a byte sequence is bytes. Make a decimal with sfv/decimal, since JavaScript can't tell 1.0 from 1, and a display string with sfv/display-string, to tell it apart from a string.

In ClojureScript

WebCrypto's sign and verify return promises. So in ClojureScript, sign, verify and the functions that call them return promises, while on the JVM they return values. To write code for both at once, chain them with wary-fetch's async/then. Everything that needs no key is synchronous everywhere, e.g. digests, structured fields and signature bases.

A page served over plain http has no WebCrypto. A browser's fetch also forbids a Date field, so draft-cavage signing works only on the JVM and in Node.

Principles

  • Standards first. The code follows RFC 9421, RFC 9530 and RFC 9651, and cites their sections. Where the fediverse departs from the draft, a comment says which server does what.
  • Plain data. Messages are maps that any HTTP client can send, keys are maps, and the results of a verification are maps.
  • Strict by default. The defaults accept only what RFC 9421 and the fediverse would accept. A stranger's fields take time in proportion to their length, never more.
  • One codebase. The library is written in .cljc and passes the same tests on the JVM, in Node and in a browser.

Development

dev/fetch.sh                                   # once, for the structured-field tests
clojure -X:test                                # the tests on the JVM
npm install                                    # once, for the tests in JavaScript
clojure -M:cljs compile test                   # the tests in Node
clojure -M:cljs compile browser-test           # the tests for a browser,
node test/browser-run.mjs target/browser-test  # run in a headless Chrome

The fetch script downloads the HTTP working group's structured-field tests at a pinned commit into dev-resources/, which Git ignores. The examples of RFC 9421 and of draft-cavage are in the tests. Two other RFC 9421 libraries check this one with random requests, Visma's on the JVM and dhensby's in Node: each verifies what the other signs.

The public namespaces are the root, cavage, ring, digest and sfv. The internal ones are under impl:

  • impl.components, the components and the signature base
  • impl.keys, the keys and the algorithms in webcrypto-clj's terms
  • impl.sfv, the parsing and serializing of structured fields
  • impl.message, the fields and the body of a message
  • impl.date, the time now, seconds since the epoch, and IMF-fixdate
  • impl.async, values on the JVM and promises in ClojureScript

License

The httpsig-clj project is licensed under the MIT licence.

About

HTTP Message Signatures for Clojure(Script).

Resources

Readme
MIT license
Activity

Stars

0 stars

Watchers

0 watching

Forks

0 forks
Report repository

Releases

Packages

Contributors

Languages

You are visitor 01402503 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.