This library is the W3C's Web Cryptography API for Clojure and ClojureScript, with the bytes and encodings that cryptography needs. It runs on each platform's own cryptography: the Java Cryptography Architecture on the JVM and WebCrypto in browsers and Node.
This library brings together the bytes and cryptography of wary-fetch, podcast-clj and like-minded, which each had a copy of their own. Like them, it was developed with assistance from frontier LLMs.
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/webcrypto-clj {:mvn/version "0.1.3"}
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.
Bytes are a byte array on the JVM and a
Uint8Array
in JavaScript. The
bytes namespace turns them into
text and back:
(require '[dk.simongray.webcrypto.bytes :as bytes])
(bytes/hex (bytes/encode "Blåbær"))
;; => "426cc3a562c3a672"
(bytes/base64url (bytes/of [0 1 2 250 251 252 253 254 255]))
;; => "AAEC-vv8_f7_"
Each encoding has a strict decoder, e.g. bytes/from-hex, which returns
nil for text that isn't valid. Base64 needs its padding,
base64url doesn't, and
neither takes whitespace. Compare secrets with bytes/same? and
bytes/same-text?, which take the same time wherever the bytes differ, so
a timing attack learns
nothing.
The hash namespace has SHA-1,
SHA-2, HMAC and HKDF, which return their results at once on every
platform:
(require '[dk.simongray.webcrypto.hash :as hash])
(bytes/hex (hash/digest :sha-256 (bytes/encode "abc")))
;; => "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
(bytes/hex (hash/hmac :sha-256 (bytes/encode "key")
(bytes/encode "The quick brown fox jumps over the lazy dog")))
;; => "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8"
Derive keys with hash/hkdf, or call its two steps hash/hkdf-extract
and hash/hkdf-expand yourself. In JavaScript the hashes come from the Closure
Library, since WebCrypto only returns promises. For a large file in a
browser, WebCrypto's own webcrypto/digest is faster.
The root namespace has random bytes and the methods of SubtleCrypto. Each returns its result on the JVM (and a promise of it in ClojureScript). This seals a text with AES-GCM under a key that PBKDF2 derives from a passphrase:
(require '[dk.simongray.webcrypto :as webcrypto])
(def password
(webcrypto/import-key :raw (bytes/encode "correct horse battery staple")
:pbkdf2 false [:derive-key]))
(def pbkdf2
{:name :pbkdf2 :hash :sha-256 :salt (webcrypto/random-bytes 16) :iterations 600000})
(def aes-key
(webcrypto/derive-key pbkdf2 password {:name :aes-gcm :length 256}
false [:encrypt :decrypt]))
(def aes-gcm
{:name :aes-gcm :iv (webcrypto/random-bytes 12)})
(def sealed
(webcrypto/encrypt aes-gcm aes-key (bytes/encode "a secret")))
(bytes/decode (webcrypto/decrypt aes-gcm aes-key sealed))
;; => "a secret"
To run the same code on both platforms, chain the calls with
webcrypto/then, which takes a value or a promise:
(-> (webcrypto/import-key :raw (bytes/encode "correct horse battery staple")
:pbkdf2 false [:derive-key])
(webcrypto/then #(webcrypto/derive-key pbkdf2 % {:name :aes-gcm :length 256}
false [:encrypt :decrypt])))
An algorithm is a keyword, or a map of its :name and its parameters
named as in WebCrypto but in kebab case (e.g. :additional-data). Usages
and formats are keywords too, and a JWK is a map. To see what a key is,
use webcrypto/key-info:
(webcrypto/key-info aes-key)
;; => {:type :secret :extractable? false
;; :algorithm {:name :aes-gcm :length 256} :usages #{:encrypt :decrypt}}
| Methods | Algorithms |
|---|---|
digest | :sha-1, :sha-256, :sha-384 and :sha-512 |
sign and verify | :hmac, :ecdsa, :rsassa-pkcs1-v1_5, :rsa-pss and :ed25519 |
encrypt and decrypt | :aes-gcm |
derive-bits and derive-key | :hkdf, :pbkdf2 and :ecdh |
generate-key | :hmac, :aes-gcm, and the key pairs of :ecdsa, :ecdh, :rsassa-pkcs1-v1_5, :rsa-pss and :ed25519 |
import-key and export-key | secret keys as :raw or :jwk, key pairs as :spki, :pkcs8 or :jwk (and EC and Ed25519 public keys as :raw), and an import of :hkdf or :pbkdf2 as :raw |
A key pair is a map of its :public-key and its :private-key. This signs
and verifies with ECDSA:
(def key-pair
(webcrypto/generate-key {:name :ecdsa :named-curve :p-256} false [:sign :verify]))
(def ecdsa
{:name :ecdsa :hash :sha-256})
(def signature
(webcrypto/sign ecdsa (:private-key key-pair) (bytes/encode "a message")))
(webcrypto/verify ecdsa (:public-key key-pair) signature (bytes/encode "a message"))
;; => true
ECDSA
and ECDH
take the curves :p-256, :p-384 and :p-521. An ECDSA signature is r
and s side by side, as in JSON Web Tokens
and HTTP Message Signatures.
With ECDH, derive-bits and derive-key take the other party's public
key as :public.
The pem namespace writes keys as
PEM and reads them back for import-key. That includes keys in OpenSSL's
traditional formats (e.g. RSA PRIVATE KEY), which WebCrypto can't import on
its own:
(require '[dk.simongray.webcrypto.pem :as pem])
(pem/encode "PUBLIC KEY" (webcrypto/export-key :spki (:public-key key-pair)))
;; => "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE…"
(let [{:keys [format data]} (pem/key-data (slurp "private-key.pem"))]
(webcrypto/import-key format data {:name :rsa-pss :hash :sha-512} false [:sign]))
The key data also says what kind of key it holds, e.g. {:kind :ec :named-curve :p-256}, for a key whose algorithm you don't know yet. A key
that OpenSSL marks as RSASSA-PSS is {:kind :rsa-pss}, for RSA-PSS alone,
though it imports like any other RSA key.
A call that fails throws an ex-info (or in ClojureScript rejects with one)
whose :type names WebCrypto's exception, e.g. for a ciphertext that
someone changed:
(try
(webcrypto/decrypt aes-gcm aes-key (bytes/of (range 32)))
(catch Exception e
(:type (ex-data e))))
;; => :dk.simongray.webcrypto/operation-error
The docstring of the root namespace lists the types. The library runs the
spec's checks itself before it calls the platform, so a mistake throws the
same error everywhere. Where the platforms disagree, it refuses what any of
them refuses with :dk.simongray.webcrypto/not-supported-error:
Where only some platforms check a key, the library checks it on all of them, e.g. that the numbers of an RSA private key agree. It also refuses the weak Ed25519 public keys (those of small order) that verify forged signatures on every platform. A key that fails a check throws data-error.
wrap-key and unwrap-key with
AES-KW, AES-CTR, AES-CBC, RSA-OAEP and randomUUID.:type..cljc and has no
dependencies.dev/fetch.sh # once, for the vectors of Wycheproof and NIST
clojure -X:test # the tests on the JVM
npm install # once, for the Node tests
clojure -M:cljs compile test # the tests in Node
clojure -M:cljs watch browser-test # the tests in a browser, at http://localhost:8021
The fetch script downloads the test vectors of Wycheproof at a pinned
commit and NIST's vectors of GCM into dev-resources/ (which Git ignores). NIST
doesn't version its file, so the script checks its SHA-256. Without these
files the tests check only the vectors of the RFCs.
The public namespaces are the root, bytes, hash and pem. The
internal ones are under impl:
impl.algorithms, the algorithms and the normalization of their
parametersimpl.keys and impl.jwk, keys and their JWKsimpl.der, the DER
of SPKI, PKCS#8 and the keys inside themimpl.hmac, impl.aes-gcm, impl.kdf, impl.ec, impl.rsa and
impl.ed25519, the operations of each algorithm on each platformimpl.subtle, the calls of WebCrypto in JavaScriptimpl.errors, impl.async and impl.random, the errors, the promises
and the random bytesThe webcrypto-clj project is licensed under the MIT licence.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |