A clean-slate Rust implementation of HPKE (RFC 9180) with type-driven ciphersuite selection.
Read the announcement: hpke-ng: Faster, Smaller, Harder HPKE for Rust — for the full design rationale, benchmarks, and migration notes.
use hpke_ng::*;
use rand::rngs::SysRng;
use rand_core::UnwrapErr;
type Suite = Hpke<DhKemX25519HkdfSha256, HkdfSha256, ChaCha20Poly1305>;
let mut os = SysRng;
let mut rng = UnwrapErr(&mut os);
let (sk_r, pk_r) = DhKemX25519HkdfSha256::generate(&mut rng)?;
let (enc, ct) = Suite::seal_base(&mut rng, &pk_r, b"info", b"aad", b"hello")?;
let pt = Suite::open_base(&enc, &sk_r, b"info", b"aad", &ct)?;
assert_eq!(pt, b"hello");
# Ok::<_, hpke_ng::HpkeError>(())hpke-ng exists because three friction points in the existing Rust HPKE story kept producing real bugs and real overhead:
- Provider abstraction overhead. A trait-based pluggable backend pushes dispatch costs into hot paths and inflates the
Hpkestruct to hundreds of bytes — for a value the type system already knows. - Struct-owned PRNG hazard. When the
Hpkeinstance owns its RNG, cloning silently aliases randomness state. The fix is structural: don't own it. - Type-system gaps.
Option<&[u8]>for mode-specific parameters turns missing-PSK and wrong-mode into runtime errors that should be compile errors.
The design takes one position on each: no provider abstraction, no owned RNG, type parameters instead of mode enums. The math is a solved problem; the surrounding library is where the engineering still has slack.
- Type-parameterized API.
Hpke<K, F, A>is zero-sized; the ciphersuite lives in the type system. Mismatched primitives are compile errors. - Four explicit methods per mode.
seal_base,seal_psk,seal_auth,seal_auth_psk— noOption<&[u8]>parameters for required-by-mode arguments. - Validated PSK bundle. PSK modes take a single
Psk, built byPsk::new(secret, id), which enforces the RFC 9180 32-byte minimum up front. The PSK and its identifier — one secret, one usually public — cannot be transposed at a call site. - Auth restricted to DHKEMs at the type level.
Hpke::<XWingDraft06, ...>::seal_auth(...)does not compile. - Export-only restricted at the type level.
Hpke::<_, _, ExportOnly>::seal_base(...)does not compile; only*_export*methods are available. - Type-tagged keys. Private keys carry their KEM in their type, so passing a
DhKemP256key into an X25519 suite is rejected by the compiler, not at runtime. - Caller-provided RNG. No PRNG owned by the configuration; cloning cannot alias randomness.
- Structural nonce-reuse prevention.
Contextis non-cloneable and refuses to encrypt atseq == u64::MAX. no_std+alloc.HpkeErrorimplementscore::error::Errorregardless; the defaultstdfeature only forwardsstdto thesubtledependency.- One provider stack. All primitives from RustCrypto-org crates.
| Operation | Elsewhere | hpke-ng |
|---|---|---|
Calling seal_auth on a non-DH KEM |
Runtime error | Compile error |
| Using a wrong-KEM private key | Runtime mismatch | Compile error (type-tagged) |
| Base-mode call with a PSK supplied | Runtime error | Compile error (no PSK param) |
Encrypt with an ExportOnly AEAD |
Runtime error | Compile error |
Transposing psk and psk_id |
Silent wrong key | Not expressible (one Psk arg) |
| Component | Variants |
|---|---|
| KEMs | DhKemX25519HkdfSha256, DhKemX448HkdfSha512, DhKemP256HkdfSha256, DhKemP384HkdfSha384, DhKemP521HkdfSha512, DhKemK256HkdfSha256 |
KEMs (post-quantum, pq feature) |
XWingDraft06, MlKem768, MlKem1024 — registered by draft-ietf-hpke-pq, not RFC 9180 |
| KDFs | HkdfSha256, HkdfSha384, HkdfSha512 |
| AEADs | Aes128Gcm, Aes256Gcm, ChaCha20Poly1305, ExportOnly |
| Modes | Base, Psk, Auth, AuthPsk |
hpke-ng is benchmarked head-to-head against the two major Rust HPKE libraries, hpke-rs and rust-hpke, across 137 benchmark cells (76 against hpke-rs, 61 against rust-hpke) spanning every supported ciphersuite. A cell counts as a tie when the two medians fall within ±2% of each other.
| Comparison | Cells | Wins | Ties | Losses |
|---|---|---|---|---|
vs hpke-rs |
76 | 61 | 13 | 2 |
vs rust-hpke |
61 | 38 | 7 | 16 |
| Combined | 137 | 99 | 20 | 18 |
rust-hpkehas no standalone ML-KEM-768 / ML-KEM-1024 and no secp256k1 support, so those ciphersuites are scored only againsthpke-rs.
Most of the speedup traces to two pieces of caching. On the decapsulation path, hpke-ng stores the expanded FIPS 203 decapsulation key directly in the PrivateKey, whereas hpke-rs rebuilds it from the seed on every setup_receiver. For classical KEMs, caching the recipient's serialized public key alongside the secret removes a redundant base-point scalar multiplication on every decap.
| Operation | vs hpke-rs |
vs rust-hpke |
|---|---|---|
| ML-KEM-768 / 1024 decap | 54–56% faster | n/a |
| X25519 decap | 44% faster | 51% faster |
| X-Wing decap | 38% faster | ≈ parity ¹ |
| ML-KEM encap | 33–41% faster | n/a |
| X-Wing encap | 15% faster | ≈ parity |
¹ rust-hpke wraps raw decap inside a full HPKE setup, so the closest comparison is hpke-ng's setup_receiver, which lands at roughly parity.
| Operation | vs hpke-rs |
vs rust-hpke |
|---|---|---|
| Export (all 5 output lengths) | 71–76% faster | — |
| Single-shot open (all payloads) | 13–41% faster | 8–47% faster |
| AES-128-GCM single-shot seal | 9–22% faster (≤ 16 KiB) | 29–51% faster (≤ 4 KiB); slower ≥ 16 KiB |
Post-setup Context::seal (64 B) |
13% faster | 44% faster |
| End-to-end roundtrip (1 KiB) | 30% faster | 49% faster |
Export is the largest sustained advantage over hpke-rs. For bulk AEAD the per-byte rates converge as payloads grow: rust-hpke pulls ahead on AES-GCM at ≥ 16 KiB, and on post-setup Context::seal at ≥ 1 KiB, once framing overhead stops dominating.
(n/a = unsupported by that library; — = no separate head-to-head figure reported.)
| Quantity | hpke-rs | hpke-ng | rust-hpke |
|---|---|---|---|
Hpke<K, F, A> struct |
344 bytes | 0 bytes (PhantomData) |
n/a |
Context<_, _, ChaCha20Poly1305> struct |
424 bytes | 88 bytes | 96 bytes |
Context<_, _, ExportOnly> struct |
n/a | 56 bytes | 184 bytes |
Context<_, _, Aes128Gcm> struct |
424 bytes | 792 bytes | 912 bytes |
Context<_, _, Aes256Gcm> struct |
424 bytes | 1,048 bytes | 1,168 bytes |
| Minimal release binary | 586 KB | 370 KB (~37% smaller than hpke-rs) | 385 KB |
Notes on the table above:
rust-hpkehas no typed configuration handle — it uses freesetup_sender/setup_receiverfunctions rather than a struct likeHpke<K, F, A>, so that row is n/a. Its context types areAeadCtxS<A, Kdf, Kem>(sender) andAeadCtxR<A, Kdf, Kem>(receiver), measured here asAeadCtxSwithX25519HkdfSha256+HkdfSha256.- Context size grows by
Nhbytes with a larger KDF — e.g. +32 bytes forHkdfSha512. ExportOnlymaps torust-hpke'sExportOnlyAead. It is larger there (184 B vs 56 B) becauserust-hpke'sAeadCtxalways reserves space for a full nonce buffer regardless of the AEAD variant.- The AES-GCM
Contextrows are larger inhpke-ngthan inhpke-rsbecause the expanded round keys + GHash table are cached inline — which is exactly what eliminates the per-call AES key-schedule cost inContext::seal. AES-GCM streaming trades memory for throughput;ChaCha20-Poly1305is unaffected.
Build with RUSTFLAGS="-C target-cpu=native" to pick up AES-NI / SHA-NI where available; [profile.bench] in Cargo.toml sets lto = "thin" and codegen-units = 1. For the head-to-head numbers:
cargo bench --features comparative --bench comparativeThis loads both hpke-rs (with its experimental feature, so the post-quantum KEM stubs are wired up) and rust-hpke 0.14 (whose X-Wing support the comparison needs) as dev-dependencies, and emits side-by-side criterion results for every supported ciphersuite. KEM-op rows for hpke-rs and rust-hpke carry a _via_setup_* suffix: neither library exposes raw encap / decap separable from setup, so those rows are explicitly not apples-to-apples with hpke-ng's bare-operation rows.
The library responds to two classes of issue observed in prior implementations:
- Zero shared-secret check (RFC 9180 §7.1.4). Enforced for every DH group — X25519, X448 and the four prime-order curves — with
subtle::ConstantTimeEq. It is unreachable on the prime-order curves given a validated public key and a non-zero scalar; it is kept there as defense in depth and for parity. - Nonce counter wraparound. Prevented structurally:
Contextuses au64sequence number, refuses to encrypt atu64::MAX, and is non-cloneable so a counter cannot fork.
The post-DH all-zeros check is constant-time. Context cannot be Cloned, so two ciphertexts cannot be produced under the same (key, nonce) from two copies of the same context.
This crate composes RustCrypto primitives. Constant-time properties are inherited from those crates:
| Primitive | CT property |
|---|---|
| X25519, X448 | CT by construction. |
| P-256, P-384, P-521, secp256k1 | CT in arithmetic mode (pinned). |
| HKDF-SHA-{256,384,512} | CT (deterministic; no secret-dependent branches). |
| ChaCha20-Poly1305 | CT by construction. |
| AES-128-GCM, AES-256-GCM | CT only with hardware AES-NI/PCLMULQDQ. Prefer ChaCha20Poly1305 on platforms without these instructions. |
| ML-KEM, X-Wing | CT per upstream documentation; both crates are pre-1.0. |
Everything this crate controls is scrubbed: private keys, shared secrets, PRKs, candidate scalars, seeds, and the derived AEAD key and base nonce are held in Zeroizing/ZeroizeOnDrop wrappers, with explicit manual scrubbing wherever an upstream type lacks zeroize-on-drop (the X448 scalar and shared-secret point, GenericArray temporaries from SecretKey::to_bytes and HkdfExtract::finalize, and the ML-KEM/X-Wing shared-secret arrays).
Known limitation — HKDF/HMAC internal state. The RustCrypto hkdf/hmac crates do not zeroize their internal HMAC state on drop. Every HKDF extract/expand operation therefore leaves PRK-derived ipad/opad block state transiently in freed memory. This is key-equivalent material: an attacker who can read process memory (core dumps, swap, a same-process memory-disclosure bug) could recover it while the allocation remains unreused. The limitation is shared by every RustCrypto-based HPKE implementation and cannot be fixed from this crate. Deployments with a strong memory-forensics threat model should disable core dumps and swap (or use encrypted swap) for processes holding HPKE keys.
cargo test # library, roundtrip, negative matrix
cargo test --features pq # + post-quantum, X-Wing KAT, rust-hpke differential
cargo test --features pq,hazmat-kat-internals # + RFC 9180 KAT
cargo test --features pq,hazmat-kat-internals --test compile_fail # + compile-time invariant tests
cargo test --features pq,hazmat-differential,hazmat-kat-internals # + differential vs hpke-rsTo regenerate the compile-fail .stderr fixtures after an intentional change (e.g. a toolchain bump), run:
TRYBUILD=overwrite cargo test --features pq,hazmat-kat-internals --test compile_failThis rewrites the fixtures unconditionally and should not be used as the normal test invocation.
Coverage includes:
- Roundtrip matrix — 57 macro-generated tests across every ciphersuite × mode combination.
- Negative matrix — 19 tests asserting that each transcript input actually binds (
info,aad, PSK, PSK ID, sender public key), that tampered, truncated and replayed ciphertexts are refused, and that a failedopenleaves the sequence counter untouched. - Known-answer tests — the RFC 9180 vectors for X25519, X448, P-256, P-521 and secp256k1, plus the official X-Wing draft vectors.
- Cross-implementation differential — against
hpke-rs(X25519, P-256, secp256k1) and againstrust-hpke(P-384, P-521, ML-KEM-768, ML-KEM-1024, X-Wing, with X25519/P-256 as controls). Between the two, every supported ciphersuite is checked against either published vectors or an independent implementation. - Compile-fail tests — locking in the type-system invariants:
Contextis non-cloneable,ExportOnlycannot seal, PQ KEMs cannot authenticate, each key-schedule path rejects the wrong mode tag, and external crates cannot implement the sealed supertrait. - Unit tests — including direct verification of the RFC 9180 §5.2 nonce derivation formula (
nonce = base_nonce XOR I2OSP(seq, Nn)) at sequence-number boundaries. - Fuzzing — five
cargo-fuzztargets over the parsers,DeriveKeyPair, the key schedule, andopen; panics are treated as bugs.
The full suite (without the hpke-rs differential) runs in under two seconds.
Three mechanical steps, typically under an hour for a real codebase:
- Define a
type Suite = Hpke<K, F, A>;alias for the ciphersuite you use. - Replace
hpke.seal(...)calls with the explicit mode method:Suite::seal_base,seal_psk,seal_auth, orseal_auth_psk. - Thread
&mut rngthrough call sites — the configuration no longer owns one.
See the announcement post for a worked example.
hpke-ng is a joint project between Nadim Kobeissi and Daniel Dia.
Licensed under either of Apache License, Version 2.0 or MIT license at your option.