security & Noise — the Noise link’s crypto backend¶
In one paragraph
The Noise link binding (RFC-0033)
carries frames inside a Noise_NNpsk0_25519_ChaChaPoly_SHA256 session. This module is its
cryptographic half: the handshake and transport steps, written once over a crypto backend
chosen at compile time (OpenSSL, libsodium or mbedTLS through PSA). The core stays
crypto-free. A build that does not name a backend compiles none of this and links no crypto
library. Everything works on caller memory: no allocation of its own, no clock and no
randomness. On every backend, nothing allocates before a first message’s PSK tag is proven,
and nothing allocates per frame.
What it does¶
security_noise.hpp holds the protocol, written once over a backend type B:
symmetric_state_t<B>is Noise’s SymmetricState (ck,hand the handshake cipher key), a plain value that copies.psk_statebuilds the state after the prologue and thepsktoken. That state is the same for every handshake on a link, so a link computes it once and starts each handshake from a copy (RFC-0033 §5.8).handshake_t<B>is one side of the handshake over RFC-0033’s datagrams:write_firstandread_secondfor the initiator,read_firstandwrite_secondfor the responder, thensplit.read_firstchecks the PSK tag with no Diffie-Hellman and no key generation. The caller checks the counter against its mark, and onlywrite_secondrunsee. So a message under a wrong PSK, or a replayed one, costs oneMixHash, one HKDF and one tag check, and no memory. The tag is checked with the link’s handshake cipher, a backend cipher the link builds once beside its PSK state and passes to eachhandshake_t, which re-keys it per message. An all-zero X25519 result is refused (LOW_ORDER) whatever the backend does.transport_cipher_t<B>holds a session’s two transport keys, keyed into the backend once atsplit.sealwrites the type byte, the nonce, the ciphertext and the tag, in place when the frame already sits at its payload offset.parse_transport_headerreads the type and the nonce before any decryption, which is where the replay pre-check goes, andopenthen decrypts in place. Nonces at or above 2^62 are refused on both sides. A cipher takes one call at a time: the link serialises each direction’s senders, or keeps one session cipher per sending thread.
Every refusal is a refusal_t, which the link maps onto RFC-0033 §5.9’s counters.
The link half (the slots, the counter and the mark, the replay window, the key phase, the
now deadlines and the counters) is the next slice of
#2064, built on these types.
Choosing a backend¶
One CMake cache variable, LIBTRACER_NOISE_CRYPTO, picks the backend. Any value but none
makes the header-only target libtracer_noise, which carries the library and a
LIBTRACER_NOISE_CRYPTO_<BACKEND> define. security_noise.hpp turns that define into the
alias tr::net::noise::default_crypto_t. There is no runtime switch.
value |
backend |
library |
notes |
|---|---|---|---|
|
— |
— |
the module is not compiled |
|
|
libcrypto 3 |
the fastest AEAD on a host at 1 KiB and above; needs the deprecated |
|
|
libsodium |
no allocation at all, handshake included |
|
|
mbedTLS 3.6.1+ or 4.x through PSA |
the ESP-IDF backend; needs a sized static key-slot table (below) |
A backend is a type that meets the crypto_backend concept (security_noise_crypto.hpp):
SHA-256, Noise’s HKDF, an X25519 key pair and a ChaCha20-Poly1305 cipher. hash, hkdf and
the cipher’s set_key, seal and open must not allocate; building a cipher and the X25519
calls may. A test can wrap a backend in another compile-time policy, as security_noise_test
does to count the X25519 calls a refused first message makes (none).
The PSA backend needs three things from the mbedTLS build:
MBEDTLS_PSA_ASSUME_EXCLUSIVE_BUFFERS, or every PSA call copies its buffers through the heap. ESP-IDF’s port sets it.MBEDTLS_PSA_STATIC_KEY_SLOTSwithoutMBEDTLS_PSA_KEY_STORE_DYNAMIC(mbedTLS 3.6.1 and later), or every key import allocates, a first message’s handshake key included.security_noise_psa.hpprefuses to build without it.A slot table sized by the app, because every PSA user in the image shares it. The rule:
setting
at least
MBEDTLS_PSA_KEY_SLOT_COUNT6 per Noise link (two sessions of two keys during a rekey, the handshake cipher, the ephemeral key), plus the peak of the image’s other PSA users
MBEDTLS_PSA_STATIC_KEY_SLOT_BUFFER_SIZEthe largest key any of them imports; Noise’s keys are all 32 B
A Noise-only image needs 8 slots of 32 B: 360 B of
.bsson an ESP32-C6, and the configuration the CI psa leg tests (8 is the fewest the two suites pass with, holding both ends of a session in one process). Rough figures for a mixed image at 16 slots: about 2 KB with TLS that verifies only ECDSA P-256 or X25519 peers, about 5 KB with RSA-2048 server certificates, about 20 KB if the image holds its own RSA-2048 private key. Left unsized, mbedTLS sizes 32 slots for the largest key type enabled: 76,616 B at ESP-IDF’s defaults, which enable RSA-4096 key pairs. That figure is the unsized default, not the cost of Noise. A table that is too small fails at run time, when an import is refused, not at build. ESP-IDF has no option for any of this: the Kconfig help ofCONFIG_LIBTRACER_NOISE_CRYPTO_PSAshows the header an app passes asMBEDTLS_USER_CONFIG_FILE.A NARROW image that needs PSA for nothing else should use libsodium (ESP-IDF’s component registry has a port). It allocates nothing, has no global table, and needs no mbedTLS change. The backend is a compile-time choice, so the swap is one define.
On ESP-IDF,
CONFIG_MBEDTLS_CHACHA20_CandCONFIG_MBEDTLS_CHACHAPOLY_C, which are off by default. The Kconfig option selects them.
What it costs¶
Allocation calls, counted process-wide (malloc and its family) by security_noise_test
on the host (GCC 13, glibc). The library’s own allocations are included. The zero rows are
asserted.
openssl (3.0.13) |
sodium (1.0.18) |
psa (mbedTLS 4.1.1) |
psa (mbedTLS 3.6.5) |
|
|---|---|---|---|---|
per link: PSK state and handshake cipher |
3 |
0 |
0 |
0 |
per session: the two transport ciphers |
6 |
0 |
0 |
0 |
handshake, initiator |
34 |
0 |
6200 |
6244 |
handshake, responder |
34 |
0 |
6200 |
6244 |
a first message read, refused (wrong PSK) or accepted (a replay) |
0 |
0 |
0 |
0 |
per frame (seal + open), 0 B to 65519 B |
0 |
0 |
0 |
0 |
The handshake rows are the X25519 calls: EVP_PKEY objects on OpenSSL, and mbedTLS’s bignum
arithmetic on PSA, paid once per session and only after the PSK is proven. The #2065
harness measures the time of each step on each backend.
Conformance¶
conformance_runner replays every tests/conformance/vectors/v1/noise/<case>/transcript.json
on the build’s backend and compares each recorded value: the symmetric state after every
token, both handshake messages, the handshake hash, the transport keys and the transport
datagrams. The first case is RFC-0033 Appendix A. Two third-party Noise implementations
reproduce it too, as the RFC’s §15 Q6 ruling requires: noiseprotocol (Python) and snow
(Rust, over its own primitives). Their scripts and results sit next to the transcript.
API reference¶
-
template<class B>
concept crypto_backend¶ - #include <
security_noise_crypto.hpp>The compile-time contract of a Noise crypto backend, as this file’s comment states it.
-
template<crypto_backend B>
class symmetric_state_t¶ Noise’s SymmetricState (Noise §5.2): the chaining key, the handshake hash and the handshake cipher key with its nonce.
The cipher key is held as bytes, and each
EncryptAndHash/DecryptAndHashkeys the caller’s backend cipher for its one message, so the state is a plain value: it copies, and a copy of the state after the PSK starts each handshake. Its destructor wipes it.- Template Parameters:
B – The crypto backend.
Public Functions
-
symmetric_state_t(const symmetric_state_t&) = default¶
Copy the state (the handshake start).
-
symmetric_state_t &operator=(const symmetric_state_t&) = default¶
Copy-assign the state.
-
inline bool initialize(std::span<const std::byte> protocol_name)¶
Noise
InitializeSymmetric(protocol_name)for a name longer thanHASHLEN.- Returns:
False if the name is not longer than 32 bytes or the hash failed.
-
inline bool mix_hash(std::span<const std::byte> data)¶
h = HASH(h || data), fordataup to kMaxMixHash bytes.
-
inline bool mix_key(std::span<const std::byte> ikm)¶
ck, k = HKDF(ck, ikm, 2), thenInitializeKey(k).
-
inline bool mix_key_and_hash(std::span<const std::byte> ikm)¶
ck, temp_h, k = HKDF(ck, ikm, 3),MixHash(temp_h),InitializeKey(k).
-
inline bool encrypt_and_hash(typename B::aead_t &aead, std::span<const std::byte> pt, std::byte *out)¶
Noise
EncryptAndHash: sealptwithhas associated data intoout(pt.size() + kTagLenbytes), then mix the ciphertext intoh.NNpsk0’s first token is
psk, so a key is always set before any payload: the unkeyed pass-through Noise defines for other patterns is refused here instead of carried.- Parameters:
aead – The cipher to seal with, keyed here with
kand cleared again after use.
-
inline bool decrypt_and_hash(typename B::aead_t &aead, std::span<const std::byte> ct, std::byte *out)¶
Noise
DecryptAndHash: openctwithhas associated data intoout(ct.size() - kTagLenbytes), then mix the ciphertext intoh.- Parameters:
aead – The cipher to open with, keyed here with
kand cleared again after use.- Returns:
False on a bad tag, or with no key set;
hand the nonce are then unchanged.
-
inline bool split(key32_t &k1, key32_t &k2) const¶
Noise
Split():k1encrypts initiator to responder,k2the reverse.
-
inline const key32_t &chaining_key() const noexcept¶
The chaining key
ck.
-
inline const key32_t &handshake_hash() const noexcept¶
The handshake hash
h; after the last message, the channel binding.
-
inline const key32_t &cipher_key() const noexcept¶
The handshake cipher key
k, meaningful only while has_key holds.
-
inline bool has_key() const noexcept¶
Whether a cipher key is set (Noise
HasKey).
-
template<crypto_backend B>
bool tr::net::noise::psk_state(const key32_t &psk, symmetric_state_t<B> &out)¶ The symmetric state every handshake on a link starts from:
InitializeSymmetric,MixHash(prologue)and thepsktoken’sMixKeyAndHash(psk)(RFC-0033 §5.8).The link computes it once when it is built, and again only when its PSK is replaced.
-
template<crypto_backend B>
class handshake_t¶ One side’s NNpsk0 HandshakeState over RFC-0033’s datagrams.
The initiator calls write_first, then read_second; the responder calls read_first, then write_second. Either then calls split. A refused step leaves the state where it was, so a link can read a datagram into a scratch copy and keep its handshake slot untouched when it is refused.
Each handshake message is sealed or opened with the link’s handshake cipher, passed in and keyed for each message and cleared after it, so it holds no key between messages (RFC-0033 §6.8). The link builds it once, beside its PSK state, so a first message allocates nothing. Handshakes may share it while their calls do not overlap.
- Template Parameters:
B – The crypto backend.
Public Functions
-
inline handshake_t(role_t role, const symmetric_state_t<B> &keyed, typename B::aead_t &cipher)¶
Start a handshake from the link’s PSK state (psk_state).
- Parameters:
role – This side.
keyed – The state after the prologue and the PSK; copied.
cipher – The link’s handshake cipher; it must outlive this handshake.
-
inline std::expected<void, refusal_t> write_first(const key32_t &e_priv, first_payload_t payload, std::span<std::byte, kFirstMessageBytes> out)¶
Initiator: write
-> psk, ewithpayloadinto the 58-byteout.- Parameters:
e_priv – The ephemeral private key, from the application’s random source: fresh for each handshake, never reused, and wiped by the caller after this call (RFC-0033 §6.8).
-
inline std::expected<first_payload_t, refusal_t> read_first(std::span<const std::byte> datagram)¶
Responder: check and read a first message, with no Diffie-Hellman.
- Return values:
MALFORMED – Not 58 bytes, or not type 0x01.
AUTH – The PSK tag did not verify.
PAYLOAD – Flag bits 7-1 are set.
- Returns:
The authentic payload; the caller checks its counter before answering.
-
inline std::expected<void, refusal_t> write_second(const key32_t &e_priv, std::uint8_t flags, std::span<std::byte, kSecondMessageBytes> out)¶
Responder: write
<- e, eewith theflagsbyte into the 50-byteout.- Parameters:
e_priv – The ephemeral private key, from the application’s random source: fresh for each handshake, never reused, and wiped by the caller after this call (RFC-0033 §6.8).
- Return values:
LOW_ORDER –
DH(e, re)is all zero; nothing is written.
-
inline std::expected<std::uint8_t, refusal_t> read_second(std::span<const std::byte> datagram)¶
Initiator: check and read a second message.
- Return values:
MALFORMED – Not 50 bytes, or not type 0x02.
LOW_ORDER –
DH(e, re)is all zero.AUTH – The tag did not verify.
PAYLOAD – Flag bits 7-2 are set.
- Returns:
The authentic flags byte (bit 0 the key phase, bit 1
fresh).
-
inline std::expected<void, refusal_t> split(transport_cipher_t<B> &out) const¶
After the last message: key
outfor this side’s two directions.
-
inline const symmetric_state_t<B> &symmetric_state() const noexcept¶
The symmetric state so far; after the last message,
his the handshake hash.
-
template<crypto_backend B>
class transport_cipher_t¶ The two transport keys of one session, keyed into the backend once at
Split(): seal toward the peer, open from it (Noise §11.4, explicit nonces).Nonce choice, the replay window and the key phase are the link’s (RFC-0033 §5.5, §5.6); this type applies a given nonce and phase.
sealandopenallocate nothing, and take one call at a time per direction: a link serialises its senders, or keeps one session cipher per sending thread (the backend contract).- Template Parameters:
B – The crypto backend.
Public Functions
-
inline bool set_keys(const key32_t &send, const key32_t &recv)¶
Key both directions (what
Split()feeds).
-
inline std::expected<std::size_t, refusal_t> seal(std::uint8_t phase, std::uint64_t n, std::span<const std::byte> frame, std::span<std::byte> out)¶
Write one transport datagram: the type byte for
phase, noncen, thenframesealed with empty associated data, and its tag.framemay already sit atout.data() + kTransportHeaderBytes, which seals in place; any other overlap withoutis undefined.- Return values:
MALFORMED –
phaseis not 0 or 1, the frame is above kMaxFrameBytes, oroutis too small.NONCE_BOUND –
nis at or above 2^62.
- Returns:
The datagram size,
frame.size() + kTransportOverhead.
-
inline std::expected<std::span<std::byte>, refusal_t> open(const transport_header_t &h, std::span<std::byte> datagram)¶
Check the tag of
datagramand decrypt it in place.- Parameters:
h – The header parse_transport_header read from
datagram.- Return values:
AUTH – The tag did not verify (the bytes are then unspecified).
- Returns:
The plaintext, inside
datagramat offset kTransportHeaderBytes; empty for a confirmation or an acknowledgement.
-
inline std::expected<transport_header_t, refusal_t> tr::net::noise::parse_transport_header(std::span<const std::byte> datagram) noexcept¶
Read a transport datagram’s type and nonce, with no decryption: the steps that come before the replay pre-check (RFC-0033 §5.5 steps 1 and 2).
- Return values:
MALFORMED – Fewer than 25 bytes, or a type byte other than 0x04 / 0x05.
NONCE_BOUND – The nonce is at or above 2^62.
-
enum class tr::net::noise::refusal_t : std::uint8_t¶
Why a step refused, mapped by the link onto RFC-0033 §5.9’s counters.
MALFORMEDandNONCE_BOUNDaremalformed_rx.AUTH,PAYLOADandLOW_ORDERon a handshake message arenoise_handshake_failed;AUTHon a transport message isnoise_decrypt_failed.STATEis a caller error, andBACKENDa library failure.Values:
-
enumerator MALFORMED¶
Wrong size or reserved type byte; nothing was decrypted.
-
enumerator AUTH¶
The tag did not verify.
-
enumerator PAYLOAD¶
An authentic handshake payload with reserved flag bits set.
-
enumerator LOW_ORDER¶
The peer’s ephemeral key gave an all-zero X25519 result.
-
enumerator NONCE_BOUND¶
A transport nonce at or above 2^62.
-
enumerator STATE¶
The step was called out of order for this role.
-
enumerator BACKEND¶
The crypto library failed.
-
enumerator MALFORMED¶
-
enum class tr::net::noise::msg_type_t : std::uint8_t¶
The datagram type byte (RFC-0033 §5.3); every other value is reserved.
Values:
-
enumerator FIRST¶
Handshake, first message (
-> psk, e).
-
enumerator SECOND¶
Handshake, second message (
<- e, ee).
-
enumerator TRANSPORT_PHASE_0¶
Transport message, key phase 0.
-
enumerator TRANSPORT_PHASE_1¶
Transport message, key phase 1.
-
enumerator FIRST¶
-
constexpr std::size_t tr::net::noise::kFirstMessageBytes = 1 + kDhLen + 9 + kTagLen¶
A first handshake message’s datagram: type,
e.pub, payload (9) and tag.
-
constexpr std::size_t tr::net::noise::kSecondMessageBytes = 1 + kDhLen + 1 + kTagLen¶
A second handshake message’s datagram: type,
e.pub, payload (1) and tag.
-
constexpr std::size_t tr::net::noise::kTransportHeaderBytes = 1 + 8¶
A transport datagram’s clear header: the type byte and the u64 nonce.
-
constexpr std::size_t tr::net::noise::kTransportOverhead = kTransportHeaderBytes + kTagLen¶
What a transport datagram adds to its frame: the header and the tag.
-
constexpr std::size_t tr::net::noise::kMaxFrameBytes = 65535 - kTagLen¶
The largest frame one Noise message carries: 65535 minus the tag (§5.3).
-
constexpr std::uint64_t tr::net::noise::kNonceLimit = std::uint64_t{1} << 62¶
No transport nonce at or above this is sent or decrypted (§5.5).
-
struct first_payload_t¶
The first message’s 9-byte payload (RFC-0033 §5.2).
-
struct transport_header_t¶
A transport datagram’s clear header, read before any decryption (§5.5).
Public Members
-
std::uint8_t phase = 0¶
The key phase, 0 or 1 (from the type byte).
-
std::uint64_t nonce = 0¶
The nonce, below kNonceLimit.
-
std::uint8_t phase = 0¶
-
struct openssl_crypto_t¶
libcrypto (OpenSSL 3):
EVP_CIPHER_CTXper cipher,EVP_PKEYfor X25519.Public Static Functions
-
static inline bool init()¶
Nothing to set up in OpenSSL 3.
-
static inline bool hash(std::span<const std::byte> in, key32_t &out)¶
SHA-256 of
in, on a stackSHA256_CTX(no allocation).
Public Static Attributes
-
static constexpr const char *kName = "openssl"¶
The backend’s name in reports.
-
struct aead_t¶
A ChaCha20-Poly1305 cipher: one
EVP_CIPHER_CTX, allocated when it is built.Public Functions
-
inline bool set_key(const key32_t &k)¶
Key the context in place, replacing any previous key (no allocation).
-
inline void clear()¶
Overwrite the key in the context with zeros (no allocation).
-
inline bool seal(std::uint64_t n, std::span<const std::byte> ad, std::span<const std::byte> pt, std::byte *out)¶
Seal
ptunder noncen;outtakespt.size() + kTagLenbytes.
-
inline bool open(std::uint64_t n, std::span<const std::byte> ad, std::span<const std::byte> ct, std::byte *out)¶
Open
ctunder noncenintoout; false on a bad tag.
-
inline bool set_key(const key32_t &k)¶
-
struct dh_key_t¶
An X25519 key pair held as an
EVP_PKEY.
-
static inline bool init()¶
-
struct sodium_crypto_t¶
libsodium: stateless calls, keys held in the backend’s own objects.
Public Static Functions
-
static inline bool init()¶
sodium_init; idempotent, false only if the library cannot run here.
-
static inline bool hash(std::span<const std::byte> in, key32_t &out)¶
SHA-256 of
in.
Public Static Attributes
-
static constexpr const char *kName = "sodium"¶
The backend’s name in reports.
-
struct aead_t¶
A ChaCha20-Poly1305 (IETF) cipher: its key.
Public Functions
-
inline bool set_key(const key32_t &k)¶
Store the key.
-
inline void clear()¶
Wipe the key.
-
inline bool seal(std::uint64_t n, std::span<const std::byte> ad, std::span<const std::byte> pt, std::byte *out)¶
Seal
ptunder noncen;outtakespt.size() + kTagLenbytes.
-
inline bool open(std::uint64_t n, std::span<const std::byte> ad, std::span<const std::byte> ct, std::byte *out)¶
Open
ctunder noncenintoout; false on a bad tag.
-
inline bool set_key(const key32_t &k)¶
-
struct dh_key_t¶
An X25519 key pair: the private scalar.
-
static inline bool init()¶
-
struct psa_crypto_t¶
mbedTLS through the PSA Crypto API: keys in volatile key slots.
Public Static Functions
-
static inline bool init()¶
psa_crypto_init; idempotent.
-
static inline bool hash(std::span<const std::byte> in, key32_t &out)¶
SHA-256 of
in.
-
static inline bool hkdf(const key32_t &ck, std::span<const std::byte> ikm, std::span<std::byte> out)¶
Noise HKDF as
PSA_ALG_HKDF(PSA_ALG_SHA_256)withsalt = ck, emptyinfo.
Public Static Attributes
-
static constexpr const char *kName = "psa"¶
The backend’s name in reports.
-
struct aead_t¶
A ChaCha20-Poly1305 cipher: its key, imported once into a volatile slot.
Public Functions
-
inline bool set_key(const key32_t &k)¶
Import
k, replacing any previous key.
-
inline void clear()¶
Destroy the key, which wipes it and frees its slot.
-
inline bool seal(std::uint64_t n, std::span<const std::byte> ad, std::span<const std::byte> pt, std::byte *out)¶
Seal
ptunder noncen;outtakespt.size() + kTagLenbytes.
-
inline bool open(std::uint64_t n, std::span<const std::byte> ad, std::span<const std::byte> ct, std::byte *out)¶
Open
ctunder noncenintoout; false on a bad tag.
-
inline bool set_key(const key32_t &k)¶
-
struct dh_key_t¶
An X25519 key pair in a volatile key slot.
-
static inline bool init()¶