Status: implemented in signet.encryption (PR #1, merged to main
2026-09-23; build 0.7.0-SNAPSHOT), with decisions settled the same day (see
"Decisions"). v2 replaces v1 entirely: signet is its own
ecosystem, so there is no v1 reader or writer to keep.
box descends from NaCl's crypto_box (Bernstein, Lange, Schwabe, around
2008), which libsodium (Frank Denis, around 2013) carries forward. In NaCl's
design the ciphertext is only the 16-byte tag plus the encrypted message.
The sender's public key, the recipient's keypair and the 24-byte nonce are
all left to the surrounding protocol. libsodium's sealed box
(crypto_box_seal) embeds only the sender's ephemeral public key.
signet's box v1 wire format is nonce(12) ‖ ChaCha20-Poly1305(k, nonce, pt, aad), with k = HKDF(X25519(sender, recipient), info = "signet/box/v1").
It has three problems:
k depends only
on the unordered pair of keys, so A→B and B→A share it. A message can be
reflected back to its sender and decrypts as if the peer had sent it.urn:signet:pk:x25519:<base64url>). A
Curve25519 public key is 32 bytes, so the kid is the key. A kid slot
needs no registry lookup, and resolving it registers nothing (see
key/lookup).box already does.box/unbox. The caller never sees or
supplies a nonce ("take the gun away").An embedded sender kid is a claim, not a credential. Anyone can box a message to you with their own key and put their own kid in the slot. It decrypts fine, and says it came from Mallory. So, in signet's vocabulary:
unbox … {:from expected-kid-or-set} gives :verified?, exactly like
verify-edn's :signer);The sender slot helps the receiver find the right key; it must never decide trust. The recipient slot helps a receiver with several keypairs pick the right one; a missing or wrong one simply fails to decrypt.
The key derivation always binds both public keys, in direction order:
shared = X25519(sender_sk, recipient_pk) ; same value both ways
k_msg = HKDF-SHA-256(ikm = shared,
salt = nonce, ; 24 random bytes
info = "signet/box/v2" ‖ sender_x25519_pk ‖ recipient_x25519_pk,
len = 32)
The info binds the X25519 form of each key: Ed25519 kids are converted first. So one identity derives the same key whichever kid form (Ed25519 or X25519) appears in a slot. The slot itself is AAD, so its form cannot be swapped in transit.
info = … ‖ A ‖ B and B→A uses … ‖ B ‖ A, so
their keys differ, and a reflected message fails authentication. That
closes finding 7.info, which the receiver
reconstructs from the keys it uses. Omission only changes the wire, not
what was checked.Alternatives considered:
signet.impl.crypto_kx (rx/tx session keys): also directional, but it
assumes client/server roles and is libsodium-only.signet.session) are the
stateful answer.{:type :signet/box
:v 2
:from "urn:signet:pk:ed25519:…" ; optional (default on): sender kid, Ed25519 or X25519
:to "urn:signet:pk:x25519:…" ; optional (default on): recipient kid, Ed25519 or X25519
:aad {:request-id #uuid "…"} ; optional: caller context, any CEDN-P EDN value
:nonce #bytes "…" ; required: 24 random bytes (the HKDF salt)
:ct #bytes "…"} ; ChaCha20-Poly1305(k_msg, 0^96, pt, aad) incl. 16-byte tag
aad = cedn-bytes(header), where header is every field except :ct, in
canonical form. The caller's context lives inside the header as the
optional :aad slot, so it is authenticated with everything else. Since
the header is canonical EDN, :aad can be any EDN value, not just bytes.
Two consequences:
:aad travels in the clear: it is authenticated, not secret.unbox takes an
expected value ({:aad expected}) and treats a mismatch as invalid, just
as :from works for the sender. Without an expectation, unbox returns
the value for the caller to inspect.Size overhead versus v1: about 24 bytes of nonce plus about 60 bytes per
kid included, plus the EDN framing. As canonical EDN text, cedn writes
#bytes in hex, so :nonce and :ct take twice their byte size. That is
negligible for signet's typical messages (commands, tokens, small
payloads), but a real cost for large ones. A compact binary transport
framing could be added later without changing the cryptography: the AAD
stays cedn-bytes(header).
(box sender-kp recipient-pub plaintext) ; default: include :from and :to
(box sender-kp recipient-pub plaintext {:from? false :to? false :aad edn-value})
(unbox recipient-kp-or-keys boxed) ; resolves keys from slots when present
(unbox recipient-kp-or-keys boxed {:from expected-kid-or-set :aad expected-edn-value})
;; => {:valid? … :verified? (with :from) :plaintext … :from kid :aad … :error …}
:to slot. With no
:to, each candidate is tried; that is fine for the "one obvious key"
case.:from slot, or from the caller when
the slot is omitted.verify-edn, unbox never throws on malformed input.box/unbox read and write only v2. v1's raw
nonce ‖ ct bytes are rejected as malformed (:valid? false). The :v
field is kept so a future v3 can be told apart.The same format, with the sender slot carrying the ephemeral public
key (e.g. :epk), created and wiped inside seal, never registered and
never exposed. info binds epk ‖ recipient_pk. A seal has no :from
identity by definition, so it can be valid but never "verified as" anyone.
:aad: any EDN value round-trips. A mismatch against the expected
value, or a missing slot when one is expected, is invalid.unbox as B→A, both with and
without kid slots. It must fail today against v1 (failing-first).:from, :to, :v or
:nonce, or the :ct bytes, fails authentication.:from; a
wrong expected sender gives :verified? false.:valid? false, never a
throw. Resolving kids registers nothing in the store.signet.encryption/box and unbox, 14 tests in encryption_test.clj.
All failed against v1 first. v1 demonstrably allowed reflection: Alice
accepted her own "transfer 100 to bob" as if sent by Bob.reflection-fails. That required adding the slot-less reflection
case: with slots, header binding alone already rejects a reflected box,
which masked the missing direction binding.k from the formula above using raw
primitives and decrypts box's output. Run under both backends, it is
also a parity check.key/encryption-public-key
and key/encryption-private-key (the as-* variants until 0.7.0 made
all conversions pure): box and unbox register nothing.:from/:to for unlinkable or single-key protocols.:aad slot (any
EDN value; authenticated, visible on the wire, checked against
unbox's expectation).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 |