Skip to content

Latest commit

 

History

History
209 lines (149 loc) · 15.7 KB

File metadata and controls

209 lines (149 loc) · 15.7 KB

hpke-ng

CI License

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>(())

Why a new HPKE crate?

hpke-ng exists because three friction points in the existing Rust HPKE story kept producing real bugs and real overhead:

  1. Provider abstraction overhead. A trait-based pluggable backend pushes dispatch costs into hot paths and inflates the Hpke struct to hundreds of bytes — for a value the type system already knows.
  2. Struct-owned PRNG hazard. When the Hpke instance owns its RNG, cloning silently aliases randomness state. The fix is structural: don't own it.
  3. 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.

Design highlights

  • 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 — no Option<&[u8]> parameters for required-by-mode arguments.
  • Validated PSK bundle. PSK modes take a single Psk, built by Psk::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 DhKemP256 key 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. Context is non-cloneable and refuses to encrypt at seq == u64::MAX.
  • no_std + alloc. HpkeError implements core::error::Error regardless; the default std feature only forwards std to the subtle dependency.
  • One provider stack. All primitives from RustCrypto-org crates.

Compile-time guarantees

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)

Supported ciphersuites

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

Performance

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-hpke has no standalone ML-KEM-768 / ML-KEM-1024 and no secp256k1 support, so those ciphersuites are scored only against hpke-rs.

Where the KEM wins come from

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.

AEAD and single-shot throughput

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.)

Memory and binary footprint

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-hpke has no typed configuration handle — it uses free setup_sender / setup_receiver functions rather than a struct like Hpke<K, F, A>, so that row is n/a. Its context types are AeadCtxS<A, Kdf, Kem> (sender) and AeadCtxR<A, Kdf, Kem> (receiver), measured here as AeadCtxS with X25519HkdfSha256 + HkdfSha256.
  • Context size grows by Nh bytes with a larger KDF — e.g. +32 bytes for HkdfSha512.
  • ExportOnly maps to rust-hpke's ExportOnlyAead. It is larger there (184 B vs 56 B) because rust-hpke's AeadCtx always reserves space for a full nonce buffer regardless of the AEAD variant.
  • The AES-GCM Context rows are larger in hpke-ng than in hpke-rs because the expanded round keys + GHash table are cached inline — which is exactly what eliminates the per-call AES key-schedule cost in Context::seal. AES-GCM streaming trades memory for throughput; ChaCha20-Poly1305 is unaffected.

Reproducing the benchmarks

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 comparative

This 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.

Security posture

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: Context uses a u64 sequence number, refuses to encrypt at u64::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.

Constant-time considerations

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.

Zeroization

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.

Testing

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-rs

To 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_fail

This 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 failed open leaves 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 against rust-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: Context is non-cloneable, ExportOnly cannot 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-fuzz targets over the parsers, DeriveKeyPair, the key schedule, and open; panics are treated as bugs.

The full suite (without the hpke-rs differential) runs in under two seconds.

Migration from hpke-rs

Three mechanical steps, typically under an hour for a real codebase:

  1. Define a type Suite = Hpke<K, F, A>; alias for the ciphersuite you use.
  2. Replace hpke.seal(...) calls with the explicit mode method: Suite::seal_base, seal_psk, seal_auth, or seal_auth_psk.
  3. Thread &mut rng through call sites — the configuration no longer owns one.

See the announcement post for a worked example.

Authors

hpke-ng is a joint project between Nadim Kobeissi and Daniel Dia.

License

Licensed under either of Apache License, Version 2.0 or MIT license at your option.