GHSA-G433-PQ76-6CMF

The Verification Theater: Breaking hpke-rs

Amit Schendel
Amit Schendel
Senior Security Researcher

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:

  1. Interceptor: The attacker sits between Alice and Bob.
  2. 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).
  3. The Math: Bob receives the malicious key. hpke-rs computes SharedSecret = ECDH(BobPriv, MaliciousPub). Due to the properties of the curve and the point chosen, SharedSecret becomes 0x00...00.
  4. 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.
  5. 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.

Fix Analysis (2)

Technical Appendix

CVSS Score
9.8/ 10
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
EPSS Probability
0.10%
Top 100% most exploited

Affected Systems

hpke-rshpke-rs-rust-cryptohpke-rs-crypto

Affected Versions Detail

Product
Affected Versions
Fixed Version
hpke-rs
Cryspen
< 0.6.00.6.0
AttributeDetail
Attack VectorNetwork
CVSS9.8
ComplexityLow
PrivilegesNone
ImpactCritical (Confidentiality & Integrity)
CWE IDsCWE-327, CWE-190
CWE-327
Use of a Broken or Risky Cryptographic Algorithm

Use of a Broken or Risky Cryptographic Algorithm

Vulnerability Timeline

Vulnerabilities identified by Symbolic Software
2026-02-03
Public disclosure via 'Verification Theater' paper
2026-02-05
Final fixes merged and v0.6.0 released
2026-02-12