libsodium as one crypto engine for Clojure on every runtime, with one binding source file, hardened at the C boundary (see "Memory and type safety").
babashka.ffi. The same
src/nacljc/core.cljc runs unchanged on all three.Every engine produces byte-identical results. They reproduce the RFC test vectors, and they reproduce the outputs of signet's current JCA backend, so moving signet onto libsodium would change no signature, key or ciphertext.
signet runs on it. signet ships a libsodium backend on nacljc
(signet.impl.sodium, since signet 0.7.0), byte-identical to its JCA
backend. bb test:signet runs signet's own suite in ../signet with
nacljc replaced by this checkout. At 0.3.2 with signet 0.9.2:
nacljc is NaCl (sodium chloride, and Bernstein, Lange and Schwabe's
Networking and Cryptography library) plus cljc. It is not a binding to
the original NaCl C library. It binds libsodium,
NaCl's maintained successor. It is aligned with NaCl's design: a few
well-chosen primitives, hard to misuse, and the same Curve25519, Ed25519
and Poly1305 lineage. But it also exposes libsodium primitives that NaCl
never had, such as HKDF-SHA-256 and IETF ChaCha20-Poly1305. The repo was
called sodium.cljc until 2026-09-23. It was renamed because Clojars'
com.degel/sodium already ships a sodium.core namespace.
Status: 0.5.0 released on Clojars (2026-09-27; see CHANGELOG.md). signet uses it as its libsodium backend.
docs/feasibility.md has the research findings and
the evidence.
nacljc needs libsodium >= 1.0.19 installed natively (brew install libsodium; on Linux see "Requirements", since Debian and Ubuntu ship
1.0.18).
;; deps.edn (JVM, JDK 25+)
{:deps {com.github.franks42/nacljc {:mvn/version "0.5.0"}}
:aliases {:run {:jvm-opts ["--enable-native-access=ALL-UNNAMED"]}}}
;; bb.edn (babashka 1.13.220+; bb ignores the org.babashka/ffi
;; dependency and uses its built-in babashka.ffi)
{:deps {com.github.franks42/nacljc {:mvn/version "0.5.0"}}}
;; nbb.edn (nbb 1.6.213+ on Node 26+; nbb resolves :deps through bb,
;; so bb must be installed)
{:deps {com.github.franks42/nacljc {:mvn/version "0.5.0"}}}
(require '[nacljc.core :as na])
(let [seed (na/random-bytes 32)
pk (na/ed25519-public-key seed)
msg (.getBytes "hello" "UTF-8")]
(na/ed25519-verify? pk msg (na/ed25519-sign seed msg))) ;=> true
Everything is in nacljc.core. Byte arrays in and out: byte[] on the JVM
and bb. On nbb, Int8Array or Uint8Array in, and Int8Array out.
| Function | Inputs, sizes in bytes | Returns |
|---|---|---|
(ed25519-public-key seed) | seed 32 | public key 32 |
(ed25519-sign seed msg) | seed 32, msg any | signature 64, deterministic |
(ed25519-verify? pk msg sig) | pk 32, msg any, sig 64 | boolean; never throws for a bad pk or sig (untrusted input) |
(ed25519->x25519-public-key pk) | Ed25519 public key 32 | X25519 public key 32 |
(ed25519->x25519-secret-key seed) | Ed25519 seed 32 | X25519 secret key 32 |
(x25519 sk pk) | our secret key 32, their public key 32 | shared secret 32 |
(x25519-public-key sk) | secret key 32 | public key 32 |
(chacha20-poly1305-encrypt k nonce pt aad) | key 32, nonce 12, plaintext, aad (nil = none) | ciphertext ‖ 16-byte tag |
(chacha20-poly1305-decrypt k nonce ct aad) | key 32, nonce 12, ciphertext ≥ 16, aad | plaintext |
(hkdf-sha-256 ikm salt info len) | salt and info may be nil (empty); ikm and salt may be secrets (0.3.0: salt); len 1..8160 | len bytes (RFC 5869); a secret if ikm or salt is one |
(hmac-sha-256 k data) | key of any length | 32 |
(sha-256 data) | 32 | |
(argon2id password salt len limits) | password (bytes or a secret), salt 16, len ≥ 16, {:opslimit n :memlimit bytes} | len bytes (Argon2id v1.3, libsodium's crypto_pwhash); a secret if the password is one (0.4.0) |
(argon2id-limits preset) | :interactive, :moderate or :sensitive | {:opslimit n :memlimit bytes} from libsodium (0.4.0) |
(random-bytes n) | n ≥ 0 | n bytes from libsodium's CSPRNG |
(memzero! bs) | byte array | nil; overwrites bs with zeros |
(constant-time-equal? a b) | two byte arrays or secrets (0.4.0: secrets compared in place) | boolean; constant-time for equal lengths |
(libsodium-version), minimum-libsodium-version | "1.0.22", [1 0 19] | |
(aegis256-encrypt k nonce pt aad) | key 32, nonce 32, plaintext, aad (nil = none) | ciphertext ‖ 32-byte tag (AEGIS-256, RFC 10032) |
(aegis256-decrypt k nonce ct aad) | key 32, nonce 32, ciphertext ≥ 32, aad | plaintext |
(xwing-public-key seed) | seed 32 (the X-Wing secret key) | public key 1216 (X-Wing: ML-KEM-768 + X25519) |
(xwing-encapsulate pk) | public key 1216 | {:ciphertext <1120 bytes> :shared-secret <secret>} |
(xwing-decapsulate seed ct) | seed 32, ciphertext 1120 | shared secret, always a secret object |
Secrets (0.2.0). Key material can live in libsodium's guarded memory instead of the Clojure heap:
| Function | Does |
|---|---|
(secret-random n) | a new secret of n random bytes (1..65536), drawn straight into guarded memory |
(secret-import! bs) | a new secret holding a copy of bs, then wipes bs |
(secret-export s {:i-understand :exposes-secret}) | the bytes as a new array; throws ::export-not-acknowledged without that exact map |
(secret-destroy! s) | zeroes and frees it (sodium_free); later use throws ::destroyed-secret; again is a no-op |
(with-secret [s (secret-random 32)] …) | destroys s on exit, also when the body throws |
(secret? x), (secret-length s), (secret-destroyed? s) | |
(wrap-secret k nonce s aad) | 0.5.0: ChaCha20-Poly1305 of secret s's bytes, read in place, under key k; returns ciphertext ‖ tag as bytes (safe to store). Key wrapping without the heap |
(unwrap-secret k nonce ct aad) | 0.5.0: decrypts straight into a new secret; ::auth-failed for a wrong key, nonce, aad or ciphertext, with nothing left behind |
(secret-split s [32 32]) | 0.3.0: new secrets holding consecutive parts of s (lengths must add up to its size); copied inside guarded memory; s is unchanged |
Every function that takes key material (a seed, a secret key, an AEAD, HMAC
or HKDF key) accepts a secret wherever it accepts a byte array. Secrets
stay secrets: when a secret-key input is a secret, a result that is
itself secret key material comes back as a secret too. That covers the
X25519 shared secret, ed25519->x25519-secret-key and HKDF output (a
secret ikm or, since 0.3.0, a secret salt: Noise's chaining key is the
salt of each MixKey). secret-split then turns one HKDF output into
several keys without leaving guarded memory.
Public results (public keys, signatures, ciphertexts, MAC tags) are byte
arrays. Callers that pass byte arrays get byte arrays, exactly as in 0.1.0.
X-Wing shared secrets are always secrets.
Ed25519 secret keys are 32-byte seeds. libsodium's 64-byte secret key (seed ‖ public key) never leaves native memory; take its first 32 bytes if you have one.
Errors are ex-info with a :type:
:type | When |
|---|---|
:nacljc.core/bad-input | an input is not a byte array, or a count is not an integer |
:nacljc.core/bad-length | a fixed-size input has the wrong size, or a count is out of range |
:nacljc.core/auth-failed | decryption failed: wrong key, nonce or aad, or tampered ciphertext |
:nacljc.core/low-order-point | x25519 with a small-order public key (the shared secret would be all zeros) |
:nacljc.core/invalid-public-key | ed25519->x25519-public-key with a point that is not on the curve or has small order |
:nacljc.core/call-failed | any other non-zero libsodium return code |
:nacljc.core/destroyed-secret | a secret used after secret-destroy! |
:nacljc.core/secret-in-use | secret-destroy! while another thread's call is using the secret |
:nacljc.core/export-not-acknowledged | secret-export without {:i-understand :exposes-secret} |
:nacljc.core/unsupported-by-libsodium | X-Wing on a libsodium older than 1.0.22 |
:nacljc.core/library-not-found, not-libsodium, libsodium-too-old, init-failed | at load time (see below) |
Error data describes types and sizes, never contents, since they may be secret.
nacljc.core is the line between Clojure, where a wrong value gives an
exception, and C, where it reads or writes the wrong memory. C trusts every
pointer and length, so everything is settled on the Clojure side first.
int[], or on nbb a plain JS array or Int16Array, is
::bad-input, never a pointer to the wrong bytes.verify? over-reading
a short signature, is caught this way.sodium_memzero before the arena is
released, on success and on error. The tests audit every allocation,
wipe and close, and check that the wipe really zeroes memory.Each of these checks was shown to bite by removing it and watching the
tests fail. Without the count check, (random-bytes -1) does not throw at
all: it aborts the whole process inside libsodium.
What stays with the caller:
sodium_malloc memory: guard pages around it, canaries checked when it
is freed, and locked so it is never swapped out. It is no-access
except during a call that uses it, when it is read-only. C reads it in
place, so the bytes never reach the Clojure heap unless you call
secret-export. Several threads can use one secret at once: a counter
under a lock opens the read-only window on the first use and closes it
after the last. bb test:secrets proves the protection in child
processes: reading a secret's memory outside a call, or after
destroying it, faults, while the same read inside a call works. bb
and nbb crash either way. On the JVM, a read outside a call (the page is
still mapped, no-access) raises InternalError on macOS and aborts on
Linux; a read after secret-destroy! is a use after free and crashes
the whole JVM (SIGSEGV) on every platform. Removing the
counter as an experiment crashed the JVM with SIGBUS when threads shared
a secret.sodium_stackzero over 16 KiB below the caller, as libsodium's docs
recommend. It costs about 0.2 µs per operation (an Ed25519 signature
takes about 25 µs). Operations on plain byte arrays skip it. This is
best effort: registers saved by the OS during a context switch are out
of reach. libsodium's docs also recommend disabling core dumps
(ulimit -c 0), encrypted or no swap, and no hibernation on machines
that handle secrets.memzero! on
secrets when you are done.chacha20-poly1305-encrypt takes a
caller-chosen nonce. Never reuse one with the same key. Higher-level
APIs such as signet's box hide nonces entirely.mlocked. Byte-array inputs are copied into
a per-call scratch arena (wiped before release), which could reach swap
during the microseconds of a call. Secrets are locked.secret-destroy! (or with-secret)
stays allocated and locked until the process exits.nacljc keeps secrets out of the Clojure heap, but a crashing or inspected
process can still leak what is in memory. Hardening that is a deployment
choice, because each measure also takes a debugging tool away. So
nothing happens by default: loading nacljc changes no process setting.
nacljc.process (0.4.0) does it when you ask:
(require '[nacljc.process :as p])
(p/process-status)
;; => {:os :linux :core-dumps {:soft 0 :hard :unlimited} :dumpable true :heap-dump-on-oom false}
(p/harden-process! {:core-dumps false :dumpable false :heap-dump-on-oom false})
;; => {:core-dumps :disabled :dumpable :disabled :heap-dump-on-oom :disabled}
| Option | What it does | Where | What it costs |
|---|---|---|---|
:core-dumps false | setrlimit(RLIMIT_CORE, 0, 0): no core dump; the hard limit 0 cannot be raised again in this process | macOS, Linux | no core file to debug a crash |
:dumpable false | prctl(PR_SET_DUMPABLE, 0): no core dump, and other processes of the same user can no longer attach a debugger (ptrace) or read /proc/<pid>/mem | Linux | no debugger, profiler or /proc inspection by the same user |
:heap-dump-on-oom false | switches off -XX:+HeapDumpOnOutOfMemoryError, which writes the whole heap to disk | HotSpot JVM | no heap dump to diagnose an out-of-memory error |
Each option reports :disabled, :unsupported (not on this OS or
runtime) or :failed. Only disabling is offered; an unknown option or a
value other than false throws before anything changes.
Not settable from a running process, so set them yourself if you want them:
-XX:+DisableAttachMechanism (no jcmd/agent attach by
the same user, which would bypass :dumpable), -XX:ErrorFile=<path>
(the crash log hs_err_pid*.log contains registers and stack words).libsodium loads when nacljc.core loads. Without configuration it is
looked for in these places:
libsodium.so.26, then .so.23, then .so, through the
system search path.To use exactly one library, set a path:
NACLJC_LIBSODIUM=/opt/libsodium/lib/libsodium.so.26 bb … # any runtime
clojure -J-Dnacljc.libsodium=/opt/libsodium/lib/libsodium.dylib … # JVM (bb: -Dnacljc.libsodium=…)
::library-not-found, listing the paths tried.::not-libsodium.::libsodium-too-old.Security note: whoever sets these chooses the crypto library.
NACLJC_LIBSODIUM and -Dnacljc.libsodium load whatever library they
name, and every secret then passes through it. Anyone who can change the
process's environment or command line can point them at a malicious
library that passes the checks above. Treat them like LD_PRELOAD: set
them only from deployment configuration you control, and do not let
untrusted input reach the environment of a process that holds secrets.
All of these are needed to run everything. Each runtime also works on its own.
| Component | Minimum | Tested | Notes |
|---|---|---|---|
| libsodium (native) | 1.0.19 (X-Wing: 1.0.22) | 1.0.22 (Homebrew) | 1.0.19 added HKDF and AEGIS-256; X-Wing arrived in 1.0.22, and on an older libsodium only the X-Wing functions fail, with ::unsupported-by-libsodium; nacljc.core checks the version at load and throws ::libsodium-too-old for anything older. macOS: brew install libsodium. On Linux, check your distribution's version with pkg-config --modversion libsodium, since some ship an older one. |
| JDK (JVM Clojure) | 25 | 25.0.3 (Temurin) | org.babashka/ffi needs JDK 25+. On 21.0.11 it fails with ClassNotFoundException: java.lang.classfile.ClassBuilder. Run with --enable-native-access=ALL-UNNAMED (the :test alias sets it). Without it, JDK 25 warns that native calls "will be blocked in a future release". |
org.babashka/ffi (JVM only) | 0.1.2 | 0.1.2 | Built into bb and nbb. Experimental. |
| Clojure CLI | — | 1.12.6 | |
| babashka | 1.13.220 | 1.13.223, 1.13.224 | babashka.ffi was added in 1.13.220. On Linux use the dynamically linked build (babashka-<v>-linux-amd64.tar.gz). The static build (…-static), which DeLaGuardo/setup-clojure installs on Linux, cannot load shared libraries at all: cannot load library, even by absolute path. |
| nbb | 1.6.213 | 1.6.213 | babashka.ffi built in. |
| Operating system | macOS, Linux | macOS 26, Ubuntu 24.04 | Windows is not supported or tested. Loading fails cleanly with ::library-not-found (there are no default Windows locations); NACLJC_LIBSODIUM pointing at a DLL may load, untested. |
| Node.js (nbb) | 26.1 | 26.9.0 | nbb's FFI uses Node's built-in node:ffi. It is experimental and prints an ExperimentalWarning. |
| libsodium.js (WASM) | 0.8.4 sumo | 0.8.4 | The standard 0.8.4 build has no HMAC, SHA-256 or HKDF. 0.8.4 does not export HKDF, so the tests implement it on HMAC (see the docs). |
| Scittle (browser) | — | 0.8.33 | libsodium.js loads through a plain <script> tag, with no bundler. Only headless Chromium has been tested. |
| Playwright (browser test only) | — | 1.58.2 | npm install && npx playwright install chromium in test/browser. |
| clj-kondo, cljfmt | — | current | For bb lint and bb fmt. |
bb test:bb # vector tests on babashka
bb test:jvm # vector tests on JVM Clojure (JDK 25+)
bb test:nbb # vector tests on nbb (Node 26+)
bb test:loading # library loading in fresh processes, on bb, nbb and the JVM
bb test:secrets # secret memory faults outside calls and after destroy (child processes, all runtimes)
bb test:wasm # libsodium.js on Node (first: cd test/wasm && npm install)
bb test:browser # Scittle + libsodium.js in headless Chromium
# (first: cd test/browser && npm install; optional arg = a libsodium.js build URL or path)
bb test:jca # random-input cross-check against signet's JCA backend (needs ../signet)
bb test:signet # signet's own test suite: JCA oracle (JVM), libsodium (JVM), libsodium (bb)
# (needs ../signet; also test:signet-jca / test:signet-jvm / test:signet-bb)
bb test:all # everything except test:jca and test:signet, plus library loading, lint and format
bb install # install the jar (build.clj's version) into ~/.m2
bb test:jar # the suite against the installed jar, from a scratch project: JVM, bb, nbb
bb test:clojars V # the same against release V fetched from Clojars
bb install builds a jar containing only src/ (its pom depends on
org.babashka/ffi 0.1.2) and installs it locally. Re-run it after every
change, or consumers such as signet keep using the old jar. bb test:jar
runs the whole suite against that jar from a scratch project with no
src/, on the JVM, bb and nbb. On babashka, the jar's org.babashka/ffi
dependency is ignored; bb always uses its built-in babashka.ffi
(verified).
Releases: push a vX.Y.Z tag. The release workflow checks the version
(bb release-check) and runs the tests. It then deploys to Clojars, runs
the suite again against the jar fetched back from Clojars (bb test:clojars X.Y.Z), and creates the GitHub release from the CHANGELOG section.
test:wasm and test:browser need the network: npm, and jsdelivr for
Scittle and libsodium.js.
src/nacljc/core.cljc the binding (API above): checks, scratch arenas, wiping
src/nacljc/process.cljc opt-in process hardening (libc: setrlimit, prctl)
test/nacljc/vectors.edn RFC vectors + cross-platform vectors
test/nacljc/core_test.cljc known answers, typed errors, hygiene audit (JVM, bb, nbb)
test/nacljc/process_test.cljc nacljc.process checks that change nothing
test/loading/run.clj library-loading checks, driving test/loading/child.cljc
test/secrets/run.clj secret memory faults, driving test/secrets/child.cljc
test/process/run.clj hardening applied, driving test/process/child.cljc
test/nacljc/jca_crosscheck.clj libsodium vs signet's JCA on random inputs
test/wasm/check.cljs libsodium.js on Node against the same vectors
test/browser/index.html Scittle page doing the same in a browser
test/browser/run.mjs Playwright runner for that page
docs/feasibility.md findings and recommendation
Copyright (c) Frank Siebenlist. Distributed under the Eclipse Public License v2.0.
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 |