hiss
Encrypted, authenticated channels between two peers you control — with the handshake checked by the compiler, and private keys that can stay in an Apple Secure Enclave.
Built on the Noise Protocol Framework: you write the handshake in Noise's own
notation; hiss generates it, sizes every message at compile time, and rejects malformed ones.
Status:
0.2, unreleased — unstable API, not independently audited. See How this is tested and Security before relying on it. The0.1.0currently on crates.io predates thenoise!macro this README describes.
Quickstart
Two peers authenticate each other and exchange an encrypted message in each direction,
neither knowing the other's key in advance. Four steps, each a doctest that compiles and
runs; assembled, they are examples/quickstart.rs.
hiss never picks a random-number generator for you, so the CSPRNG is yours to choose:
[]
= "0.2"
= "0.10"
1. Describe the handshake you want. This one is XX: three messages, both sides
proving who they are along the way. Name the type after its pattern — the name you write
goes on the wire as part of the protocol identity.
use ;
noise!
2. Give each side a long-term key. XX authenticates both parties, so each owns
a key pair that outlives the connection; nothing is shared in advance. Keep the public
halves — step 3 is where each side checks the other against one.
use ;
let mut alice_keys = new;
let alice_static = alice_keys.?;
let alice_pub = alice_keys.public?;
let mut bob_keys = new;
let bob_static = bob_keys.?;
let bob_pub = bob_keys.public?;
3. Run the handshake — and decide whether to trust the peer. Each call hands you the
bytes to send; moving them — socket, queue, QR code — is yours, because hiss does no I/O.
Completing XX proves the peer holds a static private key, never that it is one you
trust. read_message_N_with is where that decision goes: the closure sees the peer's key
as it decrypts, and an Err aborts before any Transport exists. Leave it out and you
have an encrypted channel to a stranger.
The prologue is any context both sides already agree on — a protocol version, a channel
name — mixed into the handshake so a mismatch fails it; pass &[] if you have none.
use HandshakeError;
const PROLOGUE: & = b"prologue";
// Your trust policy: a pin, an enrolment record, an allow-list. Here, the key we expect.
let accept = ;
let = XXinitiator.write_message_1?;
let bob = XXresponder.read_message_1?;
let = bob.write_message_2?;
let alice = alice.read_message_2_with?;
let = alice.write_message_3?;
let mut bob = bob.read_message_3_with?;
4. Talk. Both ends now hold a Transport. OVERHEAD is what the authentication tag
costs you per message: give send a buffer of plaintext.len() + OVERHEAD, and receive
one that fits the plaintext. b"ping" is 4 bytes, so 4 is the size below. One record
carries at most 65519 bytes of plaintext — chunk anything larger yourself.
use Transport;
let mut wire = ;
let mut got = ;
let n = alice.send?;
let m = bob.receive?;
assert_eq!;
let n = bob.send?;
let m = alice.receive?;
assert_eq!;
Framed on a real socket, same trust check: tcp_xx_channel.rs;
plus a PSK ceremony: tcp_ikpsk1_ceremony.rs.
Why this and not snow
snow is the established Rust Noise implementation, and
this README leans on it: the hiss-interop suite runs hiss against it, and the frozen
vectors were generated from it. Neither crate has been audited — snow says so on its own front
page. Three things differ.
The pattern is a type, not a string. snow parses "Noise_XX_25519_ChaChaPoly_BLAKE2s"
at runtime and hands back one HandshakeState whose read_message / write_message take
&mut self and may be called in any order. hiss compiles the pattern into a state
machine: each message is its own method, and it consumes the state before it. Wrong order,
skipped message, or using the channel before the handshake finishes are compile errors —
as is a pattern that never keys the cipher.
Message sizes are constants. snow's own example opens let mut buf = [0u8; 65535],
because the length isn't known until the message arrives. XX::MSG1_SIZE is a
compile-time usize, so framing a handshake is a read_exact into [u8; N] — no length
prefix, no scratch buffer.
Private keys can stay in hardware. snow's builder takes the private key as bytes. On
macOS and iOS, hiss can generate the static key inside the Apple Secure Enclave and
leave it there; your process only ever holds a handle. See Providers.
Choose snow if you need more of Noise than this covers — the 23 deferred
patterns (spec §7.6), the fallback modifier, PSKs at arbitrary positions
(psk0–psk4), more ciphers (AES-GCM, XChaChaPoly), and swappable crypto backends
including ring — or if you need something on crates.io today. Two axes where it is no
longer ahead: the fundamental patterns, all fifteen of which hiss now ships, and the
hashes — snow's set is the specification's four, and so is hiss's.
One choice that isn't a comparison: production cryptography here is cryptoxide and
eccoxide, nothing else.
Supported suite
This release targets a narrow suite matrix and a fixed set of patterns:
| Axis | Supported |
|---|---|
| Patterns | N, K, Kpsk0, IKpsk1, IK, NK, IX, XK, NN, XX, X, NX, XN, KN, KK, KX, IN |
| Curves | NIST P-256 (secp256r1), X25519 (Curve25519, the Noise 25519 curve), and X448 (the Noise 448 curve) |
| Cipher | ChaCha20-Poly1305 |
| Hash | BLAKE2b-512, SHA-512, SHA-256, BLAKE2s — the Noise specification's four |
That pattern row is all fifteen of Noise's fundamental patterns plus two PSK
variants. Conformance is anchored against
snow — by the frozen vectors snow generated, which
every build replays, and by the live interop suite in hiss-interop, which runs
occasionally. What is planned beyond
this — and what is deliberately not — is in TODO.md.
There is no default suite — every noise! declaration names its curve, cipher and hash,
and one that omits them generates a bare pattern marker rather than a working protocol.
The cipher row has one entry, so the choices are the curve and the hash. For the curve,
use X25519, as the Quickstart does, unless you need the Apple Secure Enclave, which
speaks P256 and nothing else, or want X448's larger margin. For the hash, use
Blake2b — it is what the Quickstart uses and the only one with the full
seventeen-pattern frozen P-256 matrix; the other three are there for peers that require
them. All four are covered by primitive vectors from the relevant standard and by frozen
third-party (cacophony) Noise vectors over 25519 and 448 across all seventeen
patterns, plus live snow interop on XX in hiss-interop. With X448, prefer a
512-bit hash
(Blake2b or Sha512).
Which pattern?
If you are not sure, use XX — the Quickstart's pattern. It needs nothing arranged in
advance, authenticates both sides, and hides both identities from anyone watching the
wire. Move off it only when a row below describes your situation better.
Interactive — both sides talk. Every one of these mixes both ephemerals (ee), so
once that token lands the session has full forward secrecy. What differs is who proves
their identity, and what has to be arranged beforehand.
| Pattern | Msgs | Whose identity is proven | Must be arranged in advance | Reach for it when |
|---|---|---|---|---|
XX |
3 | both | nothing | The default. Neither side pre-knows the other, and both identities stay hidden from a passive eavesdropper |
IK |
2 | both | initiator knows the responder's public key | You already ship the server's key inside the client — fewest round trips for mutual authentication |
IKpsk1 |
2 | both, plus a shared secret | responder's public key and a pre-shared key | IK for devices enrolled in a ceremony that issued them a per-device secret |
XK |
3 | both | initiator knows the responder's public key | Like IK, but the initiator's identity must stay hidden from an eavesdropper — costs an extra round trip |
IX |
2 | both | nothing | Mutual authentication with nothing pre-shared, when the initiator's identity need not be private — it goes out in the clear |
NK |
2 | responder only | initiator knows the responder's public key | Anonymous client, known server, and you want a reply |
NN |
2 | neither | nothing | Only with authentication layered on top. An active machine-in-the-middle defeats it outright |
One-way — a single sealed message, no reply. There is no ee here, so forward
secrecy is one-sided: the fresh ephemeral per message protects a captured message against
later compromise of the sender's keys, but whoever compromises the recipient's static
private key — plus the pre-shared key, for Kpsk0 — can still decrypt it.
| Pattern | Msgs | Whose identity is proven | Must be arranged in advance | Reach for it when |
|---|---|---|---|---|
N |
1 | recipient only | sender knows the recipient's public key | Sealing something to a known public key; the sender stays anonymous |
X |
1 | both | sender knows the recipient's public key | Like N, but the message also proves who sent it — the sender's key travels encrypted |
K |
1 | both | both public keys, exchanged out of band | Two peers who have already swapped keys; no identity goes on the wire at all |
Kpsk0 |
1 | both, plus a shared secret | both public keys and a pre-shared key | K bound to a secret established during a ceremony |
When you get it wrong
"It refuses to build" is only worth anything if the refusal tells you something. Two kinds of mistake are caught, both before your code runs.
A slip in the pattern itself:
error: token `e` appears twice in the same message
--> src/main.rs:3:15
|
3 | -> e, e
| ^
And — more usefully — a pattern that parses fine but is not a sound protocol:
error[E0277]: this Noise pattern never keys the cipher: it performs no DH
(ee/es/se/ss) and no psk token, so it provides no confidentiality
or authentication
--> src/main.rs:3:9
|
3 | pub Bad<X25519, ChaChaPoly, Blake2b> {
| ^^^ pattern finalises with an unkeyed cipher
That second one is the point. It is not a type error dressed up — it is the compiler telling you your protocol is insecure, at the definition, before anything else compiles. The same guard rejects a Diffie–Hellman over a key that has not been transmitted yet, a key sent twice, and a Diffie–Hellman in a pre-message — the rules of Noise §7.3, checked by the type system.
Both messages are pinned by tests — the first by tests/ui/duplicate_token.stderr, the
second by a compile_fail doctest on WellFormed — so they stay true as the crate
changes. (Diagnostic text verbatim; paths and line wrapping tidied for print.)
Providers
A provider is where your private keys live and what performs the key agreement. hiss
never picks one for you: you construct it and hand it to initiator / responder, which
is the alice_keys argument in the Quickstart. Two ship with the crate:
| Provider | Platforms | Where the private key lives | Curves |
|---|---|---|---|
EphemeralOnly |
everywhere, including WASM | in your process memory, zeroized on drop | P-256, X25519, X448 (DH); Ed25519 (signing only) |
AppleSecureEnclave |
macOS, iOS | inside the enclave — your process only ever holds a handle | P-256 |
EphemeralOnly is the default, and what the Quickstart uses. Its name means no built-in
persistence, not "no long-term keys": it does generate the static key that XX
authenticates you by. Storing that key between runs, and distributing the public halves
your peers pin, are yours to do — EphemeralOnly will not do them behind your back.
Moving to the enclave is a two-line change in your code; the enclave itself still needs
setting up, which on macOS means a team-prefixed keychain entitlement carried by an
embedded provisioning profile (the hiss::provider::apple module docs list what it
takes). Everything after those two lines is identical to the Quickstart:
use ;
use ;
noise!
// Generated inside the enclave, persisted to the Keychain, never extractable.
let mut keys = new;
let static_key = keys.?;
// From here nothing is Apple-specific.
let = XXinitiator.write_message_1?;
The suite names P256 because the Secure Enclave implements that curve and no other.
This snippet is a compiled doctest on AppleSecureEnclave, marked no_run — running it
needs enclave hardware and a provisioned entitlement.
Bring your own
A provider is just a pair of traits, so a backend hiss has never heard of — an HSM, a
cloud KMS, a key store you already have — plugs in without touching the Noise core.
Implement CryptoKeyProvider (your key handle, your error type, generate a key, extract a
public key) and DhProvider (one method: dh), or their _async mirrors if the backend
genuinely suspends. Signing lives on separate traits that the Noise handshake never calls.
One hard requirement, and it is Noise's rather than hiss's: the handshake key-agrees only via raw Diffie–Hellman, so a backend qualifies only if it will hand back the shared secret. A backend that can sign but never expose a DH result cannot carry the channel — it fits an identity layer around it instead.
Platforms
- All platforms: the software backend (
EphemeralOnly). - macOS / iOS: the Apple Secure Enclave backend.
hiss depends on no async runtime on any platform. The *Async provider traits exist
for backends that genuinely await I/O; where the underlying calls are blocking — as
Apple's Security-framework calls are — those futures do the work on the thread that polls
them. Keeping that off your executor is the application's call, not the library's.
Cargo features
| Feature | Default | Effect |
|---|---|---|
x25519-cryptoxide |
yes | Backs X25519's software Diffie–Hellman with cryptoxide's implementation (the faster backend). --no-default-features falls back to the eccoxide ladder; the output is byte-for-byte identical, so this only changes which dependency carries the primitive. |
hiss has one feature, and it only picks a backend for a primitive. There is no
feature that turns the library's API on or off: noise! needs none, because
hiss-macros is a required dependency and the macro is re-exported as
hiss::noise!.
How this is tested
No audit has happened. This is what stands in for one — every row runs in CI on each commit.
| Check | What it establishes |
|---|---|
Interoperability with snow |
28 tests over P-256 and 5 over X25519 run one side of a handshake with hiss and the other with snow, then require both to derive the same handshake hash and to exchange transport messages in both directions. A one-byte disagreement between the two implementations fails the suite. These live in the separate hiss-interop crate and run weekly plus on demand — not on every cargo test, and not as a release gate; what runs per-commit is the frozen vectors above. |
| Frozen known-answer vectors | 17 tests replay byte-for-byte expectations across all seventeen patterns, with ephemerals pinned by a scripted RNG, checking every handshake ciphertext, the final handshake hash, and the transport ciphertexts. These were generated from snow, not from a standards body — P-256 is not in the Noise specification, so no third-party vectors exist for it. Treat them as a regression lock, not independent conformance. |
| Wycheproof | 484 ECDSA and 355 ECDH secp256r1 vectors from Google's Project Wycheproof, vendored verbatim at a pinned commit and run as library unit tests. Third-party and adversarial: malformed points, edge-case scalars, signature malleability. |
| Negative tests | 26 tests assert the failures. Twenty-one are tamper sweeps — every byte of every handshake message of the eleven patterns swept, plus every byte of a transport record; the rest reject a non-canonical ephemeral, a wrong PSK, a replay, and an out-of-order record, and pin the twenty on-wire message sizes of those eleven. (The sweeps stop at eleven deliberately: every message token list in the other six already appears among them, so extending would re-test identical machinery.) There is deliberately no truncation sweep: a wrong-length message is a compile error, not a runtime rejection, so that case is pinned by a compile_fail doctest instead. |
| Compile-fail tests | 12 trybuild cases pin the compiler diagnostics for malformed patterns, so "it will not build" stays true and keeps saying something useful. Separate compile_fail doctests cover the §7.3 pattern guard and the wrong-length message case. |
| Coverage floor | CI fails the build below 80% lines / 75% regions. |
Alongside these, each commit is gated on clippy with warnings denied, a documentation
build with warnings denied, and a build on the declared MSRV.
None of that is an audit, and none of it is a substitute for one.
Security
This crate has not been independently audited and is pre-1.0. Do not use it to protect anything you cannot afford to lose. That said, the crypto core is built to be responsible:
A cryptographic property belongs to whatever actually computes it. Some of these are the crate's own and hold under any provider; the rest are a backend's, and do not transfer to the other one. They are listed apart for that reason — a guarantee about the software provider says nothing about the Secure Enclave.
Under any provider:
- Noise's 65535-byte message-length limit is enforced at the cipher-state chokepoint.
- Peer public keys are parsed and validated by
hissbefore a provider ever sees them; operations on attacker-supplied points returnResultrather than panicking. - Secret material is zeroized on drop — pre-shared keys, shared secrets, cipher state
and symmetric state, and the datagram receive ratchet all wipe their bytes — and no
provider is required to make its private key
Clone. - The Noise
25519and448curves perform no low-order or contributory-key check. Per RFC 7748 a low-order peer key yields an all-zero shared secret rather than an error.
EphemeralOnly — software, every platform:
- Constant-time P-256 scalar multiplication via
eccoxide's constant-time backend. - Deterministic ECDSA (RFC 6979), low-S normalized, no signing RNG.
- A degenerate (point-at-infinity) P-256 ECDH result is rejected rather than returned.
- Private keys are zeroized on drop — they are raw scalars sitting in your memory.
AppleSecureEnclave — macOS, iOS: its P-256 arithmetic is the platform's, so none of
the four above are hiss's to promise, and hiss does not verify them.
- ECDSA is randomized, not RFC 6979, and not low-S — the framework derives its own
nonce, and
hissdecodes the DER it returns without normalizing. Signing the same message twice yields different signatures. - The DH result is taken as given, beyond checking it is 32 bytes;
hissadds no degeneracy check of its own on this path. (A parsed public key cannot hold the identity on either provider, so the software check above is defence in depth, not a fix.) - A P-256 private key is never in your process to zeroize — you hold a
SecKeyhandle. Its Ed25519 keys are software, over a hardware-sealed seed, and do zeroize.
The Noise handshake never signs, so the ECDSA rows concern the identity layer around a channel rather than the channel itself.
Please report security issues privately to the maintainers rather than opening a public issue.
Minimum supported Rust version
hiss uses the Rust 2024 edition and declares an MSRV of 1.96, enforced in CI by
the msrv job (cargo check --all-features --all-targets on the pinned toolchain).
The MSRV tracks a recent stable, floored at stable − 3: it is bumped only once it
would fall more than three releases behind current stable. It is set at the current
stable today and will begin moving once stable advances past 1.99. The declared value
lives in Cargo.toml (rust-version); keep it and the msrv CI job in lockstep.
License
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.