Status: DRAFT plan, 2026-09-24. Implements decision 9 of
docs/07-secret-handles-design.md ("a secret derived from a vault secret
stays in the vault"). Decision 17 (deprecating key records) follows as a
separate step at the end.
signet.session keeps every secret as a byte array in the state map:
| Secret | Field | Lifetime today |
|---|---|---|
| local static private key | :local-static-kp :d (a key record) | caller's |
| local ephemeral private key | :local-ephemeral-kp :d | wiped at split! |
| DH outputs (es, ss, ee, se) | none (passed straight to mix-key!) | wiped in mix-key! |
chaining key ck, handshake key k | :ck, :k | wiped at split! |
| transport keys | :send :k, :recv :k | never wiped |
The gaps:
:sodium
provider.ck/k pairs that each mix-key! replaces are never wiped. They
can't be wiped early, because a failed read has to leave the old state
usable.Secrets: a state holds no secret bytes. ck, k, both transport
keys and the local ephemeral are vault handles. They live in the vault
of the local static handle, or in :vault from opts when a deprecated
key record is passed.
What stays in the state: only public values: h, public keys,
counters, the phase and the single-use marker. Printing or logging a
state leaks nothing.
Lifecycle: the vault holds exactly the secrets of the live states.
A successful write-message!/read-message! destroys the entries only
the consumed state used. A failed or losing call destroys the entries
it created. close! ends a session: it destroys every entry the
session ever created, from any of its states. with-conclave calls it
automatically when a block exits.
Wire format: unchanged, checked byte for byte by known-answer vectors on both backends.
API (the static key is an identity, so it comes as a handle):
(session/initiator my-handle their-public-or-kid)
(session/initiator my-handle their-public-or-kid {:prologue bs})
(session/responder my-handle their-public-or-kid)
(session/write-message! st pt) (session/read-message! st ct) ; unchanged
(session/established? st) ; unchanged
(session/close! st) ; new: destroys all the session's secrets, from any of its states
(session/with-conclave [st (session/initiator my-handle peer)] ; new: close! on exit
...)
A peer kid is resolved with vault/lookup, as in shared/shared-key!.
Key-record arguments still work in 0.9.0, marked deprecated.
Done (2026-09-25). test/signet/noise_vectors_test.clj checks every
message against the cacophony and snow Noise_KK_25519_ChaChaPoly_SHA256
vectors. It found a bug: signet mixed the prologue into the transcript
hash after the static keys instead of before (Noise §5.3), so its
handshake messages matched no other implementation. Fixed; this breaks
handshakes with 0.8.0 peers (CHANGELOG 0.9.0). With the order swapped
back, 5 of the 26 assertions fail.
Before anything moves, pin the wire format.
with-redefs on the private
fresh-ephemeral, so production code gets no injection hook. Phase 3
keeps a single ephemeral-creating function as that seam.Noise_KK_25519_ChaChaPoly_SHA256. Use the
cacophony/snow vectors if they cover KK with this suite (to confirm);
otherwise record vectors from today's code and cross-check them once
against an independent implementation. Each vector checks every
handshake and transport message.:jca and :sodium (JVM and bb). Both backends meeting
the same published vectors covers session parity, so
backend_parity.clj needs no session case.This is a separate commit. Everything after it has to keep these vectors green.
:sodium)Implemented in ../nacljc (2026-09-25), not released. Both changes are
tested on bb, the JVM and nbb, with each guard shown to fail when removed.
Release when signet's phases 2–3 have used the API.
Noise's MixKey is HKDF(salt = ck, ikm = dh), and Split is
HKDF(salt = ck, ikm = ""). In both, the salt is the secret. nacljc's
hkdf-sha-256 accepts a secret only as ikm (check-optional-bytes on
salt), and whether the output is a secret follows ikm alone. So under
:sodium the chaining key would have to leave guarded memory.
Changes:
hkdf-sha-256: accept a secret salt. The output is a secret if ikm
or salt is one. Split's empty ikm with a secret ck must give a
secret.secret-split: new secrets holding consecutive parts of a secret, for
example (secret-split s [32 32]) gives [s1 s2]. It copies inside
guarded memory and leaves s unchanged (the caller destroys it). Noise
always needs two 32-byte outputs from one 64-byte HKDF, and T(2)
depends on T(1), so two separate calls can't produce them.deps.edn
:sodium alias and in bb.edn's test:bb-sodium and test:jar
(their versions must match).The :memory/JCA path needs none of this.
Done (2026-09-25), on branch sessions-on-handles (which uses
../nacljc through :local/root until nacljc 0.3.0 is released). Two
changes from the text below:
-generate!. Derived entries (ck, k,
transport keys) do get random ids.hkdf-pair! splits the 64-byte output with a new backend function,
impl/split-material (bytes under JCA, nacljc/secret-split under
libsodium). The facade now has 18 functions.Found while testing: the :sodium provider's -adopt! stored byte
material as it was, so destroying it failed. It now moves bytes into
guarded memory with secret-import!, so a :sodium vault holds only
secrets.
These are internal, ^:no-doc functions, called only by signet.session.
Session entries. Session secrets are secret-side entries of a separate kind:
handles.export-secret refuses them with
::not-exportable.Their ids are random (urn:signet:session:<random>), never derived from
the key, in the spirit of decision 16. An ephemeral's id is also random,
not its public key's kid. The vault records the kind in a new :session
map, next to :shared: entry id → the id of the session that created
it. unregister-vault! and destroy! already cover them.
Operations, each written with -with-material/-adopt! so the
Provider protocol doesn't change:
| Function | Does |
|---|---|
generate-ephemeral! [vault-id] | New X25519 key in the provider, returns [h pub-bytes]. Under :sodium it is born in guarded memory. |
x25519-dh [h their-pub] | Exists already. Also accepts ephemeral handles. Output is material the caller must destroy. |
hkdf-pair! [vault-id salt ikm] | Noise HKDF: two 32-byte outputs as two new session handles. salt is public bytes (the initial ck) or a handle. Destroys ikm. |
aead-encrypt / aead-decrypt [h nonce pt aad] | ChaCha20-Poly1305 under a handle key. |
destroy-session! [vault-id session-id] | Destroys every entry tagged with session-id. Idempotent. |
session-entry-count [vault-id] | How many session entries the vault holds. For monitoring abandoned sessions, and for the accounting tests. |
generate-ephemeral! and hkdf-pair! take the session id and tag each
new entry with it.
Tests go in vault_test.clj: session entries are invisible to handles
and lookup, can't be exported, and outputs match impl for the same
inputs on both providers.
Later, when a remote enclave (agent, HSM) arrives, these operations become provider protocol methods so the keys never leave the enclave. That is not needed now.
Done (2026-09-25), with phase 4, on branch sessions-on-handles. The
Noise vectors pass unchanged, with handles and with key records, on JCA,
on libsodium on the JVM, and on bb. Details that differ from the steps
below:
:local-static, :local-ephemeral (an
EphemeralKeyPair with :handle, no :d).*created*, bound by consume!,
instead of passing a collector through every helper.::secret-in-use); such an entry stays until close!.::stale-session-state, not ::destroyed-key.(random-uuid).vault/handle returns it, so not a session entry) with algorithm
:ed25519 or :x25519; otherwise ::no-private-key. A peer given as
a kid that can't be resolved throws ::unknown-peer.with-conclave gets a clj-kondo lint-as in the project config and in
src/clj-kondo.exports/com.github.franks42/signet/config.edn, for
consumers.State shape:
{:phase :handshake :role :initiator :pos 0 :consumed <marker>
:vault :default
:session-id <random> :closed <atom> ; shared by every state of the session
:h <bytes> :ck <bytes-or-handle> :k <handle-or-nil> :n 0
:local-static <handle-or-record> ; the caller's, never destroyed
:remote-static-pub <pub record>
:local-ephemeral <EphemeralKeyPair :x pub :h handle>
:remote-ephemeral-pub <EphemeralPublicKey>}
;; after Split:
{:phase :transport :role … :vault … :consumed … :session-id … :closed …
:send {:k <handle> :n 0} :recv {:k <handle> :n 0}}
The initial ck is the public protocol-name hash, so nothing enters
the vault before the first message. initiator and responder draw a
random session id and create the :closed atom. Under the naming
convention that is a read of the random generator, so they keep their
names without !, and their docstrings get an Impure: line.
dh/edh stay enforced. EphemeralKeyPair keeps its own type and now
carries a handle (:h) instead of :d. dh and edh check the record
types exactly as today. The local static goes through one dispatch:
vault/x25519-dh for a handle, impl/x25519-dh for a deprecated
record.
mix-key! becomes a call to hkdf-pair!. The DH output is destroyed
inside that call.
Split runs hkdf-pair! with an empty ikm and the ck handle,
producing the transport handles.
Tracking in consume! consume! gives the op a collector (a
volatile!) that every helper creating a vault entry adds to. Then:
::stale-session-state.ck/k from es-then-ss), plus
the entries the consumed state held that the next state doesn't hold.
This is where a replaced ck/k and the ephemeral at Split get
destroyed.The local static handle is never tracked and never destroyed.
Closing by session, not by state. A state is replaced with every
message, so the state a caller still holds, for example the one bound
at the top of a block, is usually a consumed one. That's why close!
works from any state of a session: the first one, a consumed one,
or one from before an exception. It does two things:
:closed atom. consume! checks it first,
so any state of a closed session throws ::session-closed, which is
clearer than ::destroyed-key coming from the vault;vault/destroy-session! with the session id.Closing twice is a no-op. The per-step cleanup in step 5 still destroys
replaced ck/k and the ephemeral right away, for forward secrecy
during the handshake. Closing by session is the backstop: an entry
that step 5 misses (a bug, a dropped state) stays until the session is
closed, not for the rest of the process.
with-conclave, a macro in the style of with-open:
(session/with-conclave [s (session/initiator my-h peer-kid)]
(let [[s msg1] (session/write-message! s payload)
...]
...))
;; expands to
(let [s (session/initiator my-h peer-kid)]
(try ... (finally (session/close! s))))
The body keeps threading states as usual. close! on the state bound
at the top closes the whole session, even when the body threw after
several messages. Docstring caveats:
::session-closed (the usual with-open rule).close!
wired to the connection's close.java.lang.ref.Cleaner
may not exist on babashka and would fire long after the key should
have gone.Who calls what. Today nothing in signet or its sibling projects
sits above signet.session: the application calls it directly, so
with-conclave and close! are part of the API it sees. They become
invisible once a layer that owns the connection drives the session:
with-conclave;close! when the connection closes, because no
lexical block spans the session.In both cases the caller of that layer never deals with cleanup. Such
a transport layer is out of scope for signet (a crypto library); it
belongs to a consumer or a companion library. The with-conclave
docstring states this division, and the README session section shows
both patterns.
Docstrings follow the naming convention. The ns docstring no longer
claims "no atoms, no global state". write-message!/read-message!
get Impure: lines that name the vault. ::not-a-session-state stays
for maps without a marker.
Done (2026-09-25). session_test.clj runs on handles, with one
deprecated-record test, plus lifecycle tests:
close! from the first state;with-conclave on normal exit and on an exception;trust_test.clj checks that ephemerals are destroyed in the vault, and
has a new race test for handshake writes. Removing any one of seven guards
(winner cleanup, failed-op cleanup, loser cleanup, close by session, the
closed flag, single-use CAS, the local-handle check) fails at least one
test.
The existing tests are adapted to take handles; each keeps one deprecated-record case. The known-answer vectors from phase 0 must pass unchanged on both backends.
New tests:
concurrent-writes-from-one-state…, and
close!), the number of session entries in the vault equals what the
live states hold: 0 before msg1, 2 per side for ck/k plus 1 for the
initiator's ephemeral during the handshake, 2 per side after Split, and
0 after close!. The count comes from vault/session-entry-count.close! from the first state, after a full handshake and some
transport messages, leaves 0 entries;close! twice is a no-op;::session-closed, both from
write-message! and read-message!;with-conclave after several messages still
leaves 0 entries and rethrows the original exception;h and public keys, and pr-str of a state is safe. This is the
same style of check as "no byte array in ex-data".ck handle throws
::destroyed-key when used through the vault.session-ephemerals-dropped-and-zeroed becomes "the ephemeral's
vault entry is destroyed at Split".session-ephemerals-never-registered extends to the vault's public
side and to handles.consume! must fail the invariant test;close! destroy only the given state's entries (not the
session's) must fail the close-from-the-first-state test;Suites: bb test:all (jca, jvm-sodium with parity, bb-sodium) and
bb test:jar.
Done (2026-09-25): README (a sessions section; ephemerals destroyed, not zeroed; nacljc 0.3.0), docs/05 (API shape, "Where the secrets live", forward-secrecy boundary), docs/07 (sessions done), CHANGELOG 0.9.0, CLAUDE.md.
docs/05-noise-kk-session-design.md: the state shape and the
lifecycle.docs/07: mark sessions done, and fix the stale release-plan line
("in 0.7.0 or next (open)").close! and with-conclave, record arities
deprecated, the nacljc bump.docs/05: sessions must be closed, with with-conclave or
an explicit close!, or their secrets stay in the vault.Done (2026-09-25), as chosen: ^{:deprecated "0.9.0"} plus a
docstring pointer on the constructors of secret-carrying records
(key/signing-keypair(!), encryption-keypair(!), signing-private-key,
encryption-private-key, private-key, raw-shared-secret,
ssh/read-private-key, ssh/load-keypair(!)). Record inputs to
sign/box/chain/session keep working. The project's clj-kondo config
exempts signet.* namespaces, so consumers get the warnings and signet
doesn't. ssh/import-keypair! returns a handle and checks that the seed
gives the file's public key.
Accepted (2026-09-25): key/signing-keypair also makes secp256k1 keys,
which have no vault equivalent yet (decision 13: :memory only, not
built), so secp256k1 users get a warning they can't act on until that
lands.
Separate commits, same release:
^:deprecated "0.9.0" and a docstring pointer to the vault
equivalent on the key-record constructors and ! twins, and on the
session record arities.ssh/import-keypair!,
returning a handle (it goes through import-signing-key!, which wipes
the parsed seed).:sodium ephemerals are born in guarded memory.
"Ephemerals are never registered" is restated as "never on the public
side, never in handles, destroyed at Split", and trust_test changes
accordingly.close! closes a whole session from any of its states; the
with-conclave macro closes on exit; vault/session-entry-count lets
callers monitor what's left. No cleanup on garbage collection. Under
:sodium each entry is guarded memory (pages plus guard pages,
counted against mlock limits), which is why this matters.REKEY) and the 2^64 nonce limit: out of scope for
now (settled). Not designed yet; to be discussed separately.with-conclave (settled 2026-09-25). A conclave is a
closed meeting, from the Latin con clave, "with a key": a room locked
with a key. That is a session whose keys live in the vault and are
destroyed when it ends. The docstring states the name's meaning and
that a conclave is not an enclave (where a vault's secrets live,
docs/07 decision 8). Rejected:
with-session: "session" is used everywhere.with-vault-session: would collide with a later "vault session" (the
time a vault is unlocked).with-enclave: "enclave" already names where providers keep secrets
in docs/07, and in the industry (SGX, Apple's Secure Enclave). Keep it
free for a future block that runs against a given enclave.with-parley, with-confab, with-closing-session: considered;
with-conclave conveys a private, locked meeting best.Recorded 2026-09-25; it belongs to "persistence and password unlocking" in docs/07.
crypto_pwhash (Argon2id). Its output is a key, and
it should be a secret whenever the password is passed in as one.
crypto_pwhash_str/_str_verify are for storing login passwords
(server side). Key unlocking doesn't need them: the AEAD tag already
shows a wrong password, and a stored verifier would only give an
attacker a second target for offline guessing.opslimit, memlimit), authenticated as associated data (docs/07
"suites, not knobs"). Costs can then rise later without breaking old
files, and can't be quietly swapped for cheaper ones.(vault/password-key! vault-id password {:salt … :opslimit … :memlimit …})
runs Argon2id inside the provider and returns a handle. Under :sodium
the key is born in guarded memory, since a secret password gives a
secret output. The handle can unwrap the vault master key (unlock!)
or serve as a symmetric key for seal/open, like signet.shared keys;
export is refused, as for session entries. The password itself is the
weak point: take it as bytes that the vault imports into a secret and
then wipes (as import-signing-key! does). A string can't be wiped, so
accepting one is only a documented convenience./proc/<pid>/environ, ps -E), children
inherit them, and System/getenv returns a String. Options, strongest
first:
:agent provider (docs/07). The socket is 0600 with a
peer-credential check. This is the target architecture.hmac-secret or a YubiKey wraps the master key; native
APIs over FFI put the unwrapped key straight into guarded memory.secret-read-passphrase! that reads /dev/tty with echo off
(termios, or readpassphrase(3)) straight into sodium_malloc
memory, so the password is never a String or byte[]. It then goes
into Argon2id as a secret, and the derived key comes out as a secret.
Needs a few libc bindings.LoadCredential or a file on tmpfs, read with an
FFI read(2) into guarded memory.Console/readPassword gives a char[] that can be
wiped (still on the heap).crypto_generichash),
SHA-2, SHA-3 and SHAKE/TurboSHAKE.Phases 3–4 are the bulk: roughly 250 changed lines in session.cljc,
about 80 in vault.cljc, and the test updates.
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 |