Research date: 2026-09-23. Motivation: signet (../signet), which uses
canonical-edn and uuidv7, runs only on the JVM and on babashka, and its JCA
backend has gaps on bb. The question was whether libsodium could be one
engine under JVM Clojure, bb, nbb and the browser. A second question was
whether it would also give us a secure random generator for free.
Every claim below is marked. [verified] means it was run here. [source] means it comes from the cited documentation. [inference] is our own reasoning.
Go, as a prototype. One binding file covers JVM Clojure, bb and nbb. libsodium.js covers the browser. Every engine reproduces the RFC vectors and signet's current output byte for byte. signet's own test suite passes unchanged on a libsodium backend, on the JVM and, for the first time in full, on babashka. The main risks are that the tooling is very new and that libsodium becomes a native dependency.
| Runtime | Engine | How it is called | Result |
|---|---|---|---|
| babashka 1.13.223 | libsodium 1.0.22 (Homebrew) | built-in babashka.ffi | [verified] 12 tests / 38 assertions pass. The JCA cross-check passes 13/13. |
| JVM, JDK 25.0.3 | same | org.babashka/ffi 0.1.2 | [verified] Same core.cljc. 12/38 and 13/13 pass. |
| nbb 1.6.213, Node 26.9.0 | same | built-in babashka.ffi over node:ffi | [verified] 12/38 pass. |
| Node 26.9.0 | libsodium.js 0.8.4 sumo (WASM) | npm libsodium-wrappers-sumo | [verified] all vector checks pass |
| Headless Chromium | libsodium.js 0.8.4 sumo, browsers-sumo/sodium.js | Scittle 0.8.33, plain <script> tags | [verified] 12 checks pass |
| Headless Chromium | libsodium.js at master (unreleased, local clone), sumo build | same | [verified] Passes. The native HKDF export works too. |
| JVM, JDK 21.0.11 | — | org.babashka/ffi 0.1.2 | [verified] Fails: ClassNotFoundException: java.lang.classfile.ClassBuilder |
The operations covered: seed → Ed25519 keypair, Ed25519 sign and verify,
Ed25519 → X25519 key conversion, X25519 Diffie-Hellman and base-point
multiplication, SHA-256, HMAC-SHA-256 (any key length), ChaCha20-Poly1305
IETF encryption, HKDF-SHA-256 and randombytes_buf. Wrong-size inputs are
also tested. The known answers come from RFC 8032 §7.1 test 1, RFC 7748
§5.2 and §6.1, RFC 8439 §2.8.2, RFC 5869 A.1, RFC 4231 cases 1 and 6, and
FIPS 180-2 ("abc"). Each vector was checked against libsodium before it was
added to the file. For the RFC 8439 vector, the ciphertext prefix and the
tag both match the RFC.
test/nacljc/jca_crosscheck.clj feeds random inputs to libsodium and to
signet.impl.jvm. The results are byte-identical for the seed-derived
public key, the Ed25519 signature, the X25519 keys derived from Ed25519
keys, the DH secret, ChaCha20-Poly1305 and HKDF. This holds on bb and on
the JVM. Switching engines therefore needs no migration.
In particular, libsodium's crypto_sign_seed_keypair derives a public key
from a seed on bb. signet cannot do that today: its proxy [SecureRandom]
trick fails on bb 1.13.223 with No matching clause.
integration/signet-shim/signet/impl/jvm.clj is a drop-in replacement for
signet's JCA backend. It has the same namespace and the same 16 functions,
implemented on nacljc.core. When it comes first on the classpath, signet
uses libsodium without any change to signet's repo.
integration/nacljc/signet_suite.clj runs signet's own test namespaces and
checks a backend marker var. If the wrong backend loaded it exits 2; this
was checked by running the JCA configuration while expecting libsodium.
Run (bb test:signet-…) | Runtime | Backend | Result |
|---|---|---|---|
jca (oracle) | JVM 25.0.3 | signet's JCA | 102 tests / 436 assertions, 0 failures |
jvm | JVM 25.0.3 | libsodium | 102 / 436, 0 failures: identical to the oracle |
bb | bb 1.13.223 | libsodium | 93 / 415, 0 failures (all but the 9 secp256k1 tests) |
| (for comparison) | bb 1.13.223 | signet's JCA | 93 / 377, 16 errors |
All 16 JCA-on-bb errors have one cause:
No matching clause: ["java.security.SecureRandom" #{}]. That is signet's
proxy [SecureRandom] seed → key trick, which bb cannot run. It breaks key
construction from seeds, the Ed25519 → X25519 conversions, kid round
trips, the key store and verification of open chains. libsodium's
crypto_sign_seed_keypair removes the trick, so signet runs in full on bb
for the first time. signet's own bb smoke covers 9 tests.
The signet runs use signet's own pinned dependencies through
:local/root, so the backend is the only thing that changes. The results
held at the original pins (cedn 1.2.0, uuidv7 0.5.0, Bouncy Castle 1.78.1)
and still hold after the bump on signet's branch (cedn 1.5.2, uuidv7 0.7.1,
Bouncy Castle 1.86). test:signet-bb mirrors signet's bb pins.
libsodium's C functions read fixed-size inputs (32-byte keys and seeds,
64-byte secret keys and signatures, 12-byte nonces) without knowing the
size of the buffer. The first version of the binding passed arrays through
unchecked. Given a 32-byte array where the 64-byte secret key belongs, it
returned a "signature" without error; given a 16-byte X25519 public key, it
returned a "shared secret". Both were computed from memory past the end of
the allocation. nacljc.core now checks every fixed-size input and throws
::bad-length. ed25519-verify? returns false for a wrong-size signature
or key, because both are untrusted input. wrong-sizes-are-bad-length in
core_test.cljc covers this. Any future binding needs the same discipline;
see "Hardening before 0.1.0" below.
org.babashka/ffi 0.1.2. Its README says "Status: experimental".--enable-native-access=ALL-UNNAMED silences JEP 472's warning, which
announces that such calls will be blocked in a future release.
[verified] The warning appears without the flag.node:ffi. It prints an ExperimentalWarning [verified]. It does not
support structs by value or variadic functions [source].read-array :char returns a byte[] on the JVM and bb, and an
Int8Array on nbb. alength works on both.with-open. core.cljc uses a small with-scratch macro,
which wipes and closes the arena in a finally.clj-kondo.exports/babashka/ffi).
Import it with
clj-kondo --lint "$(clojure -Spath)" --dependencies --copy-configs --skip-lint.These surfaced when signet's CI first ran the libsodium backend on Linux:
nacljc.core now
refuses anything older than 1.0.19 at load, with a clear error.babashka.ffi/load-library fails with cannot load library, even for
/usr/local/lib/libsodium.so.26 by absolute path. DeLaGuardo/setup-clojure
installs that static build on Linux. The dynamically linked build
(babashka-<v>-linux-amd64.tar.gz) works. The JVM, being dynamically
linked itself, was never affected.clojure -P before clojure -T:build install:
tools.build's basis did not download org.babashka/ffi itself.dist/browsers*/sodium.js) load from a
plain <script> tag using the window.sodium = {onload: …} hook. They
are not in the npm packages, but jsdelivr serves them from the GitHub
tag, e.g.
https://cdn.jsdelivr.net/gh/jedisct1/libsodium.js@0.8.4/dist/browsers-sumo/sodium.js.
[verified] This works under Scittle 0.8.33.crypto_scalarmult throws
g._crypto_scalarmult_curve25519_bytes is not a function. The wrapper
calls a function that the standard core does not export: the symbol
occurs 0 times in dist/modules/libsodium.js and once in the sumo core.
Released 0.8.4 standard is fine. Candidate for an upstream issue.crypto_kdf_hkdf_*() namespace. It is implemented for the SHA-256 and
SHA-512 hash functions." The source is in
src/libsodium/crypto_kdf/hkdf/. core.cljc calls
crypto_kdf_hkdf_sha256_extract/_expand natively and reproduces
RFC 5869 A.1._crypto_kdf_hkdf_sha256_extract /
_expand in sumo 0.8.4 [source: research agent's inspection of the npm
package]. The second is hand-written JS wrappers, which turn raw exports
into Uint8Array calls; each function needs its own wrapper
definition, and 0.8.4 has none for HKDF [verified].c0f9f85 (2026-07-10, "implement JS wrapper definitions for
crypto_kdf_hkdf") added them, and 59 commits have landed since the 0.8.4
tag (2026-04-19). A browser build from master exports HKDF: the
test:browser run against ../libsodium.js/dist/browsers-sumo/sodium.js
reports "native export yes" and matches the vectors.RFC 5869 is two HMAC steps. Extract: PRK = HMAC(salt, IKM), where an
empty salt means 32 zero bytes. Expand:
T(i) = HMAC(PRK, T(i-1) ‖ info ‖ byte(i)), concatenated and truncated
to the requested length. The shim in test/browser/index.html and
test/wasm/check.cljs (about 12 lines) matches native HKDF, including
RFC 5869 A.1. Two traps:
crypto_auth_hmacsha256 accepts only 32-byte
keys ("invalid key length"). The A.1 salt is 13 bytes, so the shim uses
the streaming crypto_auth_hmacsha256_init/update/final, which takes any
key length.info as a string — consequences [verified]The master wrapper types HKDF's info (ctx) as a JavaScript string
(unsized_string). The conversion is in
wrapper/macros/input_unsized_string.js: from_string(ctx + "\0"),
passing the byte length minus the NUL, so an embedded NUL is not
truncated. from_string (wrapper/wrap-esm-template.js) is
new TextEncoder().encode(str), i.e. UTF-8. When TextEncoder is missing
it falls back to unescape(encodeURIComponent(str)).
info
as arbitrary bytes. Its own vector A.1 (f0 f1 … f9) is not valid UTF-8,
so no JavaScript string yields those bytes. The native export cannot
reproduce the RFC's own vector.TextEncoder maps it to U+FFFD with no error. Measured
on Node 26.9:
TextEncoder("\uD800") → ef bf bd, the same bytes as for "�".
So two different info strings give the same derived key, which is the
collision info exists to prevent. Only the no-TextEncoder fallback
throws (URIError: URI malformed), and every current browser and Node
has TextEncoder.(.getBytes "\uD800" "UTF-8") → 3f (?). The same label therefore
derives different keys on the JVM and in the browser, and each side sees
only a decryption failure. This is the class of bug canonical-edn fixed
in 1.4.0 (decision 10: lone surrogates became ? on the JVM and U+FFFD
in JS; cedn now rejects them).signet is unaffected today: "signet/box/v1" is ASCII, which encodes
identically everywhere.
info (and every KDF or AEAD context) as bytes, never strings,
at the facade's API. Callers encode on purpose: a constant byte array, or
canonical-edn bytes, which reject lone surrogates._crypto_kdf_hkdf_sha256_expand
with a byte buffer, so every runtime feeds libsodium identical bytes.libsodium.js master, crypto_kdf_hkdf_sha256_expand /
crypto_kdf_hkdf_sha512_expand: ctx is typed as a string and
UTF-8-encoded with TextEncoder. This means:
info (RFC 5869 A.1) cannot be passed;Suggestion: accept a Uint8Array for ctx (optionally also a string), as
the other buffer inputs do. Since the wrapper is unreleased, changing it
breaks nobody.
randombytes_buf uses the operating system's secure
generator [source: libsodium docs].crypto.getRandomValues in
browsers or crypto.randomBytes on Node. When neither is available it
fails closed: libsodium calls sodium_misuse() (an abort), and the older
code threw 'No secure random number generator found'. There is no
Math.random fallback. [verified] read in ../libsodium, commit
c960b3b0 (2026-09-22), "Emscripten: simplify randomness extraction".random-uuid is built on Math.random, and uuidv7 uses it on CLJS, nbb
and Scittle. With Math.random pinned, the "random" bits become
identical. uuidv7 should stay dependency-free, so it needs a small
random-bytes of its own (SecureRandom / crypto.getRandomValues,
chunked at 65,536 bytes, fail closed) rather than a dependency on
libsodium.babashka.ffi is under four weeks old and node:ffi is
experimental. Both APIs could still change.pkg-config --modversion libsodium.await sodium.ready) and
works on Uint8Array, where the FFI binding uses byte[]/Int8Array.
A signet engine needs a cljc facade over both.core.cljc copies secrets into confined arenas and
closes them, but does not zero them first. A production version should
call sodium_memzero before closing (TODO). Every fixed-size input is
length-checked (see "Memory safety").signet.impl.jvm functions
and run signet's suite against it~~ Done: passes on the JVM (102/436)
and on bb (93/415). Also done: it now lives in signet itself (PR #1,
merged to main 2026-09-23, build 0.7.0-SNAPSHOT) as signet.impl.sodium, behind a
signet.impl facade that selects the backend once at load
(-Dsignet.backend / SIGNET_BACKEND, default jca, loud failure
instead of silent fallback). signet's own test/signet/backend_parity.clj
compares both backends in one JVM (54 checks, byte-identical). The shim
here stays as the no-changes-to-signet integration test.info (draft above), and crypto_scalarmult in the master standard
build.Before the first Clojars release, the binding was reworked to be the only
place where Clojure values become C pointers. The API was renamed to say
which algorithm each function is (ed25519-sign,
chacha20-poly1305-encrypt, hkdf-sha-256, ...). The README's "Memory and
type safety" section lists the guarantees. The findings behind them:
(hkdf-sha256 ikm nil info 32) threw a bare NullPointerException, and
HKDF with length 0 threw an internal babashka.ffi error. With the count
check removed as an experiment, (random-bytes -1) did not throw: libsodium's
randombytes_sysrandom asserted size <= SSIZE_MAX and aborted the whole
process. Every count is now an integer in a checked range.crypto_sign/ed25519/ref10/sign.c]. crypto_sign_detached derives the
nonce from the seed alone and hashes sk[32..64], the public-key half,
into the challenge without checking it. Two signatures of the same
message under one seed with different public-key halves reveal the
private scalar. signet's adapter built the 64-byte key in the Clojure
heap on every signature. ed25519-sign now takes the seed and derives
the key in native memory, so a mismatch cannot occur, and the 64-byte key
never exists on the heap.sodium_memzero before
closing, in a finally. An audit hook (*audit*, private) records every
allocation, wipe and close, and the tests check that they match, both on
success and when C reports a failure.ed25519-verify?'s length
guard removed as an experiment, every test still passed: C read past the
short signature and returned false, the expected answer. The tests now
also assert that rejected input allocates no native memory at all, and
that catches it.write-array :char accepts only
Int8Array. A Uint8Array, the usual JS byte type, failed deep inside
babashka.ffi. It is now accepted through a zero-copy Int8Array view.
Other typed arrays are ::bad-input: alength counts their elements,
not their bytes. read-array copies on both runtimes (checked by
overwriting the buffer after reading), so wiping native memory cannot
corrupt a result.NACLJC_LIBSODIUM, or the
-Dnacljc.libsodium property on the JVM and bb) is the only one tried. A
path that loads but is not libsodium is ::not-libsodium, where before
it failed with a bare "symbol not found". On the JVM that surfaced as
"Syntax error macroexpanding". 19 subprocess checks on bb, nbb and the
JVM (bb test:loading) cover this.Proof that each guard bites: removing it made the tests fail. The counts of failed assertions were:
| Guard removed | Failed |
|---|---|
sodium_memzero call | 1 |
wipe in release! | 16 |
| wipe on the error path | 2 |
| type check | 178 |
| fixed-size length check | 13 |
| count range check | 3, plus a process abort |
ed25519-verify? size guards | 5 each |
| ciphertext ≥ tag check | 1 |
| raw binding made public | 1 |
sodium_init check at load | 3 loading checks |
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 |