hiss 0.1.0

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

`hiss` is a maximally-static implementation of a curated subset of the
[Noise Protocol Framework](https://noiseprotocol.org/), with a pluggable
crypto-provider abstraction (software and Apple Secure Enclave backends).

> ⚠️ **`hiss` is pre-1.0 (`0.1.x`) and has not been independently audited.**
> Do not use it to protect anything you cannot afford to lose. The API and the
> security posture described here may change before 1.0.

This document states what `hiss` defends against, what it does not, how to report
a vulnerability, and the concrete cryptographic posture of each backend — with the
validated evidence behind every claim.

## Reporting a vulnerability

**Please report security issues privately. Do not open a public issue or PR for a
suspected vulnerability.**

Use GitHub's private vulnerability reporting:

➡️ **<https://github.com/primetype/hiss/security/advisories/new>**

(Repository → **Security** tab → **Report a vulnerability**.)

Please include enough detail to reproduce — the affected backend/pattern, a proof
of concept or failing test if you have one, and the version/commit. We will
acknowledge the report, work with you on an assessment, and coordinate a fix and
disclosure. As a pre-1.0 project run by a small team there is no formal SLA, but we
treat cryptographic issues as the highest priority.

If you cannot use GitHub advisories, contact the maintainer listed in
[`Cargo.toml`](Cargo.toml).

### Supported versions

| Version | Supported |
|---------|-----------|
| `0.1.x` (latest) | ✅ |
| anything older   | ❌ |

Being pre-1.0, only the most recent `0.1.x` release receives fixes; there are no
backports.

## Scope

`hiss` deliberately implements a **small, curated surface** rather than the full
Noise matrix. The security claims below apply only to this surface:

| Component | Supported |
|-----------|-----------|
| Handshake patterns | `N`, `K`, `Kpsk0`, `IKpsk1`, `IK`, `NK`, `IX`, `XK`, `NN`, `XX`, `X` (an 11-pattern subset) |
| Noise DH curves | NIST **P-256** (the Noise DH curve), **X25519** (the Noise `25519` curve, Curve25519 / RFC 7748), and **X448** (the Noise `448` curve, RFC 7748) |
| Signing curve | **Ed25519** (standalone signing; not used as a Noise DH curve) |
| AEAD cipher | **ChaCha20-Poly1305** |
| Hash | **BLAKE2b-512** |
| Backends | **Software** (default, all platforms incl. WASM) and **Apple Secure Enclave** (macOS/iOS, opt-in) |

> **The non-P-256 DH curves carry a narrower assurance surface.** All three DH
> curves (`P-256`, `25519`, `448`) are fully shipped Noise handshake curves, but
> the validation behind them differs:
>
> - **Contributory-behaviour / low-order check.** P-256 ECDH **rejects** a
>   degenerate (point-at-infinity) shared secret. **X25519 and X448 do not perform
>   any low-order or contributory key check** — per RFC 7748 a low-order peer key
>   simply yields an **all-zero shared secret** rather than an error. This is
>   spec-conformant (the check is optional in RFC 7748), but it means the Curve25519
>   / Curve448 paths give the caller no signal on a degenerate DH.
> - **Frozen known-answer coverage is P-256-only.** The frozen Noise KAT corpus
>   exists for **P-256 only**. X25519 is validated by **byte-for-byte interop with
>   [snow](https://crates.io/crates/snow)** across the `N` / `IK` / `XX` patterns;
>   X448 has a single self round-trip (`XX`). There are **no frozen X25519/X448
>   KAT vectors yet**. See [Validated test vectors](#validated-test-vectors).
>
> Ed25519 is exposed for **standalone** signing (and a Curve25519 DH) through the
> provider API; it is not used as the curve inside a Noise pattern.

## Threat model

### Adversary

We assume an active network attacker who can read, drop, reorder, replay, and
inject ciphertext on the wire, and who may know the long-term static public keys of
the parties. The attacker does **not** know the private keys held by an honest party
or a pre-shared key (PSK) they do not possess, and does not control the honest
party's host OS or RNG.

### In scope

- Confidentiality and integrity of payloads, to the degree each handshake pattern
  provides (see the table below).
- Sender/recipient authentication, to the degree each pattern provides.
- Rejection of tampered, truncated, over-length, replayed, and out-of-order
  messages (systematically tested; see [Validated test vectors](#validated-test-vectors)).
- Rejection of malformed or invalid peer public keys without panicking. For
  **P-256**, rejection extends to a **degenerate (identity) DH output**; for
  **X25519 / X448**, a low-order peer key yields an **all-zero shared secret**
  rather than an error (per RFC 7748 — see the [Scope](#scope) note).
- Deterministic, non-malleable ECDSA signing (RFC 6979 + low-S).
- Constant-time secret-scalar **point** multiplication in the software P-256
  backend (subject to the upstream caveat in [Side-channel posture](#side-channel-posture-per-backend)).

### Out of scope

- A compromised host OS, a malicious or broken RNG supplied by the caller, or
  physical/hardware attacks beyond what the platform or Secure Enclave mitigates.
- Traffic analysis (message sizes and timing of the application protocol itself).
- Patterns, curves, ciphers, or hashes outside the [Scope](#scope) table.
- Replay protection for the one-way patterns (`N`/`K`/`Kpsk0`/`X`) — they are one-shot
  seals by construction; replay resistance is a property only of the interactive
  `IK` / `IKpsk1` / `NK` / `IX` / `XK` / `NN` / `XX` handshakes. See the per-pattern table.
- Anything a caller does with secret bytes after copying them out of a `hiss` type
  (e.g. via `seed()`).

### Per-pattern security properties

These follow the payload-security properties defined by the Noise specification for
the one-way and interactive patterns. `hiss`'s conformance is validated by
byte-for-byte agreement with [snow](https://crates.io/crates/snow), the reference
Noise implementation — **not** by an independent standards-body vector set, because
P-256 is not part of the Noise spec (see the provenance note under
[Validated test vectors](#validated-test-vectors)). The full per-pattern matrix is
exercised on P-256; X25519 is exercised against snow across `N` / `IK` / `XX`, and
X448 has a single `XX` self round-trip (see [Validated test vectors](#validated-test-vectors)).

| Pattern | Flow | Sender authentication | Confidentiality to recipient | Forward secrecy | Replay resistance | PSK |
|---------|------|-----------------------|------------------------------|-----------------|-------------------|-----|
| **N** | one message (`-> e, es`) | **none** — sender is anonymous | encrypted to the recipient's known static key | sender-side only (fresh ephemeral per message); **lost if the recipient's static key is compromised** | **no** (one-way) | — |
| **K** | one message (`-> e, es, ss`) | sender's static key, **vulnerable to key-compromise impersonation** (auth rests on the static–static `ss` DH) | encrypted to the recipient's known static key | sender-side only | **no** (one-way) | — |
| **Kpsk0** | one message (`-> psk, e, es, ss`) | sender's static key **plus a PSK** (a second factor) | recipient's static key **plus the PSK** — an attacker needs the relevant private key *and* the PSK | sender-side only | **no** (one-way) | position 0 |
| **IKpsk1** | two messages (`-> e, es, s, ss, psk` / `<- e, ee, se`) | **mutual** — responder authenticated to the initiator via `es`/`ss` (the DHs that bind the responder's static key); initiator authenticated to the responder via `ss` then `se` — **plus a PSK** | recipient's static key plus the PSK; the initiator's identity is hidden from a passive eavesdropper | **full** once both ephemerals are mixed (`ee`) | **yes** (responder contributes a fresh ephemeral) | position 1 |
| **IK** | two messages (`-> e, es, s, ss` / `<- e, ee, se`) | **mutual** — responder authenticated to the initiator via `es`/`ss` (the DHs that bind the responder's static key); initiator authenticated to the responder via `ss` then `se` | encrypted to the recipient's known static key; the initiator's identity is hidden from a passive eavesdropper | **full** once both ephemerals are mixed (`ee`) | **yes** (responder contributes a fresh ephemeral) | — |
| **NK** | two messages (`-> e, es` / `<- e, ee`) | **none for the initiator — it is anonymous** (no static key); the **responder is authenticated to the initiator** via the `es` DH that binds the responder's known static key | encrypted to the responder's known static key | **full** once both ephemerals are mixed (`ee`) | **yes** (responder contributes a fresh ephemeral) | — |
| **IX** | two messages (`-> e, s` / `<- e, ee, se, s, es`) | **mutual** — no pre-known statics: the initiator is authenticated to the responder via `se`, the responder to the initiator via `es`; both statics are sent in-handshake | payload encrypted after `ee`; **the initiator's static identity is sent in the clear in msg1 (before any DH) and is exposed to a passive eavesdropper**; the responder's static is sent in msg2 after `ee` and is encrypted | **full** once both ephemerals are mixed (`ee`) | **yes** (responder contributes a fresh ephemeral) | — |
| **XK** | three messages (`-> e, es` / `<- e, ee` / `-> s, se`) | **mutual** — the responder is authenticated to the initiator via the `es` DH that binds the responder's known static key; the initiator is authenticated to the responder via `se` | encrypted to the responder's known static key; **the initiator's static identity is sent encrypted in msg3 (after `ee`) and is hidden from a passive eavesdropper** | **full** once both ephemerals are mixed (`ee`) | **yes** (responder contributes a fresh ephemeral) | — |
| **NN** | two messages (`-> e` / `<- e, ee`) | **none — both parties are anonymous** (no static keys); **no protection against an active man-in-the-middle** | only against a **passive** eavesdropper — there are no static keys, so a passive observer cannot read the traffic but an active MITM can impersonate either side | **full** once both ephemerals are mixed (`ee`) | **yes** (responder contributes a fresh ephemeral) | — |
| **XX** | three messages (`-> e` / `<- e, ee, s, es` / `-> s, se`) | **mutual** — no pre-known statics: the initiator is authenticated to the responder via `se`, the responder to the initiator via `es`; both statics are sent in-handshake | payload encrypted after `ee`; **both statics are sent encrypted (the responder's in msg2, the initiator's in msg3, both after `ee`), so both identities are hidden from a passive eavesdropper** | **full** once both ephemerals are mixed (`ee`) | **yes** (responder contributes a fresh ephemeral) | — |
| **X** | one message (`-> e, es, s, ss`) | sender's static key, **vulnerable to key-compromise impersonation** (auth rests on the static–static `ss` DH); the sender's static is sent **encrypted in-band** (after `es`), not pre-shared | encrypted to the recipient's known static key; **the sender's identity is hidden from a passive eavesdropper** (its static rides encrypted after `es`) | sender-side only (fresh ephemeral per message); **lost if the recipient's static key is compromised** | **no** (one-way) | — |

Notes:

- `N`, `K`, `Kpsk0`, and `X` are **one-way seals** (a single message, no response). They
  provide no replay protection and only sender-side forward secrecy; they are
  intended for encrypting data at rest to a known public key (for example, sealing a
  per-pair PSK to a device's own Secure Enclave key). Because there is no recipient
  ephemeral, compromise of the recipient's static private key exposes past payloads.
- `X` is a **one-way, sender-authenticated seal with sender-identity hiding**. Like `K`
  it authenticates the sender via the static–static `ss` DH, but where `K` pre-shares
  *both* statics out of band, `X` pre-knows only the recipient's static (`<- s`) and
  transmits the **sender's** static **encrypted in-band** (after `es` keys the cipher),
  so the sender's identity is hidden from a passive eavesdropper. Its single message is
  the same token sequence as `IK`'s msg1 with no responder reply.
- `IK` and `IKpsk1` are the **interactive mutually-authenticated handshakes**
  (`IKpsk1` layers a PSK on top of `IK`). Forward secrecy and replay resistance are
  established only after the second message (the `ee` DH).
- `NK` is an **interactive, responder-authenticated** handshake: the initiator is
  **anonymous** (it carries no static key), and only the responder is authenticated —
  to the initiator — via the `es` DH that binds the responder's known static key. Like
  the other interactive patterns, forward secrecy and replay resistance follow the
  second message (the `ee` DH).
- `IX` is an **interactive, mutually-authenticated** handshake with **no pre-messages**:
  neither party knows the other's static key in advance, so both transmit their statics
  *during* the handshake. The trade-off is identity privacy for the initiator — its
  static is sent in msg1 **before any DH runs, in the clear**, so a passive eavesdropper
  learns the initiator's identity (the responder's static, sent in msg2 after `ee`, is
  encrypted). Forward secrecy and replay resistance follow the second message (the `ee`
  DH). Use `IX` when neither side can pre-share the other's static and exposing the
  initiator's identity is acceptable; prefer `IK` when the responder's static is known in
  advance and initiator identity privacy matters.
- `XK` is an **interactive, mutually-authenticated, three-message** handshake with strong
  **initiator-identity privacy**. The initiator pre-knows the responder's static (pre-message
  `<- s`) and authenticates the responder early via the `es` DH; the initiator's own static is
  transmitted **encrypted in msg3, after `ee` keys the cipher**, so it is hidden from a passive
  eavesdropper, and is authenticated via `se`. Compared with `IK` (which sends the initiator's
  static in msg1), `XK` spends an extra round trip to give the initiator's identity full
  forward-secret confidentiality. Forward secrecy and replay resistance follow the second
  message (the `ee` DH).
- `NN` is the **unauthenticated** interactive handshake: **both parties are anonymous**
  (there are no static keys and no pre-messages), so it provides **no authentication of
  either side and no protection against an active man-in-the-middle** — an attacker who
  sits on the wire can complete a separate handshake with each party and relay traffic.
  Confidentiality holds **only against a passive eavesdropper**; forward secrecy and
  replay resistance follow the second message (the `ee` DH). Use `NN` only when an
  authenticated pattern is genuinely impossible, or layer authentication above it.
- `XX` is the **canonical interactive, mutually-authenticated, three-message** handshake
  with **no pre-messages**: neither party knows the other's static key in advance, so both
  transmit their statics *during* the handshake. Unlike `IX`, **both** statics are sent
  **encrypted** — the responder's in msg2 and the initiator's in msg3, each **after `ee`
  keys the cipher** — so **both identities are hidden from a passive eavesdropper**. The
  initiator is authenticated to the responder via `se` and the responder to the initiator
  via `es`. Forward secrecy and replay resistance follow the second message (the `ee` DH).
  Prefer `XX` over `IX` when initiator identity privacy matters and neither side can
  pre-share the other's static; prefer `IK`/`XK` when the responder's static is known in
  advance.
- A PSK (`Kpsk0`, `IKpsk1`) is an **additional** authentication and confidentiality
  factor layered on top of the asymmetric authentication — not a replacement for it.

## Side-channel posture (per backend)

### Software P-256 (default backend, all platforms)

Every **secret-scalar point multiplication** — key generation (`public()`), ECDH
(`dh()`), and the ECDSA signing nonce's `k·G` — routes to a **constant-time** routine
in the underlying `eccoxide` curve library: a fixed-base comb (`mul_base`) for
base-point multiplies and a fixed-window algorithm with a constant-time table scan
(`scale_am3_ct`) for variable-base multiplies. There is no secret-dependent branch
or table index on these paths.

Honest caveats:

- **This is an upstream property.** `hiss` does not implement or independently verify
  constant-time behaviour; it relies on the `eccoxide` release it depends on —
  currently the **crates.io `eccoxide` 0.4**, whose P-256 backend carries the
  constant-time scalar multiplication. The guarantee is therefore only as strong as
  that upstream code.
- **The fixed-base comb relies on `eccoxide`'s `table` feature** (enabled by default
  in this crate's `eccoxide` dependency). Without it the fixed-base multiply is still
  correct and constant-time, just not the precomputed comb — this is a performance,
  not a constant-time, difference.
- **Scope is point multiplication.** ECDSA signing also performs a secret-scalar
  modular inversion (`k⁻¹`) and scalar arithmetic. In `eccoxide` 0.4 the inversion
  is a fixed addition-chain exponentiation (no secret-dependent branching by
  construction); the residual trust is `eccoxide`'s underlying scalar field being
  constant-time, which is **not** independently verified here.
- **Verification is intentionally variable-time.** ECDSA signature *verification*
  computes `u1·G + u2·Q` with a variable-time multiply (`mul_vartime`). Every input on
  the verify path — public key, signature, message hash — is public, so this is safe
  and faster; no secret is involved.

### Apple Secure Enclave (macOS/iOS, opt-in)

P-256 static private keys are generated **inside the Secure Enclave** and stored in
the Data Protection Keychain as non-exportable key references; the private key bytes
never leave the hardware. Side-channel resistance for those operations is the
platform's responsibility.

Honest caveats:

- **Ed25519 is not enclave-backed.** The Secure Enclave has no Ed25519 support, so
  Ed25519 signing is performed in **software** (via `cryptoxide`). For the Enclave
  provider, only the Ed25519 *seed's at-rest storage* is hardware-protected: the seed
  is sealed (Noise-`N` to the device's Secure-Enclave P-256 key) into a 129-byte
  envelope and stored as a data-protection Keychain item, identically on macOS and
  iOS. Protection of that item is `AfterFirstUnlockThisDeviceOnly` — i.e. device
  first-unlock, **not** a per-use biometric prompt.
- The Keychain seal/store/load round-trip is **not exercised in CI** (it needs a
  codesigned binary with the keychain entitlement and real Secure Enclave hardware);
  that test is marked `#[ignore]`.

### Ed25519 (software)

Ed25519 signing and verification are delegated to `cryptoxide`. `hiss` does not add an
independent constant-time audit of that implementation.

## Validated test vectors

The crypto and protocol layers are pinned against the following corpora, run as
in-tree tests:

| Suite | Count / scope | Source & format |
|-------|---------------|-----------------|
| Wycheproof ECDSA (secp256r1, SHA-256) | **484** vectors | Project Wycheproof, DER/ASN.1; every vector decoded and verified |
| Wycheproof ECDH (secp256r1) | **355** vectors | Project Wycheproof, `ecpoint` encoding |
| RFC 6979 deterministic ECDSA | Appendix A.2.5 (P-256/SHA-256) KAT | RFC 6979; raw `(r, s)` pinned for `"sample"` and `"test"` |
| NIST ECC CDH | P-256 `Count=0` | NIST CAVP ECDH vector |
| Noise handshake KATs (**P-256**) | patterns `N` / `K` / `Kpsk0` / `IKpsk1` / `IK` / `NK` / `IX` / `XK` / `NN` / `XX` / `X` | frozen, replayed byte-for-byte (handshake ciphertexts + handshake hash + transport) |
| Noise interop, **X25519** | patterns `N` / `IK` / `XX` | byte-for-byte agreement with `snow` (no frozen KAT vectors) |
| Noise round-trip, **X448** | pattern `XX` | single self round-trip (no frozen KAT vectors, no snow interop) |
| Negative / boundary sweeps | per-pattern, deterministic | every-byte tamper, every-prefix truncation, over-length, ciphertext bit-flip, replay, out-of-order, wrong-PSK → all rejected |

The Wycheproof corpora are third-party authoritative. The negative sweeps are
generated deterministically (each driver runs a genuine handshake that must complete
first, so the tests are non-vacuous).

> **Provenance of the Noise KATs (read this).** P-256 is **not** a curve in the Noise
> specification, and no third-party P-256 Noise test vectors exist. The frozen `hiss`
> Noise KATs are therefore **"agreement with snow"** — they assert byte-for-byte
> equality with the [snow](https://crates.io/crates/snow) reference implementation, not
> with a standards body. A latent bug shared with snow would not be caught by these
> vectors. The frozen KAT corpus is **P-256-only**: X25519 is covered by live snow
> interop (`N` / `IK` / `XX`) and X448 by a single `XX` self round-trip, but neither
> curve has a frozen KAT file yet — adding standards-body X25519/X448 vectors is future
> work.

## Signature malleability and encoding

- **Deterministic nonces.** ECDSA signing uses **RFC 6979** deterministic nonces
  (HMAC-SHA256 DRBG); no randomness is involved in signing, eliminating the
  catastrophic private-key leak from nonce reuse or a biased RNG.
- **Low-S on signing.** Produced signatures are normalized to the canonical **low-S**
  form (`s = min(s, n − s)`), so `hiss` never emits a malleable high-S signature.
- **Verification accepts high-S.** Verification follows standard ECDSA and accepts
  both low-S and high-S encodings (matching the Wycheproof `valid` expectations). A
  caller that needs strict low-S on *inbound* signatures must enforce it itself.
- **Strict DER (Apple path only).** When parsing DER signatures produced by Apple's
  Security framework, the ASN.1 reader is strict: it rejects long-form/indefinite
  lengths, non-minimal integer encodings, trailing data, and negative `r`/`s` (per
  X.690 §8.3.2). The pure-software P-256 path uses the fixed 64-byte `r‖s`
  representation and does not parse DER at all.

## Randomness requirements

`hiss` **pulls in no entropy source of its own.** `getrandom` is not a runtime
dependency of the published crate.

- The software provider is `EphemeralOnly<R>`, owning a **caller-supplied**
  `R: CryptoRng + RngCore`. **You must supply a cryptographically secure RNG.**
- With `rand` 0.9, `OsRng` is fallible (`TryRngCore`) and does **not** implement
  `RngCore`; seed a real CSPRNG, e.g. `StdRng::from_os_rng()`.
- P-256 key generation **rejection-samples** the secret scalar into `[1, n−1]`. On a
  broken RNG it fails with a typed `ScalarSamplingFailed` error after a bounded number
  of retries (per-iteration miss probability `< 2⁻³²`) rather than returning a biased
  key. (With a sound CSPRNG this path is effectively unreachable.)
- `Psk::generate` is **infallible**: any 32 random bytes form a valid PSK, so there is
  no error path to surface.

## Secret handling and zeroization

Primary secret types are wiped on drop using volatile writes followed by a
best-effort compiler fence:

- P-256 and Ed25519 private keys
- `Psk`
- `SharedSecret`
- `CipherState` (AEAD key + nonce)
- `SymmetricState` (chaining key; handshake mix/`split` intermediates are wiped inline)

**Honest limits:**

- **The fence is best-effort.** It defeats dead-store elimination of the wipe, but it
  is not a hardware memory barrier and gives no guarantee against secret copies that
  the compiler placed in CPU registers or spilled to the stack.
- **Library-owned intermediates are not zeroized.** Secret values materialized inside
  the underlying libraries — `eccoxide` `Scalar`/`Point` values created during DH,
  signing, and key generation, and `cryptoxide`-internal expansion buffers — are
  **not** wiped, because `hiss` cannot reach inside those library-owned types. This is
  a known, accepted limitation that depends on upstream support to close.
- **`seed()` exposes raw secret bytes.** `SoftwareEd25519PrivateKey::seed()` returns
  the raw 32-byte seed (needed to persist/seal the key). The owning key zeroizes on
  drop, but any bytes a caller copies out are the caller's responsibility.

## Known limitations and non-goals

- **Not audited; pre-1.0.** No external cryptographic audit has been performed; the
  API and posture may change before 1.0.
- **Curated surface.** Only the patterns, curves, cipher, and hash in [Scope](#scope)
  are implemented — not the full Noise matrix.
- **No `fallback` / compound protocols.** The Noise `fallback` modifier (`XXfallback`,
  Noise Pipes / 0-RTT-with-retry) is intentionally not implemented. It is optional in the
  spec and unnecessary for the targeted use cases — a deliberate scoping decision, not an
  oversight. `snow` omits it likewise.
- **Noise KAT provenance.** The frozen P-256 Noise vectors are agreement-with-snow,
  not standards-body vectors; X25519 has live snow interop only and X448 a single
  self round-trip — no frozen X25519/X448 KATs yet (see above).
- **Upstream constant-time dependency.** Constant-time P-256 rests on `eccoxide`'s
  released crates.io `0.4`; the property is only as strong as that upstream backend,
  which `hiss` does not independently verify.
- **Unproven Enclave path.** The Apple Keychain seal/store/load path is not exercised
  in CI (requires codesigned binary + Secure Enclave hardware).

---

*This document describes the security posture of `hiss` as of `0.1.x`. If you find a
discrepancy between this document and the code, that is itself a bug worth reporting.*