Status: released in 0.8.0 (2026-09-24). Implemented:
handles and the vault registry, the two sides, keys born in the vault,
import/export/destroy, the :memory and :sodium providers, sign / box /
chain on handles, and shared keys (signet.shared). Sessions on handles
followed in 0.9.0 (decision 9, docs/08). "Decisions so far" at the end
lists what is settled; the open questions remain open. "Future: enclave
tiers" (2026-09-25) describes how hardware and remote providers fit in.
Application code should never see private or secret keys. It refers to a secret by a handle, and the secret lives in a vault. An operation resolves the handle inside the vault, uses the secret, and wipes any working copy. The secret is never returned. Moving secret bytes across the vault boundary takes an explicit, deliberately named import or export.
This is how serious key management already works:
| System | How it works |
|---|---|
| PKCS#11 and HSMs | Callers get object handles; private keys are marked non-extractable. |
| ssh-agent | Clients ask the agent to sign and never see the key. |
| WebCrypto | CryptoKey with extractable: false, now also for Ed25519 and X25519. |
| Android Keystore, Apple Secure Enclave, cloud KMS | Operations run inside the key's home, and callers only get results. |
Rust secrecy | A Secret<T> whose debug output is redacted; access goes only through expose_secret(). |
Findings from 2026-09-23 [verified]:
cedn/canonical-str of a keypair writes
:d, the private key, in full hex. So does anything that walks a record
as a map (into {}, select-keys, clojure.walk).pr-str
shows :d as an opaque #object["[B" …]. On nbb, which signet's
upcoming ClojureScript side will use, the same call prints the bytes:
#object[Uint8Array 222,173,190,239].Arrays/fill zeroes the one we hold (see
nacljc's README, "Memory and type safety").k, ck) are plain arrays inside state maps.!-twin rule (see "Decisions so far"), pure constructors would
return keys instead of hiding them in the store. That puts more
secrets into return values, and so into code that prints and serialises.signet.impl.*, nacljc work on bytes
internally). The handle layer is the imperative shell, and names its
writes with !.Audience (agreed 2026-09-25). signet protects developers from mistakes and ordinary exposure: secrets printed, logged, serialised, copied or left around, keys handled as raw bytes, nonces reused. Keeping secret values away from application code is the point. Determined adversaries with code execution will still reach the crown jewels; they are not the target audience. The sections below document what they could still do and how a stronger design would stop them, so that the guarantee is not over-read, not as a promise.
What this protects against: copies and accidental exposure. That
covers logs, printing, tap>, the REPL, serialisation mistakes, ex-data,
crash reports and heap dumps. With native protected memory it also covers
swap and stray reads of idle key memory.
What it does not protect against: code running inside the process. Such code can call the vault and use any key it can reach. Only an out-of-process provider (agent, HSM, KMS, Secure Enclave) prevents copying the key, and even then it can still be used. Nor does it defend against a memory-reading attacker at the moment a key is in use.
This section belongs in the README once the model ships, so users do not over-read the guarantee.
What is left after handles, guarded memory (:sodium), the stack wipe
(nacljc 0.3.1) and the planned startup-hygiene helper (core dumps off; see
"Prior art", follow-up 2). The measures so far address accidental exposure
and passive reading of memory, not an attacker who can make the process
do things. Roughly in order of how much they matter:
Code running inside the process: the biggest gap. Sources: a
malicious or compromised dependency, an exposed nREPL, read-string or
eval on untrusted input, a deserialization bug, a Java agent. Such
code can
export-secret: the acknowledgement stops mistakes, not
attackers;mprotect and read guarded memory directly through FFI.A handle is a reference, not a credential (decision 8). Mitigation: an out-of-process enclave ("Future: enclave tiers") prevents extraction; misuse is limited only by that enclave's own policy (user confirmation, rate limits, a PDP such as stroopwafel's).
Other processes of the same user.
jcmd, jattach) loads an agent into a
running JVM, which becomes case 1. It does not use ptrace, so
PR_SET_DUMPABLE does not stop it. Only the JVM flag
-XX:+DisableAttachMechanism does (a startup recommendation; it
can't be set at runtime).~/.m2, LD_PRELOAD or
DYLD_* at startup. And NACLJC_LIBSODIUM /
-Dnacljc.libsodium choose the libsodium library to load, so
whoever controls the environment can point it at a malicious one.
Document it; consider an option to pin the expected path or version.ptrace is blocked by PR_SET_DUMPABLE (Linux) and macOS's hardened
runtime.Secrets outside the enclave by design.
read-message!, unbox and open return byte arrays on
the heap. Protecting keys does not protect data.:memory keys are on the heap; only :sodium
gives guarded memory.export-secret output, deprecated key records.Crash artifacts beyond core dumps.
hs_err_pid*.log, the JVM crash log, contains register contents and
the top of the stack in hex, which may hold key material at the
moment of a crash. Use -XX:ErrorFile=/dev/null, or treat these logs
as sensitive.-XX:+HeapDumpOnOutOfMemoryError writes the whole heap to disk. It
can be switched off at runtime (HotSpotDiagnosticMXBean), so the
helper can do that or warn.Root, the kernel, the hypervisor, physical access read everything; cold-boot and DMA (Thunderbolt) attacks too. Only hardware enclaves or confidential computing (SGX, SEV, TDX) help.
Side channels. libsodium is constant-time, and signet's own
comparisons use MessageDigest/isEqual. Left: Spectre-class cache
attacks between processes or VMs on shared hardware; AEGIS in
libsodium.js is not constant-time on WebAssembly (Q9); power and
electromagnetic analysis with physical access.
Protocol and application misuse.
{:signer …} or
{:root …}.Denial of service. Sessions that are never closed fill the vault;
guarded memory counts against the mlock limit, and when it is
exhausted sodium_malloc fails.
Cheap fixes (candidates for 0.10.0):
-XX:+DisableAttachMechanism, -XX:ErrorFile and the
HeapDumpOnOutOfMemoryError check, besides core dumps and
PR_SET_DUMPABLE.NACLJC_LIBSODIUM risk (and consider pinning).write-message!
docstring.The structural answer to 1 and 2 remains an out-of-process enclave with its own policy.
Agreed 2026-09-25. Hiding the key is not controlling it. Whoever can
call sign with a handle signs anything, as the key's owner, and every
such signature is valid and verified. Key secrecy is then beside the
point. Clojure makes that easy:
alter-var-root, with-redefs);requiring-resolve and eval reach anything;eval;Inside one runtime nothing can make "only this function may sign" enforceable: the process is the smallest unit of trust. Guarded memory stops theft of keys; only a boundary outside the runtime stops their misuse. Four layers bound the signing oracle:
:agent tier
("Future: enclave tiers") has no REPL and no dynamic loading, and a
minimal codebase. It decides what it is willing to sign, not only who
holds a handle. signet signs structured EDN envelopes, not opaque
bytes, so the agent can parse each request and check it against
policy: operation, audience, maximum TTL, amounts, rate limits. The PDP
(stroopwafel's Datalog) can run inside the agent. An HSM that signs
arbitrary hashes cannot do this.ssh-add -c or a hardware wallet). A compromised
app can ask; a human decides.signet.chain / stroopwafel. A compromised app then
yields a narrow capability that expires soon, not "sign anything as
anyone".Hardening inside the runtime lowers the odds and never gives a guarantee:
eval or read-string on untrusted input;:allow/:deny for embedded interpreters;-XX:+DisableAttachMechanism.Consequence for priorities. If signet ever targets determined adversaries, the agent with a policy engine and chain-based attenuation of working keys come before any further in-process hardening. Given the audience above, the order for 0.10.0 and after is:
NACLJC_LIBSODIUM
note, the message-1 replay note): small, and they prevent mistakes.:agent provider, first as ssh-agent-style convenience (the
password never enters the app), with the policy seam left open.Discussed 2026-09-25; parked, deliberately not planned ("maybe for version 25.0.0"). It is recorded because it keeps what makes Clojure powerful, REPL-driven development and live load/eval, instead of banning it.
Idea: load/eval is a database update where the database is the runtime. Treat each eval as a transaction: submitted by an identity, authorized by policy, recorded in a log, replayable.
Prototype sketch (bb first, since bb, nbb and scittle are SCI already):
sign-edn signs the form itself:
{:op :eval :ns app.billing :form (defn …)}, signed by the developer's
key and carrying a capability chain whose caveats say what may change:
namespaces, vars, a time window, dev versus production.(gated-eval envelope):
chain/verify {:root …});{:allowed? false :reason …}.sign for the caller's own capability, never the vault,
eval, alter-var-root or host interop beyond an allowlist. This is
the object-capability pattern: code can use only what it was handed.signet.session) between developer and runtime, mutually
authenticated with forward secrecy, like ssh. It replaces an
unauthenticated nREPL port; every eval inside it still carries its own
authorization.Denis's pieces that fit:
wasmsign (signed WebAssembly
modules), for jars and namespaces at boot;sodium_malloc memory
marked sodium_mprotect_readonly: they need integrity, not secrecy, and
ordinary Clojure code cannot change them;was-not-wasm) if SCI's
shared-heap sandbox is not enough.Honest limit. The gate and the trusted host share the process. Code that enters through an ungated path (a deserialization bug, the Attach API, a native memory-safety bug) can switch the gate off. It depends on a verified boot, closing the other entry points, and a small static trusted host. It moves the risk from "any REPL user or loaded library can do anything" to "a bug in a small trusted host": a real improvement, but not the hard boundary of an external signer. The two combine: the gated runtime runs the application; the agent outside decides what gets signed.
Scope: a research project, likely its own library on top of signet.
{:type :signet/key-handle
:kid "urn:signet:pk:ed25519:…" ; the public key; identifies the key
:vault :default} ; which vault holds the secret
A handle contains nothing secret. It is safe to print, log, serialise, compare and send. For Ed25519 and X25519 the kid is the public key, so a handle also gives the public key without touching the vault.
The vault holds the secret material, indexed by kid. It evolves from today's key store:
lookup. It never holds a
secret.A default vault exists for convenience. Every function also accepts an explicit vault, as the key store functions accept an explicit store today.
Operations the vault must support, over a handle:
| Operation | Returns |
|---|---|
sign handle msg | signature |
dh handle their-public-key | shared secret, kept in the vault as a new handle (see "Derived secrets" and "Shared symmetric keys") |
open-box handle boxed | plaintext |
public-key handle | public key |
generate! algorithm | handle |
import! secret-bytes | handle |
export handle | secret bytes (deliberately named, documented as dangerous) |
destroy! handle | nil; wipes and removes the secret |
Providers, in the order they would come:
| Provider | Where the plaintext lives | Notes |
|---|---|---|
:memory | JVM heap, inside the vault only | Level 1 below. Default on the JCA backend. |
:memory-encrypted | heap, encrypted under a per-process vault key; decrypted into a scratch buffer just before use, wiped straight after | Level 2. A fallback where native memory is not available. |
:sodium | libsodium secure memory: sodium_malloc (guard pages, mlock), sodium_mprotect_noaccess between uses, sodium_free zeroes it | Level 3. Never on the JVM heap. Needs nacljc 0.2.0. |
:webcrypto | browser, non-extractable CryptoKey | For signet's ClojureScript side. |
:agent, :keychain, :hsm | out of process | Level 4. Later. |
:pkcs11 | any PKCS#11 token (SoftHSM for tests; YubiHSM, CloudHSM, smartcards) | Level 4. One provider for every existing HSM; see "Future: enclave tiers". |
:tpm, :piv, :secure-enclave, :fido2 | hardware on this machine | Level 4. Slow and narrow: unlock and endorse only (see "Future: enclave tiers"). |
| Level | Protects against | Cost |
|---|---|---|
| 1. Handle and in-process vault | accidental exposure through printing, logs, serialisation and ex-data; only vault code touches bytes | small: an API change |
| 2. Encrypted at rest in memory | also heap dumps and swap catching an idle copy. But the vault key lives in the same process, so this mostly shortens the plaintext's exposure window | small, plus one AEAD call per use |
| 3. Native protected memory | also GC copies (the plaintext never touches the Java heap), swap (mlock), and stray reads of idle keys, which crash instead of reading them | nacljc work; one mprotect system call per use |
| 4. Out of process | copying the key at all | a provider per system |
Level 3 is preferred over level 2 wherever it is available. Encrypting memory with a key held in the same memory is weaker than memory that the operating system protects.
signet.key)generate-signing-key! and generate-encryption-key! create the key
inside the vault and return a handle.import-signing-key! and import-encryption-key! take secret bytes
(key files, backups, SSH import) and return a handle. The docstring says
"handle with care". These are the only way secret bytes enter.export-secret returns secret bytes. It is the only way they leave, it
is grep-able, and its docstring warns.destroy! handle.public-key, encryption-public-key,
kid, lookup) stay pure and take handles or public keys.:d stop being public API. They remain an
internal representation inside the :memory provider.signet.sign, signet.chain)(sign-edn handle payload [opts]): the handle replaces the keypair.
It is still impure (it reads the clock and draws a request ID), needs no
!, and has an Impure: docstring line.(sign-edn! payload): the convenience that uses the default identity, or
generates one and sets it as default (decided, option b).chain/extend takes handles for the root. An open token's :proof is an
ephemeral private key by design, since a bearer token carries it.
Locally it is a handle. Sending the token exports it, and the export is
explicit and documented as a bearer credential. A token prints with
:proof redacted.signet.encryption)(box handle recipient-public-key plaintext [opts]).(unbox vault boxed [opts]): the vault chooses the recipient key from the
box's :to kid. This is simpler than today's list of candidate keypairs.
A handle or a set of handles still limits the choice explicitly.signet.session)write-message!, read-message!) destroys the
entries it no longer needs.close! and
with-conclave to end a session.dh / edh outputs, HKDF outputs and session keys are secrets too. The
rule: a secret derived from a vault secret stays in the vault and is
returned as a handle. The pure core (impl, mix-key!) still works on
bytes, inside the provider.
Proposed 2026-09-23. Two parties that talk often want to agree once, keep the result, and then encrypt, decrypt and authenticate messages cheaply, without a full box or session each time. The derived key stays in the vault under a handle, like every other secret:
(def h (vault/shared-key! my-handle their-pub {:context "app/v1"}))
(vault/seal h plaintext) ; → EDN box: per-message salt, directional key
(vault/open h boxed) ; → {:valid? … :plaintext …}; never throws
(vault/mac h msg) ; → tag
(vault/verify-mac? h msg tag) ; → boolean; never throws
(vault/destroy! h)
Rules:
shared-key! runs it through
HKDF with a context (both kids, a purpose label, and the caller's
:context) and derives separate keys per purpose: one for
encryption, one for MAC. It also derives separate keys per
direction, as box v2 does, so a message cannot be reflected back to
its sender as if the peer had sent it. The raw output is wiped
immediately.seal derives a one-message key
from a fresh random salt, as box v2 does. It is safe at any volume,
needs no shared state, and survives restarts.signet.session already does. Sessions remain the tool for that.mac authenticates between the two parties, but
proves nothing to a third party about which of them wrote a message. The
API says mac / verify-mac?, never sign, and its docstring says so.
For non-repudiation, use sign-edn.signet.session.kid = HKDF-Expand(PRK, "signet/shared/v1/kid", 32), written
urn:signet:shared:<base64url> ("shared" rather than "sk", which reads
like "secret key"). Every holder computes the same id, no one else can,
and it reveals nothing about the other derived keys (independent HKDF
outputs). It names the relationship as a whole; the encryption keys
underneath stay directional. Not a plain hash of the key (see decision
16), and not a hash of both parties' kids, which anyone could compute
and which would show who talks to whom. Locally the vault also keeps a
private index from (my kid, peer kid, context) to the handle; that
index never leaves the vault.export-secret is
the only way out, as for any secret.Relation to what exists: key/raw-shared-secret returns the bare DH
output today. That is fine as a primitive, but it is not a key to use
directly. shared-key! is the safe, vault-resident way to get one.
Open: the AEAD for seal. The default is ChaCha20-Poly1305 with a
per-message HKDF key, as in box v2. AEGIS-256 could replace that
construction, and key commitment belongs here too (Open questions 9–11).
seal's output names its suite exactly as a box does.
register! no longer sets defaults (decided). The default identity is
chosen explicitly: set-default-signing-key! with a handle, or
sign-edn!'s first use.
nacljc would add a native secret type, created and held in
sodium_malloc memory:
(secret-generate n), (secret-import bytes): return an opaque secret
object. Importing copies the bytes in and wipes the caller's array.ed25519-sign, x25519, hkdf-sha-256, the AEAD. The bytes are
never copied to the Java heap. Access is mprotected read-only for the
duration of the call, and no-access otherwise.(secret-destroy! s): sodium_free, which zeroes the memory; later use
throws.signet's :sodium provider is built on this. The JCA backend gets the
:memory or :memory-encrypted provider.
!: generate-signing-key!, import-…!,
destroy!, sign-edn!.export-secret has no !, because it reads, but its name says what it
reveals.Impure: … and Throws ex-info {:type …} when …
in its docstring, per the naming convention (CLAUDE.md, "Naming: purity,
! and exceptions").pr-str, str, cedn/canonical-str, ex-data and printed exceptions
contain no secret bytes (hex or decimal), and serialising a vault secret
throws.export-secret.destroy!, the handle fails with a typed error, and
on :sodium the memory has been freed through sodium_free.dh, HKDF and session keys come back as
handles.:memory, :memory-encrypted and :sodium, like the backend
comparison today.:memory provider behind the provider protocol. Also:
! twins, write-message! and read-message!,
Impure: and Throws: docstrings;sign-edn replaced by sign-edn!.:sodium provider on
them.:memory-encrypted where native is unavailable, :webcrypto
for signet's ClojureScript side, then agent, keychain and HSM providers.Proposed 2026-09-23. A quantum computer running Shor's algorithm breaks X25519, Ed25519 and secp256k1. It does not meaningfully break the symmetric layer: Grover's algorithm at best halves a symmetric key's effective strength, so the 256-bit-key AEADs (ChaCha20-Poly1305, AEGIS-256) keep about 128-bit security, and HKDF-SHA-256 and HMAC stay. (AEGIS-128L's 128-bit key would drop to about 64 bits: one more reason to prefer AEGIS-256.) So a post-quantum suite keeps its AEAD component and replaces the signing and key-exchange components. But it still changes the protocol, for the reasons below.
| Library | Key exchange | Signatures |
|---|---|---|
| libsodium 1.0.22 | ML-KEM-768; X-Wing (ML-KEM-768 + X25519 hybrid, "the recommended KEM for most" uses per its ChangeLog) | none |
| JDK 25 | ML-KEM (SunJCE) | ML-DSA (SUN) |
| Bouncy Castle 1.86 | ML-KEM | ML-DSA |
X-Wing sizes (libsodium headers): public key 1,216 bytes, ciphertext 1,120 bytes, secret key 32 bytes (a seed), shared secret 32 bytes. ML-KEM-768: public key 1,184, ciphertext 1,088. ML-DSA-65: public key 1,952, signature 3,309.
shared-key! (see "Shared symmetric keys") derives the same key
on both sides with no exchange. With a KEM, one side must first send
the other a ciphertext: a small handshake, whose result is then kept
in the vault as before.ss token is a static-static DH. Post-quantum Noise
variants are different handshakes, not a swapped primitive.lookup rebuilds the key from the kid alone.
PQ public keys (1,184–1,952 bytes) are too large for that. PQ kids are
a hash of the key, e.g. urn:signet:pk:xwing:<base64url(SHA-256(pk))>,
and the full key comes from somewhere else: the vault, a directory,
or the message itself. That affects lookup, box's kid slots, and
chain blocks' :next-key. 0.8.0's lookup design should allow for
kids that do not contain their key, even before any PQ suite exists.Large keys are fine once per relationship. A directory is an optimisation, not a requirement.
SHA-256(key) = kid, and a substituted key fails. That gives the same
guarantee as today's "the kid is the key", in two steps. Which kids are
trusted is still the valid/verified question, and later a policy
decision point's.shared-key!, kept in the vault) makes later
messages as small as today's. A PQ key exchange plus stored shared keys
gives both post-quantum protection and small messages.Exceptions to "once per relationship":
Directories help with first contact and avoid re-sending keys. They can also become a trust anchor (for example with key transparency), but that is a policy question, not a cache.
Each suite still names the whole protocol, because the header layout, kid resolution and sender authentication change with it:
| Suite | Key exchange | Sender authentication | KDF | AEAD |
|---|---|---|---|---|
| box v2 (today) | X25519 static-static | implicit in the DH | HKDF-SHA-256 | ChaCha20-Poly1305 |
| box v3 (proposed) | X25519 static-static | implicit in the DH | HKDF-SHA-256 | AEGIS-256 + key commitment |
| box v4 (post-quantum) | X-Wing encapsulation | hybrid signature (Ed25519 + ML-DSA) or an authenticated KEM | HKDF-SHA-256 | AEGIS-256 (or ChaCha20-Poly1305) |
| envelope v2 | — | hybrid signature (Ed25519 + ML-DSA) | — | — |
The AEAD column changes independently of the PQ question: the same AEADs serve classical and post-quantum suites.
First step: box v4's key exchange with X-Wing. It is the urgent part, libsodium 1.0.22 has it today, and the JDK has ML-KEM for a JCA provider. Hybrid signatures come after, as they need ML-DSA through nacljc (libsodium has none), or through the JDK or Bouncy Castle.
Raised 2026-09-23, not for 0.8.0. Kids are global, self-certifying identifiers, but people want to say "Bob". A later version should let callers find keys by name, and do it in a way that fits trust and policy rather than a flat lookup table:
Raised 2026-09-23, not for 0.8.0; to be discussed. An encrypted vault becomes much more useful once it can be saved (a persistent atom, or an SSH-key-style file), with only ciphertext ever on disk. Unlocking it with a human password is a long-standing weak spot in many designs. Topics:
crypto_pwhash; not in the JDK, but in Bouncy Castle). Its
parameters are stored with the vault so they can be raised later. With
low-entropy passwords, this step is what resists offline guessing of a
stolen file.:sodium provider).Researched 2026-09-25 in Frank Denis's (jedisct1) repositories. The question was whether he built a handle or enclave layer himself, or only supplied the pieces. Answer: the pieces, plus single-purpose tools that each assemble a few of them. There is no general layer where code holds only references [source].
The principle is stated, but not built. libsodium-doc
(helpers/memory_management.md): sodium_mprotect_noaccess "can be
used to make confidential data inaccessible except when needed for a
specific operation". That is nacljc's no-access-outside-calls model and
this vault. In his own projects, sodium_mprotect_noaccess appears only
in the docs, never in code [source: GitHub code search, owner jedisct1].
The same page recommends two things we don't do yet:
sodium_stackzero() after a batch of sensitive operations, since
secrets are copied into registers and onto the stack during use even
when they are stored in locked pages;setrlimit(RLIMIT_CORE, …)) outside
development, plus encrypted or disabled swap and no hibernation.
Memory locking is "defense-in-depth … not a complete solution".minisign (src/minisign.c, get_line.c) is password unlocking end
to end:
sodium_malloc memory;sodium_malloc memory;sodium_free.This is option 3 in docs/08's password notes ("read the password into guarded memory"). It is a short-lived command-line process, with no no-access protection between uses.
turbocrypt (his newer file tool) uses our two-layer key design: a
random key in a key file, optionally protected with an Argon2 password.
"Changing a key file's password doesn't change the encryption key
inside it" (its docs/safety.md). It also uses fixed suites (Argon2,
AEGIS, HCTR2, TurboSHAKE) "with no insecure options", as in "Suites"
here.
libhydrogen has a key exchange "based on the Noise protocol" (N,
KK, XX, NK), but returns the session keys to the caller as plain arrays
(hydro_kx_session_keypair { rx, tx }). signet 0.9.0 keeps them in the
vault instead.
blobcrypt's example keeps the key and stream state in
sodium_malloc memory. encpipe takes passwords on the command line
or from a file: convenience over hygiene.
cpace and spake2-ee are his password-authenticated key exchanges (PAKEs) on libsodium. They are the natural choice if signet ever needs sessions authenticated by a password rather than static keys.
Follow-ups from this:
sodium_stackzero after each operation that reads a
secret~~ Done in nacljc 0.3.1 / signet 0.9.1: 16 KiB after every
operation that opens a secret, about 0.2 µs per operation.setrlimit(RLIMIT_CORE, 0) over FFI), and a README note on startup
hygiene: no core dumps, encrypted or no swap, no hibernation.Discussed 2026-09-25, after 0.9.0. Not planned for a release yet.
The observation. Hardware enclaves are slow and narrow.
Network HSMs (AWS CloudHSM, KMS) are faster but still costly per call, so
they push bulk work back to the application: KMS GenerateDataKey hands
out the plaintext data key, and all message and stream encryption happens
in ordinary heap memory. The root key is protected; the working keys are
not.
signet's handle model is PKCS#11's in spirit:
| PKCS#11 | signet |
|---|---|
| object handle | KeyHandle |
| slot / token | vault id → provider |
C_Sign, C_DeriveKey (derived keys stay in the token) | vault/sign, shared-key!, hkdf-pair! |
CKA_SENSITIVE, CKA_EXTRACTABLE=false | session entries refuse export; export-secret needs the acknowledgement |
| session objects | session entries |
C_Login | unlock (planned) |
The difference is that signet offers fixed suites instead of a menu of
mechanisms (see "Suites"). It can afford to keep every operation behind
handles because its fast enclave is in-process: libsodium guarded memory
makes each operation cheap. That boundary is weaker than hardware. Guard
pages, mlock and no-access-outside-calls stop accidental reads,
over-reads, swap and core dumps, but not an attacker who runs code inside
the process.
The design: three tiers.
| Tier | Holds | Operations |
|---|---|---|
| Hardware root (TPM, PIV card, Secure Enclave, FIDO2 key, HSM) | the root identity, unlock factors | rare: unlock, endorse |
Local guarded enclave (:sodium) | vault master key, working keys, session keys | everything hot |
| Heap | public keys, handles, ciphertexts | no secrets |
hmac-secret, YubiKey
challenge-response, or a keychain release. Then no hardware calls until
the next unlock. Each factor wraps its own copy of the master key (the
layering in "persistence and password unlocking"; password input
options are in docs/08). Hardware algorithms such as P-256 appear only
in the wrapping suite, never in the working API.chain/verify {:root hardware-kid} (verified = the expected root).What the provider protocol needs for this.
::unsupported-operation), with no silent fallback.-with-material. An
out-of-process or hardware enclave never lends its material, so sign,
ECDH/DH, unwrap-into-another-enclave, HKDF and AEAD must be methods the
provider implements (docs/08 phase 2 already anticipates this).urn:signet:pk:p256:…, for hardware roots
and wrapping keys.Candidate providers, in a plausible order:
:agent: a separate process over a Unix socket, like ssh-agent (the
password never enters the application).:pkcs11: binds any PKCS#11 module over FFI, which makes every
existing HSM and smartcard a signet enclave. Test against SoftHSM.:secure-enclave / :tpm / :fido2: unlock factors and endorsement
roots.:memory-encrypted~~ Not in 0.8.0 (decision 11). It becomes
worth more with persistence and password unlocking: to be discussed
(see "Future: persistence and password unlocking").:memory provider only (decision 13).:unsupported-suite. Caveat: libsodium's software AES forces lookup
tables on WebAssembly (softaes.c: #if defined(__wasm__) … #define FAVOR_PERFORMANCE), so libsodium.js AEGIS is not constant-time in the
browser today [source]. Likely to be fixed upstream. Once it is
constant-time on every provider signet supports, v3 can become the
default, with v2 still accepted.seal).
Poly1305 is not key-committing: one ciphertext can be valid under two
keys. That matters most for unbox with several candidate keys (no
:to slot). Proposal: a short commitment in the header, e.g.
:commit = HMAC-SHA-256(k_msg, "signet/commit") (truncated), checked
in constant time before decrypting. It works on every backend,
independently of AEGIS. It changes the header, so it is a new suite
(v3 for ChaCha, or folded into the AEGIS suite).alg
chosen by the message: alg: none, key confusion) versus PASETO, age,
WireGuard and TLS 1.3 (few fixed suites, versioned):
:v
is the suite id; there is no separate :alg knob per primitive.unbox takes an allowlist
of suites (default: the current, non-deprecated ones). Unknown or
disabled gives {:valid? false :error :unsupported-suite}. The
message names its suite but never decides whether it is acceptable.:suite, a function lists the
provider's supported suites, and a default changes only in a release
whose CHANGELOG says so. Old suites stay readable while accepted,
then are deprecated, then refused, each step documented.chain/verify? Which provider comes
first: :agent or :pkcs11?! means the call writes state that outlives it.
Reads such as the clock, randomness and the environment are documented
and get no !. ! never means "may throw". Impure:, Throws … and
Never throws … docstring lines; check- for validators.fn returns the registerable elements, extras under
:signet/register. fn! registers them and returns the rest. When the
element is the primary result, fn! returns it as well.sign-edn: the pure version always takes an explicit key or handle.
The key-less convenience exists only as sign-edn!.raw-shared-secret: pure only, with no ! twins.register! only registers; it no longer sets defaults.KeyHandle record,
{:type :signet/key-handle :kid … :vault <vault-id>}. Operations check
the type, so a key record or a plain map cannot stand in for a handle.
A handle is an immutable value, and the vault id is a name (a
keyword), not an object reference. So a handle can be printed,
serialised, sent, and survives restarts.:vault at call time; an unknown id throws ::unknown-vault,
never a fallback. :default is the default vault id, and
explicit-vault arities exist for tests and isolation. The first
enclave is the current runtime (:memory). Later ones (:sodium, an
agent, a keychain, WebCrypto, an HSM) need no API change: a handle
naming them routes there. Moving or copying a secret to another enclave
yields a new handle with the new vault id; the old handle keeps
referring to the old copy until it is destroyed. The same kid may live
in several vaults.
:memory, the enclave's own policy later
(an agent asking the user, or a policy decision point such as
stroopwafel's). That is where "authorized" eventually meets the vault.(export-secret h {:i-understand :exposes-secret}). Without it the
call throws. It is cheap enforcement that stands out in grep and in
review ("take the gun away").:memory-encrypted is not in 0.8.0. It stays a listed provider,
to be reconsidered together with persistence and password unlocking.:proof is held locally as a handle and exported explicitly when the
token is sent. A possession-proof redesign belongs with the
post-quantum chain design.:memory provider only (Bouncy Castle,
JVM only; libsodium has no secp256k1; the keys serve wallet and MPC
interop).seal) are stateless only in 0.8.0: a fresh random
salt per message, with counters left to sessions. The AEAD is
ChaCha20-Poly1305 with key commitment, the same construction as box
v3's ChaCha variant; AEGIS-256 follows as a suite where providers have
it.(lookup kid) answers from it, falling back to parsing the kid
(which works for today's 25519 kids).(handle kid) answers only if the secret side has the key. Bytes
leave only through export-secret with the acknowledgement.register!. A
key carried in a message is registered only after its kid is verified
(SHA-256(key) = kid) and only when the caller asks, so untrusted
input cannot grow memory."signet/shared/v1/kid"),
not a plain hash of the key. A plain hash of a key derived from a
password would let an observer test guesses offline. Independent HKDF
outputs keep the id unrelated to the encryption and MAC keys. And
unlike a hash of both kids, only holders can compute it, so it does
not reveal who talks to whom.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 |