Fixes from a review of signet (docs/review-devin-20260926.md), at the
seams between the vault, shared keys, key records and the two backends.
Each fix has a regression test (test/signet/seams_test.clj) shown to
fail before it.
unbox rejected valid boxes when the vault held a shared key.
vault/handles lists shared keys, and unbox tried them as recipients: in
about half of all runs (depending on hash-set order) a correct box came
back {:valid? false :error :malformed}. Box and unbox now consider
identity keys only (Ed25519, X25519). vault/x25519-dh and
vault/public-key throw :signet.vault/wrong-algorithm for a shared
key, and so does box with a shared-key handle as sender. New
vault/identity-key?. Since 0.8.0.(chain/close sealed-token content) threw a raw
ArrayStoreException; it now throws ::sealed, like (close token).export-token copies
the proof's seed out; close of that sendable token destroyed nothing,
so the vault's copy could still extend the "sealed" chain. close now
destroys the vault's copy of that key (in every vault) and wipes the
seed array.ArithmeticException for y = 1 and accepted other invalid points. Both
throw :signet.impl/invalid-public-key, and low-order X25519 is
:signet.impl/low-order-point on both. A parity check covers 455
inputs.key/kid->public-key accepted malformed kids (a 5-byte key, say)
that lookup refused; one parser now, throwing ::malformed-kid.sign/verify accepts a vault handle.session/initiator/responder (::bad-key-type, e.g. a secp256k1
peer), shared/shared-key! and vault/register-public-key!.key/register! of a secp256k1 private-only key silently stored
nothing; it throws ::no-kid.hkdf-sha-256 refuses lengths beyond 8160 bytes (RFC 5869);
:signet.impl/bad-length.::destroyed-key,
::unknown-vault) through, instead of reporting them as
::authentication-failed.signet.cli.verify): a flag without a value is a usage
error.unbox's :from accepts key records and handles, not only kids.bb release-check requires a dated ## X.Y.Z (YYYY-MM-DD) heading.chain/close (the sendable form), extend-chain (the old
proof stays usable), create-third-party-block (impure with a handle).chain/verify errors as keywords and a uniform :blocks shape
(a breaking change for consumers that read the strings).verify-edn rejecting a wrong :type early.with-secret keeps the body's exception when destroying the secret
also fails.Pure., Impure: <what it reads or writes>, and Throws ex-info {:type ::x} when …. Functions
that take a key record or a handle say which one reads the vault.
Docstrings only, except as follows.encoding/hex->bytes: the odd-length error now carries
{:type :signet.encoding/bad-hex}.! only for writes):
ssh/bad-key! is throw-bad-key, shared/meta! is checked-meta.#inst values, and so signed envelopes' timestamps, no longer depend
on the JVM's default locale. Before, under a locale with non-Latin
digits (for example ar-EG), cedn wrote #inst digits in that script,
so signatures made on such a machine verified nowhere else.java.sql.Date/Time in a payload now fail with cedn's
:cedn/unsupported-type instead of a raw exception.:sodium provider), the stack the C code used is
wiped with sodium_stackzero, as libsodium's memory docs recommend.
About 0.2 µs per operation. No API change.Sessions on vault handles (docs/08-sessions-on-handles-plan.md).
signet.session keeps its secrets in the vault. The chaining key,
handshake key, transport keys and ephemerals are vault session entries,
in the vault of the local static key; a state holds only handles and
public values, so printing it shows nothing secret. Under the :sodium
provider they stay in libsodium's guarded memory.initiator / responder take a vault handle as the local static
key, and the peer as a public key or a kid (::unknown-peer if it cannot
be resolved). Key records still work, deprecated; their session secrets
go to the :vault option's vault (default :default).write-message! / read-message! destroys the secrets
only the consumed state needed; a failed or racing call destroys what it
created, so a forged message leaves nothing behind.:local-static, :local-ephemeral.initiator / responder draw a random session id (documented as
impure: a read of the random generator).secret-split, secret HKDF salt).session/close!: destroys every secret of a session from any of its
states (even the first); any later use throws ::session-closed.session/with-conclave: closes the session when the block exits,
also on an exception. A clj-kondo lint-as export ships with the jar.vault/session-entry-count: how many session secrets a vault holds,
to monitor sessions that were never closed.handles, not exportable: ::not-exportable), impl/split-material.:sodium provider moves byte material it adopts into guarded
memory, so it only ever holds secrets.ssh/import-keypair!: imports an OpenSSH Ed25519 private key file
into a vault and returns a handle; the seed must give the file's public
key (:public-key-mismatch otherwise).Secret-carrying key records are deprecated in favour of vault handles.
Nothing is removed; the functions below carry ^{:deprecated "0.9.0"}
(clj-kondo warns) and a docstring pointer to the replacement:
key/signing-keypair, key/signing-keypair! → vault/generate-signing-key!
/ vault/import-signing-key! (secp256k1 has no vault equivalent yet)key/encryption-keypair, key/encryption-keypair! →
vault/generate-encryption-key! / vault/import-encryption-key!key/signing-private-key, key/encryption-private-key,
key/private-key → a vault handlekey/raw-shared-secret → signet.shared/shared-key!ssh/read-private-key, ssh/load-keypair, ssh/load-keypair! →
ssh/import-keypair!Key-record inputs to sign, box, chain and session keep working. Public-key records and functions are not deprecated.
signet.session is now wire-compatible with Noise. The handshake
mixed the prologue into the transcript hash after the static public
keys; the Noise spec (§5.3) mixes it first. Handshake messages
therefore matched no other Noise_KK_25519_ChaChaPoly_SHA256
implementation. Breaking: a 0.9.0 peer cannot complete a handshake
with a 0.8.0 or earlier peer (the first message fails authentication).
Transport messages were not affected.test/signet/noise_vectors_test.clj), on every backend.Secrets by reference: code holds vault handles, never secret bytes
(docs/07-secret-handles-design.md, decisions 7–16).
signet.vault:
KeyHandle records naming a key and its vault; a vault registry with
:default.lookup and register-public-key!,
handle and handles.generate-signing-key!,
generate-encryption-key!.import-signing-key! and import-encryption-key! (they wipe the
caller's array); export-secret (requires
{:i-understand :exposes-secret}); destroy!.sign, public-key, algorithm.default-signing-key,
set-default-signing-key!, ensure-default-signing-key! (race-safe).:memory (heap, inside the vault, lent copies wiped) and
:sodium (signet.vault.sodium, on nacljc 0.2.0 secrets in libsodium
guarded memory; derived secrets stay there). default-provider picks
:sodium on the libsodium backend.sign/sign and sign-edn take handles. box
takes a handle sender; unbox takes a handle, a set of handles, or a
vault id. chain/extend takes a handle root.:proof is a vault handle;
chain/export-token (sendable form, needs the acknowledgement),
chain/import-token! (refuses a mismatched proof), chain/discard!;
close destroys the proof.signet.shared:
shared-key!: both parties derive the same key and kid with no
exchange.seal and open: directional, key-committing, with a per-message salt.mac and verify-mac?: directional.key/set-default-signing-keypair!, key/set-default-encryption-keypair!,
key/default-signing-keypair, key/default-encryption-keypair,
key/clear-defaults!, key/ensure-default-signing-keypair!.
sign/sign-edn! and key-less chain/extend use
vault/ensure-default-signing-key! / vault/default-signing-key.:proof is a handle, not the seed. Use
export-token to send one.bb check-not-released, run by test:jar, refuses to install a version
already on Clojars. It prevents a local build shadowing a published jar
in ~/.m2.Dependency update only; no API or behaviour change.
cedn/check,
cedn.error/throw-*, with the old names deprecated), so the canonical
bytes, and therefore signatures, are unchanged. signet uses none of the
renamed functions.These two should have been released before signet 0.7.0; 0.7.1 catches up.
The first release on Clojars (com.github.franks42/signet). Earlier
versions were git tags only. There are breaking changes from 0.6.0; see
"Changed" below and the README's "Compatibility".
signet.impl.sodium, through
nacljc 0.1.0), selected with
-Dsignet.backend=sodium or SIGNET_BACKEND=sodium. It is
byte-identical to the JCA backend (a parity check runs in CI), and the
full suite also passes on babashka.signet.encryption): self-describing EDN boxes with
directional keys (a box cannot be reflected), a per-message HKDF salt
(safe random nonces at any volume), optional kid slots and an
authenticated :aad slot. unbox never throws on malformed input.
Design: docs/06-box-v2-design.md.key/signing-keypair!, key/encryption-keypair!,
ssh/load-keypair!. Also key/ensure-default-signing-keypair! and
sign/sign-edn!.:d "<redacted>", never secret bytes.signing-keypair,
encryption-keypair, signing-public-key, signing-private-key,
encryption-public-key, encryption-private-key, public-key,
private-key, hex->kid, raw-shared-secret, ssh/load-keypair) no
longer register keys. Only functions ending in ! write the key store or
the defaults.set-default-signing-keypair! or
ensure-default-signing-keypair!.sign/sign-edn always takes a key. The key-less arity is now
sign/sign-edn!.session/write-message and session/read-message are now
write-message! and read-message!, since they consume their state.::stale-session-state, so a nonce is never reused and a replayed
message is refused.verify-edn and chain/verify never throw, count
expiry against :valid?, and take :signer or :root for
:verified?, and :now for deterministic expiry. The raw signature
check is now :signature-valid?.key/lookup and key/kid no longer register anything.dh is for static keys and edh for ephemeral ones; mixing them up
throws.key/as-public-key, as-encryption-public-key and
as-encryption-private-key (added in 0.7.0 snapshots; use the now-pure
public-key, encryption-public-key, encryption-private-key).read-public-key and read-private-key refuse anything but an
unencrypted Ed25519 key, with ::bad-ssh-key and a :reason.ssh-rsa line or a passphrase-protected key was parsed
into a wrong key without error.assert, which can be compiled out, is replaced by ex-info.:type: for example
::no-private-key, ::sealed, ::no-default-signing-keypair,
::authentication-failed, ::wrong-message-phase.::authentication-failed
on every backend.! means the call writes state that outlives it. Impure reads are
documented, not banged, and ! never means "may throw". Docstrings state
Impure: …, Throws … and Never throws …. See CLAUDE.md.
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 |