Security
Cryptography inventory
Every cryptographic primitive in the UniNet Rust workspace, the crate and version behind it, which call sites a running node actually reaches, and the five hand-rolled constructions deleted on 2026-08-27 for being unsound.
What this inventory is
This page lists the cryptographic primitives that appear in the UniNet Rust workspace, the library and resolved version behind each one, the call sites that use it, and the property it is relied on for. It also lists what used to be in the workspace and is not, with the specific defect in each case.
The problem it addresses is that a cryptography section written from an architecture document describes intent. A reader who then opens the source finds a different set of primitives, and every other claim on the site becomes suspect. What follows is derived from the crates rather than from the capability document, and where the two disagree the code is what is described here.
One distinction runs through the whole page: a primitive being present and tested is not the same as a binary calling it. Several entries below are exercised only by the crate's own tests, and the column says so.
The inventory
Versions are the ones resolved in Cargo.lock; the declaring manifest — the workspace root for everything except argon2, which bin/uninet-node/Cargo.toml declares — states the major or minor requirement.
| Primitive | Crate and version | Where it is called | What it protects |
| --- | --- | --- | --- |
| Ed25519 (RFC 8032) | ed25519-dalek 2.2.0 | crates/crypto/src/signing/ed25519.rs; PBFT prepare and commit votes, view-change certificates, block headers, signed transactions, AO campaign votes, identity delegation grants, bridge validator attestations, and session tokens, which the caller signs with their own key and the node verifies in bin/uninet-node/src/api/auth.rs | Authenticity of every message one party asserts to another |
| secp256k1 ECDSA, Keccak-256 prehash, with public-key recovery | k256 0.13.4 | crates/crypto/src/signing/secp256k1.rs only. Reachable from outside solely through the SignatureScheme dispatcher in signing/mod.rs, which no caller invokes with Secp256k1; recover has no caller at all | Nothing today. It is the EVM-compatible signature scheme the bridge would need, not one any running code path uses |
| X25519 ECDH | x25519-dalek 2.0.1 | crates/crypto/src/encryption/asymmetric.rs; the UNP gateway handshake in bin/uninet-node/src/api/unp_gateway.rs; a fresh ephemeral exchange per onion layer in crates/net/src/onion/keys.rs, on a path a node does not reach — see below | Establishment of session and per-layer keys without transmitting them |
| AES-256-GCM | aes-gcm 0.10.3 | crates/crypto/src/encryption/symmetric.rs; UNP tunnel frames in both the node gateway and bin/uninet-cli/src/unp.rs; envelope encryption under EncryptedBackend and the node's disk_store.rs; onion packet and reply layers | Confidentiality and integrity of payloads, with additional data bound into the tag |
| ChaCha20-Poly1305 | chacha20poly1305 0.10.1 | crates/crypto/src/encryption/symmetric.rs. No call site outside that file | Nothing today. It is an available alternative AEAD, not a deployed one |
| BLAKE3 | blake3 1.8.3 | Hash::compute and Id::from_hash in uninet-types, and through them identity ids, element ids, block and state hashing, storage, the VM, AO and multichain | Content addressing and integrity of internal identifiers and commitments |
| BLAKE3 derive_key | blake3 1.8.3 | The UNP session key in the node gateway and the CLI; onion layer key, nonce and filler derivation; the handshake session key and both ratchets in crates/crypto/src/handshake/unp_handshake.rs, which nothing outside the crypto crate calls | Domain separation — each derived key is bound to a context string and cannot collide with a key derived for another purpose |
| SHA-256 | sha2 0.10.9 | crates/crypto/src/hash/sha256.rs, Hash::compute_sha256, the session-token signing preimage in bin/uninet-node/src/api/auth.rs | Digests where an external format requires SHA-256 rather than BLAKE3 |
| Keccak-256 | sha3 0.10.8 | crates/crypto/src/hash/keccak.rs, exposed by the node's hash endpoint in api/crypto.rs; the secp256k1 prehash; Ethereum address derivation and ABI function selectors in crates/interop/src/evm/, which no binary calls | Byte-level agreement with Ethereum |
| Argon2id | argon2 0.5.3 | bin/uninet-node/src/api/identity.rs at 19 MiB, t=2, p=1, 32-byte tag, 16-byte salt; passphrase-derived node master key in bin/uninet-node/src/keys.rs | The stored authentication record, so that a reader of chain state does not hold a replayable credential |
| Shamir secret sharing over GF(256) | Hand-written, crates/crypto/src/threshold/shamir.rs | One caller: MasterKeyState::generate in crates/multichain/src/nexus/master_key.rs splits a 32-byte master secret t-of-n, with t = two thirds of n rounded up. Nothing in either binary reaches it | Availability of a secret held in parts. Not integrity — see below |
| OS CSPRNG | rand 0.8.6 (OsRng) | crates/crypto/src/random.rs, which the crypto crate and the network crates key off exclusively | Unpredictability of keys, nonces, salts and session ids |
| In-memory wiping | zeroize 1.8.2 | Secret-key wrappers, shared secrets, layer keys, the software HSM store, the storage master key, the node master key | Narrows the window in which key material sits in freed heap or on the stack |
crates/crypto/src/random.rs is the crypto crate's own rule — its module doc says key generation "MUST use these functions" — but it is not a workspace-wide funnel. The node binary draws the Argon2id salt, the login challenge, and several session and DNS nonces straight from rand::random, which is a thread-local CSPRNG rather than OsRng.
Two exported items in the crypto crate have no caller in any binary: blake3_keyed, which has none at all outside its own tests, and ed25519::verify_batch, whose only caller is benches/benches/crypto.rs. verify_batch is a sequential loop over verify, not the batch-verification algorithm ed25519-dalek provides; it returns one boolean for the whole set and tells a caller nothing about which item failed. The benchmark says as much in a comment.
How the layer keys are derived
The onion construction is where the derivation detail matters most, because it is what allows the nonce to be omitted from the wire.
origin relay i
│ │
│ ephemeral X25519 keypair, fresh │ long-lived onion key,
│ for THIS layer of THIS packet │ separate from the
│ │ consensus signing key
└──────────── ECDH ─────────────────────┘
│
shared ‖ ephemeral_pub ‖ hop_pub
│
┌──────────┴──────────┐
derive_key( derive_key(
"…layer key v1") "…layer nonce v1")
│ │
AES-256-GCM key first 12 bytes → nonce
The ephemeral key is new for every layer of every packet, so the AEAD key is unique by construction and a fixed-per-key nonce cannot repeat. The derivation binds the shared secret to both public keys, so a secret cannot be replayed under a different pairing. The reply leg uses the same shared secret twice — once to open a routing layer and once to seal a payload — and those two derivations carry separate context strings for exactly that reason. An all-zero shared secret, the result of a low-order public key, is rejected rather than used.
This is a library construction. The one production path that would build an onion packet, try_relay_resolve in bin/uninet-node/src/api/unp_gateway.rs, first asks AppState for a confidential resolver and a relay pool; the node constructs both as None, and ThresholdResolver has no non-test implementor, so the path refuses before it reaches build_onion. crates/net/src/onion/mod.rs records the same gap under "# Not built", and crates/net/src/unp/resolver.rs opens by calling itself "an interface with no backend".
The UNP gateway handshake derives its session key from the ECDH output concatenated with a client nonce and a server nonce, under the context uninet unp session v1. That key is then used as-is for the life of the session. Session ratcheting exists as a separate construction in crates/crypto/src/handshake/unp_handshake.rs, and the crate documents the difference between its two forms precisely — the hash-only ratchet provides forward secrecy and no post-compromise security, because an attacker holding epoch n computes every later epoch by continuing the ratchet; the Diffie-Hellman ratchet mixes in material the attacker does not hold and does let a session recover. Neither is called from outside the crypto crate: no live session ratchets.
Key wrapping, and the two rotations
Data at rest is protected by envelope encryption rather than by encrypting directly under a long-lived key. envelope_encrypt draws a fresh 32-byte data encryption key from the CSPRNG, encrypts the payload under it with AES-256-GCM and the additional data envelope-data, then encrypts the data key under the master key with the additional data envelope-dek. The two additional-data strings differ so that a wrapped key and a wrapped payload can never be substituted for one another. This part is live: EncryptedBackend in the storage crate wraps any backend with it, the node puts both its state and its block store behind that, and bin/uninet-node/src/disk_store.rs calls it directly.
KeyHierarchy uses the same construction for chain keys and identity keys and can re-wrap a key under a new master key in one step. It has no caller outside the crypto crate, and neither does the rotation module described next.
Rotation is two distinct operations, and the crate keeps them distinct:
| | Full | ProactiveRefresh |
| --- | --- | --- |
| What changes | a new secret is drawn | the secret is kept; its shares are re-randomised |
| What must be re-encrypted | everything under the old key, hence the overlap window | nothing |
| What it defends against | compromise of the key itself | an attacker gathering shares slowly, who must now do so inside one refresh window |
| Entry point | rotate_key | refresh_shares |
Until 2026-08-27 the strategy field was never read: rotate_key drew a new random key whatever the policy said, so configuring ProactiveRefresh silently performed a Full rotation — the secret changed and no share was ever refreshed. Each entry point now performs only the operation its strategy names and returns an error for the other, so a mismatch fails loudly instead of silently. Read this as a corrected library, not as a rotation schedule a node runs: nothing outside the crate calls either function.
A related correction sits on the signing side, and this one is on a live path. The node's signing key also signs consensus payloads, network envelopes, UNP resolve attestations and session tokens. The user-facing sign endpoint originally signed caller-supplied bytes with that key and no domain tag, which made it a universal forgery oracle: hand it the exact preimage of any of those and it returned a valid signature. Every signature that endpoint produces now covers uninet-user-sign-v1\0 followed by the caller and the message, a preimage no other signer in the system can produce.
What was removed, and why
Five constructions were deleted from the crypto crate on 2026-08-27. Nothing outside the crate referenced any of them. They are recorded in the module documentation so that they are not re-approximated.
Zero-knowledge proofs (zkp/). SchnorrZkp::verify recomputed the Fiat-Shamir challenge, discarded the result, and returned whether both fields of the proof contained a non-zero byte. Any 64 non-zero bytes therefore verified against any statement. The three callers built on it — verify_share_ownership, verify_transaction_validity and verify_access_rights — inherited that behaviour in full. The crate notes that a replacement must be an algebraic Schnorr over a prime-order group, and that the k256 dependency such a construction needs is already present.
Feldman verifiable secret sharing (threshold/feldman.rs). split_verifiable committed to BLAKE3(share.data) for each share. A verifiable sharing scheme exists so that a participant can check its share against commitments the dealer cannot choose freely — commitments to the polynomial coefficients, published in a group where the participant recomputes its own evaluation point from them. Committing to the shares themselves lets a dealer choose both halves and hand every participant a consistent lie, which is the attack the scheme exists to stop. It additionally published BLAKE3(secret) alongside the shares, a preimage-checkable digest of the secret itself.
Threshold signatures (threshold/threshold_sig.rs). Partial signatures were BLAKE3(share_data ‖ BLAKE3(msg)). Combination XOR-ed the partials and hashed the result. Verification re-derived every partial from the secret shares it was handed, because the module contained no public key at all — the verifier had to hold the secret it was verifying against. The combined value was not a signature over the message under any key; it was a value any holder of t shares could recompute, and it carried no unforgeability.
DistributedSigningSession (smpc/key_usage.rs). The only consumer of the above. It collected secret shares inside one process rather than partial signatures across a wire, and finalize() returned a Signature that was not a signature.
Pkcs11Hsm (hsm/pkcs11.rs). Six trait methods, each returning Err("PKCS#11 not yet implemented"), behind an hsm-pkcs11 feature flag that made the type read as a selectable backend. The type and the flag were removed together.
The deletion reached the HTTP surface as well. The /api/crypto routes for zkp.prove, zkp.verify, threshold.keygen, threshold.sign and smpc.compute previously answered HTTP 200 with a BLAKE3 digest placed in a field named proof, partial_signature or result; threshold.keygen returned the literal string stub-group-pk-0000 as a group public key. They now answer 501 with an error, an operation and a reason, and a test asserts that no 501 body carries any field a caller could mistake for a result. The zkp.verify reason states the distinction that matters: this is not a verdict of invalid, there is no verifier.
Two corrections to the record
There is no FROST. The word appears in crates/crypto/src/threshold/mod.rs, crates/crypto/src/smpc/mod.rs and bin/uninet-node/src/api/crypto.rs, and in each case it names the scheme a correct replacement would have to use. Cargo.lock contains no frost-* crate. vsss-rs is declared in [workspace.dependencies] and depended on by no crate, so it is not in the lockfile either.
Property-based testing does not cover the thirteen crates. proptest is declared in the [dev-dependencies] of all thirteen, uninet-crypto included, and no .rs file in any workspace member imports it. One file in the repository does: fuzz/tests/properties.rs. It belongs to fuzz/, which declares its own [workspace] and is deliberately not a member of the root workspace. It exercises the onion and tunnel parsers, the byte-oriented decoders and the text parsers against unstructured input, and one property flips every bit of an onion cell and asserts each mutation fails to peel — which is the only place an AEAD is covered by a property at all. No property test covers a signature, a key derivation or the secret sharing.
What this design does not address
- Shamir shares are unverifiable. There are no commitments, so a participant cannot detect a dishonest dealer or a corrupted share before reconstruction.
crates/crypto/src/threshold/shamir.rsstates this in its first paragraph. Its GF(256) field arithmetic is hand-written and carries the risk any hand-written field arithmetic carries. Its one caller, the Nexus master key, is itself unreachable from either binary. SoftwareHsmis the only type implementingHsmBackend, it holds key material in process memory, and no binary constructs it. Secrets are wiped in place ondestroy_keyand on drop, which is the most that backend can promise: the operating system may already have copied a page to swap or into a core dump.SoftwareConfidentialRuntimeprocesses its input directly and has no caller outside the crypto crate. Data passed to it would not be enclave-protected, andattestation_reportreturns the fixed bytessoftware-tee-attestation-v1, which attest to nothing about hardware.- Cross-chain relay proofs are unkeyed.
crates/multichain/src/router/cross_chain.rshasadd_relay_proofpushBLAKE3(message_hash ‖ relay_chain_id)onto the proof vector. That is a hash anyone can compute, not a signature, and the chain of custody it produces is forgeable. Nothing but its own tests calls it, and it must not be read as attestation. - Domain separation is a convention the caller upholds. The derivation functions take a context string and will faithfully derive a colliding key if two call sites pass the same one. Nothing in the type system enforces uniqueness across those strings.
- Nonce discipline outside the onion path belongs to the caller.
generate_noncedraws twelve random bytes. Only the onion layer construction makes key uniqueness structural; elsewhere the guarantee is probabilistic and depends on how much is encrypted under one key.
What the tests cover
The signature, key agreement, AEAD, hash and password-hashing entries in the table are each a vetted library implementation reached through a thin wrapper. SHA-256 and Keccak-256 have known-answer tests against published digests. AES-256-GCM has negative tests asserting that a wrong key, a tampered ciphertext and a mismatched AAD each fail. Ed25519 has roundtrip tests plus wrong-key and tampered-message rejection; the test named for RFC 8032 test vector 1 uses that vector's secret seed but never compares the resulting public key or signature against the published values, so it is a roundtrip test, not a known-answer test.
Nothing shaped like a proof, a threshold signature, a verifiable sharing, hardware key custody or enclave execution exists in this workspace, and the endpoints that once appeared to provide them refuse rather than answer.
How it composes
The signature primitive is what makes an identity a key rather than a record, and what makes a delegation a statement its issuer cannot later disown. Both verify through the same ed25519::verify the consensus engine applies to votes.
The key agreement and AEAD entries are the primitives beneath private networking. The per-layer derivation described above is what would let each relay open exactly one layer and nothing else; the UNP tunnel between a client and a node is the part of that path a running node actually uses today.
The threat model states which parties the system treats as adversarial. This page states which primitives are load-bearing under that model, which are present but unreached, and which named capabilities are absent entirely.
What is not finished
Zero-knowledge proofs, verifiable secret sharing and threshold signatures do not exist in this codebase. Earlier implementations carried no unforgeability and were deleted; secret shares that remain are explicitly unverifiable.
Hardware security modules are not implemented, so key custody is software custody in process memory.
Hardware-backed confidential computing is not implemented. If your threat model includes the operator running your workload, UniNet does not solve that today.
The properties described here have not been independently audited. Treat them as design intent you should read and test, not as a compliance control you can cite.