hiss 0.1.0

Static, type-level Noise Protocol Framework with pluggable hardware-backed crypto.
Documentation

hiss

Static Noise. The Noise Protocol Framework, resolved at compile time.

A Noise<Pattern, Curve, Cipher, Hash> is zero-sized: the handshake pattern, curve, cipher, and hash are encoded as types, so every buffer size is a const and every protocol misuse — a token out of order, a wrong-direction message — is a compile error. Get the handshake wrong and it never builds.

Secret keys live in software or behind a pluggable, hardware-backed provider (Apple Secure Enclave today), and are wiped on drop.

Status: pre-release (0.1), not yet published. The API is unstable and the code has not been independently audited. See Security before relying on it.

What it offers

  • Compile-time protocol selection. The pattern, curve, cipher, and hash are zero-sized type parameters; message sizes are associated constants. Misuse is rejected by the type system rather than at runtime.
  • A small, deliberate crypto core. Production cryptography is cryptoxide + eccoxide only — no sprawling dependency surface.
  • Pluggable crypto providers. A trait family — CryptoKeyProvider<C> (keygen) at the base, with DhProvider<C> adding the ECDH the handshake actually consumes and SigningProvider for identity signing, each paired with an …Async refinement — lets the same handshake run against a software backend or a hardware-backed one (Apple Secure Enclave). See Providers.
  • Two streaming drivers. A blocking std::io driver (SyncHandshake, always available) and an optional tokio::io driver (AsyncHandshake, behind the async-io feature). The buffer / no-syscall / one-shot case is simply passing an in-memory Io — a Cursor, Vec, or &mut [u8] — to the synchronous driver; there is no separate buffer API.

Supported suite

This 0.1 targets one cipher suite and a fixed set of patterns:

Axis Supported
Patterns N, K, Kpsk0, IKpsk1, IK, NK, IX, XK, NN, XX, X
Curves NIST P-256 (secp256r1), X25519 (Curve25519, the Noise 25519 curve), and X448 (the Noise 448 curve)
Cipher ChaCha20-Poly1305
Hash BLAKE2b

Conformance is anchored against snow via an interop test suite. All eleven fundamental patterns are present; additional hashes and ciphers (AES-GCM) are planned for 0.2+.

The fallback modifier — and the compound protocols it enables (e.g. Noise Pipes / 0-RTT-with-retry) — is an intentional non-goal, not a missing feature. It is optional in the Noise spec, which presents it only as an illustrative building block, and is unnecessary for the targeted use cases; snow omits it for the same reason.

Quickstart

The N pattern is a one-way, sender-anonymous seal: anyone who knows a recipient's static public key can send it one confidential, authenticated message, with no reply. The whole exchange is the single Noise message -> e, es; we build it over X25519 in five steps. (This mirrors the crate-level doctest, which compiles and runs each step.)

1. The recipient's static key pair

N authenticates the recipient, so it owns a long-term static key pair and the sender must already know its public half (shared out of band). X25519 is Diffie–Hellman over Curve25519 (RFC 7748) — the curve Noise calls 25519.

use hiss::provider::{EphemeralOnly, ProviderExt};
use hiss::noise::X25519;

// `EphemeralOnly` is the software backend; it wraps a CSPRNG.
let mut recipient = EphemeralOnly::new(rand::rng());

let recipient_static = recipient.generate::<X25519>()?; // secret half — never shared
let recipient_pub = recipient.public(&recipient_static)?; // public half — the sender knows this

2. The sender begins N and pins the recipient's static

The sender drives the Initiator side. N's initiator is anonymous — no static key of its own — so the recipient learns only that the sender knew its public key. set_rs supplies that known key (N's <- s pre-message).

use hiss::noise::{Blake2b, ChaChaPoly, Initiator, Noise, SyncHandshake, pattern};

// The full protocol name: Noise_N_25519_ChaChaPoly_BLAKE2b.
type NoiseN = Noise<pattern::N, X25519, ChaChaPoly, Blake2b>;

let handshake = SyncHandshake::<NoiseN, Initiator, _, _, _, _>::initiate(
    EphemeralOnly::new(rand::rng()), // the sender's own RNG
    &[],                             // prologue: shared context, if any
    Vec::<u8>::new(),                // the sink the message bytes are written to
)
.set_rs(recipient_pub);

3. Write the message (-> e, es) and seal the payload

e writes a fresh ephemeral public key to the wire; es mixes DH(ephemeral, recipient-static) into the cipher key. After es the channel is keyed, so into_parts returns the live sender and the handshake message; the payload then rides in the first transport record.

let (mut sender, message) = handshake.e()?.es()?.into_parts();

let quote = b"Not all those who wander are lost.";
let mut sealed = vec![0u8; quote.len() + 16]; // +16 for the AEAD tag
let n = sender.send(quote, &mut sealed)?;

4. The recipient receives the message

The recipient drives the Responder side with its static private key (set_s) and replays the same tokens, recomputing the identical es secret without ever putting a key on the wire.

use hiss::noise::Responder;

let handshake = SyncHandshake::<NoiseN, Responder, _, _, _, _>::respond(
    recipient,                     // drives this side, holding the static key
    &[],                           // the same prologue
    std::io::Cursor::new(message), // read the sender's message
)
.set_s(recipient_static)?;

let (_their_ephemeral, recv) = handshake.recv().e()?;
let mut transport = recv.es()?;

5. Decrypt

Both ends now hold the same transport key, so the recipient opens the sealed record — authenticated end to end: only someone who knew the recipient's public key could have produced it.

let mut opened = vec![0u8; sealed.len()];
let m = transport.transport().receive(&sealed[..n], &mut opened)?;
opened.truncate(m);

assert_eq!(&opened, quote); // "Not all those who wander are lost."

The buffer / no-syscall case is just an in-memory Io (a Cursor, Vec, or &mut [u8]), as the example above shows; with the async-io feature the same chain runs over tokio::io via AsyncHandshake. The mutual-authentication patterns (K, Kpsk0, IKpsk1, IK) follow the same builder shape with additional pre-message setters; NK is interactive and responder-authenticated (the initiator is anonymous) and likewise pre-knows the responder's static key via set_rs. XK is a three-message, mutually-authenticated handshake with strong initiator-identity privacy: it pre-knows the responder's static via set_rs/set_s like IK, but defers the initiator's own static to an encrypted third flight, so the initiator's identity is hidden from a passive eavesdropper. IX is interactive and mutually authenticated but has no pre-message setters — neither side pre-knows the other's static; both transmit their static keys during the handshake as s tokens (the initiator's in the clear, the responder's encrypted). NN is the unauthenticated interactive pattern: both parties are anonymous (no static keys, no pre-message setters), so it offers confidentiality only against a passive eavesdropper — there is no protection against an active man-in-the-middle — with full forward secrecy once the ephemerals are mixed. XX is the canonical three-message, mutually-authenticated pattern: like IX it has no pre-message setters — neither side pre-knows the other's static — but unlike IX both statics are transmitted encrypted during the handshake (after ee keys the cipher), so both identities are hidden from a passive eavesdropper. Both parties send their static keys via s tokens; full forward secrecy follows the ee DH. X is a one-way authenticated seal: like the Quickstart's N it is a single message to a recipient whose static key the sender already pre-knows (via set_rs), but it additionally transmits the sender's own static key encrypted in-band (after es keys the cipher). The message is sender-authenticated via ss and the sender's identity stays hidden from a passive eavesdropper — IK's msg1 with no reply. Compared with K (which pre-shares both statics out of band), X carries the sender's static on the wire.

Providers

Standard Noise authenticates and key-agrees only via raw ECDH — there is no signature token in the handshake. A backend can therefore serve the Noise DH (key-agreement) role only if it can yield a value Noise can mix (the raw shared secret, or the result of Noise's exact HKDF over it). Backends that can only sign fit a separate identity/attestation layer around the channel, not inside it.

Backend DH (Noise channel) Identity / signing Status
Software (eccoxide) implemented
Apple Secure Enclave (macOS/iOS) implemented
Android Keystore / StrongBox planned (0.2+)
Linux TPM2 ✅ (policy-permitting) planned (0.2+)
AWS KMS ✅ (DeriveSharedSecret) planned (0.2+)
Windows CNG / Azure / GCP KMS ❌ (no raw ECDH) identity role only
PKCS#11 HSM, YubiKey, Ledger ❌ (no raw ECDH export) out of scope

A DH-capable backend is selected through the DhProvider / DhProviderAsync traits (both refining the CryptoKeyProvider keygen base), so additional backends can be added without touching the Noise core.

Platforms

  • All platforms: the software backend (EphemeralOnly) and the blocking std::io handshake driver.
  • macOS / iOS: the Apple Secure Enclave backend. Its blocking Security-framework calls are offloaded to a Tokio blocking thread pool for the async provider path.

Cargo features

Feature Default Effect
async-io no Adds the tokio::io streaming handshake driver (AsyncHandshake), pulling in tokio with its I/O extension traits. The blocking std::io driver needs no feature and no runtime.

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:

  • Constant-time P-256 scalar multiplication via eccoxide's constant-time backend.
  • Deterministic ECDSA (RFC 6979) with low-S normalization; no signing RNG.
  • Peer-key and DH-output validation — operations on attacker-supplied points return Result rather than panicking; a degenerate (point-at-infinity) shared secret is rejected.
  • Noise's 65535-byte message-length limit is enforced at the cipher-state chokepoint.
  • Secret material is zeroized on drop and is never required to be Clone.

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

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.