The Verification Theater: Breaking hpke-rs
Feb 13, 2026·6 min read·0 visits
Executive Summary (TL;DR)
The `hpke-rs` library, used for Hybrid Public Key Encryption, contained multiple critical flaws: it failed to validate X25519 shared secrets (allowing key compromise), used a 32-bit counter for nonces (leading to wrap-around and nonce reuse), and truncated KDF inputs. These issues allow for complete session compromise and plaintext recovery.
A collection of critical cryptographic failures in the hpke-rs library, ranging from RFC non-compliance (missing all-zero checks) to catastrophic nonce reuse via integer overflow. Despite being marketed as a high-assurance, formally verified library, it failed to implement basic safety checks required by RFC 9180.
The Hook: When "Formally Verified" Means Nothing
In the world of cryptography, we love to throw around terms like "High Assurance" and "Formal Verification." They act as a security blanket, convincing developers that the math has been checked by a machine and therefore cannot be wrong. But here is the dirty secret: a formal proof is only as good as the model it verifies. If your model assumes the sky is green, the prover will happily confirm that the grass matches the sky.
Enter hpke-rs, a Rust implementation of Hybrid Public Key Encryption (HPKE, RFC 9180). It was marketed as a high-assurance library. However, in early 2026, researcher Nadim Kobeissi published "The Verification Theater," a paper that tore the library apart. It turns out, while the developers were busy verifying abstract properties, they missed the concrete implementation details mandated by the RFC.
We aren't talking about obscure side-channels here. We are talking about missing basic validity checks and using a u32 for a counter that, when it overflows, destroys the confidentiality of every message that follows. It is a masterclass in how engineering oversight can render cryptographic theory useless.
The Flaw I: The Zero-Check Negligence
Let's start with the most embarrassing failure: the X25519 shared secret validation. RFC 9180, Section 7.1.4, is extremely explicit. It states that when performing the Diffie-Hellman exchange, the implementation MUST check if the resulting shared secret is the all-zero value. If it is, the operation must abort.
Why? Because in X25519 (Curve25519), if an attacker sends a "low-order point" (like a point that clears the state), the resulting shared secret becomes zero regardless of the victim's private key. If the library doesn't check for this, the shared secret is now 0x00...00. The attacker knows this. The Key Derivation Function (KDF) then churns out keys based on... zero.
hpke-rs simply didn't check. It took the input, did the math, got zero, and said "looks good to me." This allows an attacker to force the encryption key to a known value, rendering the encryption deterministic and trivially decryptable. It is the cryptographic equivalent of accepting a blank piece of paper as a valid passport.
The Flaw II: The 32-Bit Time Bomb
If the X25519 failure wasn't enough, let's look at the sequence counter. HPKE uses AEAD (Authenticated Encryption with Associated Data) schemes like AES-GCM or ChaCha20-Poly1305. These algorithms require a unique nonce (number used once) for every message encrypted with the same key. If you reuse a nonce, you break the security guarantees. In GCM, you can recover the authentication key. In Poly1305, you can recover plaintext.
hpke-rs stored the message sequence number as a u32. That allows for roughly 4.29 billion messages. That sounds like a lot, right? In a high-throughput microservices environment, or a long-lived QUIC connection, it isn't. But the real sin wasn't the size; it was the behavior.
When the counter hit u32::MAX, it didn't panic. It didn't return an error. In release builds, Rust's default integer semantics allowed it to wrap around to zero. Suddenly, the library starts reusing nonces with the same session key. This is a catastrophic failure that turns a secure channel into an open book.
The Code: Before and After
Let's look at the fix for the X25519 issue. The developers had to introduce a constant-time check to ensure the byte array wasn't all zeros. Note the use of the subtle crate to avoid timing leaks.
Vulnerable Logic (Conceptual):
// The library blindly accepted the DH output
let shared_secret = x25519(my_private, their_public);
// Proceed to KDF...The Fix (Commit 1c247b5):
use subtle::ConstantTimeEq;
// Check for all-zero shared secret
let mut is_zero = 1u8;
for byte in &shared_secret.0 {
is_zero &= byte.ct_eq(&0u8).unwrap_u8();
}
if is_zero == 1 {
return Err(HpkeError::InvalidKey);
}Now, for the sequence number. They bumped the counter to u64 (virtually inexhaustible) and, critically, added a check to prevent wrap-around.
The Fix (Commit 3a82549):
// Changed seq from u32 to u64
// Implemented checked increment
fn increment_seq(&mut self) -> Result<(), HpkeError> {
self.seq = self.seq.checked_add(1)
.ok_or(HpkeError::MessageLimitReached)?;
Ok(())
}This .checked_add(1) is the difference between a secure library and a broken one. If the limit is reached, the session dies safely instead of silently corrupting.
The Exploit: Forcing the Zero
Exploiting the X25519 flaw is trivial for an active attacker (Man-in-the-Middle) or a malicious peer. Here is how a "Hacker's View" scenario plays out:
- Interceptor: The attacker sits between Alice and Bob.
- The Swap: Alice sends her public key to Bob. The attacker intercepts it and replaces it with a malicious Curve25519 point (e.g., a low-order point that results in a zero output).
- The Math: Bob receives the malicious key.
hpke-rscomputesSharedSecret = ECDH(BobPriv, MaliciousPub). Due to the properties of the curve and the point chosen,SharedSecretbecomes0x00...00. - Derivation: Bob derives the session keys using the KDF. Since the input key material is all zeros, the derived encryption keys are constant and predictable by the attacker.
- Decryption: Bob encrypts his secret message. The attacker, having pre-calculated the keys for a zero-input KDF, simply decrypts the message.
This bypasses the entire purpose of the public key exchange. No brute force required. Just math and bad validation logic.
Mitigation: Patch or Perish
The remediation is straightforward: Update immediately.
Versions of hpke-rs prior to 0.6.0 are unsafe for production use. If you are using hpke-rs-rust-crypto or hpke-rs-crypto, ensure you are pulling the latest patched versions released after February 12, 2026.
If you cannot patch (why?), you would need to implement a wrapper around the library that performs the ECDH checks manually before passing data to HPKE, but given the internal nature of the state handling, this is risky and ill-advised. The u32 overflow cannot be mitigated externally without tracking sequence numbers yourself and killing the session before the library does.
Lesson Learned: Formal verification of a protocol model does not absolve you from writing robust code. Always validate inputs against the RFC, specifically boundary conditions like all-zero keys or counter wrap-arounds.
Official Patches
Fix Analysis (2)
Technical Appendix
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:HAffected Systems
Affected Versions Detail
| Product | Affected Versions | Fixed Version |
|---|---|---|
hpke-rs Cryspen | < 0.6.0 | 0.6.0 |
| Attribute | Detail |
|---|---|
| Attack Vector | Network |
| CVSS | 9.8 |
| Complexity | Low |
| Privileges | None |
| Impact | Critical (Confidentiality & Integrity) |
| CWE IDs | CWE-327, CWE-190 |
MITRE ATT&CK Mapping
Use of a Broken or Risky Cryptographic Algorithm