From 2da75290ee875fd1a79818083cac5ecb9d2260e4 Mon Sep 17 00:00:00 2001 From: lodge Date: Sat, 4 Jul 2026 13:53:23 -0300 Subject: [PATCH 1/4] Minor security hardening: decap error labeling, nonce scrubbing, drop unused serde feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small items from a security review of v0.1.0: - DHKEM decap/auth_decap now report the RFC 9180 §7.1.4 all-zeros shared-secret rejection as DecapError instead of leaking the encap-side EncapError label through the receiver path. Adds a regression test. - Context::seal/open wrap the per-message nonce in Zeroizing so the stack copy derived from the secret base_nonce is scrubbed after the AEAD call (defense-in-depth; the nonce alone does not endanger the AEAD). - Remove the 'serde' optional dependency and feature: nothing in the crate uses it, and its presence invites accidental serialization of key material in a future change. Reintroduce deliberately if wire serialization is ever designed. Co-Authored-By: Claude Fable 5 --- Cargo.toml | 2 -- src/context.rs | 7 +++++-- src/kem/dh.rs | 20 +++++++++++++++++--- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2ef47ee..64402b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,13 +31,11 @@ p521 = { version = "0.13", default-features = false, features = ["ar k256 = { version = "0.13", default-features = false, features = ["arithmetic", "ecdh"] } ml-kem = { version = "0.3.2", default-features = false, features = ["zeroize"], optional = true } x-wing = { version = "0.1.0-rc.0", default-features = false, features = ["zeroize"], optional = true } -serde = { version = "1", default-features = false, features = ["derive"], optional = true } [features] default = ["std"] std = ["rand_core/std", "subtle/std", "aes-gcm/std", "chacha20poly1305/std"] pq = ["dep:ml-kem", "dep:x-wing", "dep:rand_core_10", "dep:shake"] -serde = ["dep:serde"] differential = [] comparative = ["pq"] kat-internals = [] diff --git a/src/context.rs b/src/context.rs index 2aa4f2d..946979c 100644 --- a/src/context.rs +++ b/src/context.rs @@ -157,7 +157,9 @@ impl Context { if self.seq == u64::MAX { return Err(HpkeError::MessageLimitReached); } - let nonce = self.compute_nonce(); + // The per-message nonce is derived from the secret `base_nonce`; + // scrub the stack copy once the AEAD call has consumed it. + let nonce = Zeroizing::new(self.compute_nonce()); let ct = A::seal(&self.cipher, &nonce[..A::NONCE_LEN], aad, pt)?; self.seq += 1; // checked above; cannot overflow Ok(ct) @@ -173,7 +175,8 @@ impl Context { if self.seq == u64::MAX { return Err(HpkeError::MessageLimitReached); } - let nonce = self.compute_nonce(); + // See `seal`: scrub the stack copy of the derived nonce. + let nonce = Zeroizing::new(self.compute_nonce()); let pt = A::open(&self.cipher, &nonce[..A::NONCE_LEN], aad, ct)?; self.seq += 1; Ok(pt) diff --git a/src/kem/dh.rs b/src/kem/dh.rs index 90b9864..27614b1 100644 --- a/src/kem/dh.rs +++ b/src/kem/dh.rs @@ -432,7 +432,9 @@ impl Kem for DhKem { sk_r: &Self::PrivateKey, ) -> Result { let pk_e = D::pk_from_bytes(enc.as_ref())?; - let dh = Zeroizing::new(D::dh(&sk_r.sk, &pk_e)?); + // `D::dh` reports the RFC 9180 §7.1.4 all-zeros rejection as + // `EncapError`; relabel for the decapsulation direction. + let dh = Zeroizing::new(D::dh(&sk_r.sk, &pk_e).map_err(|_| HpkeError::DecapError)?); // `sk_r.pk_bytes` was cached at construction time; using it here // saves the base-point scalar mult that `sk_to_pk` would otherwise // perform on every decap. @@ -477,8 +479,9 @@ impl AuthKem for DhKem { pk_s: &Self::PublicKey, ) -> Result { let pk_e = D::pk_from_bytes(enc.as_ref())?; - let dh1 = Zeroizing::new(D::dh(&sk_r.sk, &pk_e)?); - let dh2 = Zeroizing::new(D::dh(&sk_r.sk, &pk_s.0)?); + // See `decap`: relabel the all-zeros rejection for this direction. + let dh1 = Zeroizing::new(D::dh(&sk_r.sk, &pk_e).map_err(|_| HpkeError::DecapError)?); + let dh2 = Zeroizing::new(D::dh(&sk_r.sk, &pk_s.0).map_err(|_| HpkeError::DecapError)?); let pk_sender = D::pk_to_bytes(&pk_s.0); Ok(DhSharedSecret(extract_and_expand_pieces::( &[&dh1, &dh2], @@ -985,6 +988,17 @@ mod tests { assert_eq!(r.err(), Some(HpkeError::EncapError)); } + /// The all-zeros rejection must surface as `DecapError` on the receiver + /// side (`D::dh` reports it as `EncapError`; `decap` relabels it). + #[test] + fn x25519_decap_rejects_small_order_zero_enc() { + type Suite = DhKem; + let mut os_rng = OsRng; + let (sk_r, _) = Suite::generate(&mut os_rng.unwrap_mut()).unwrap(); + let enc = Suite::enc_from_bytes(&[0u8; 32]).unwrap(); + assert_eq!(Suite::decap(&enc, &sk_r).err(), Some(HpkeError::DecapError)); + } + /// RFC 7748 §5: load the same X448 scalar with and without pre-clamping; /// `sk_to_pk` and `dh` must agree on both, proving wire bytes are /// clamped on load. From e54e9e89b09b435d5f86b1051c18085a2f4545f8 Mon Sep 17 00:00:00 2001 From: lodge Date: Tue, 28 Jul 2026 10:22:25 -0300 Subject: [PATCH 2/4] docs: remove superflous commenting --- src/context.rs | 3 --- src/kem/dh.rs | 6 ------ 2 files changed, 9 deletions(-) diff --git a/src/context.rs b/src/context.rs index 946979c..e0db013 100644 --- a/src/context.rs +++ b/src/context.rs @@ -157,8 +157,6 @@ impl Context { if self.seq == u64::MAX { return Err(HpkeError::MessageLimitReached); } - // The per-message nonce is derived from the secret `base_nonce`; - // scrub the stack copy once the AEAD call has consumed it. let nonce = Zeroizing::new(self.compute_nonce()); let ct = A::seal(&self.cipher, &nonce[..A::NONCE_LEN], aad, pt)?; self.seq += 1; // checked above; cannot overflow @@ -175,7 +173,6 @@ impl Context { if self.seq == u64::MAX { return Err(HpkeError::MessageLimitReached); } - // See `seal`: scrub the stack copy of the derived nonce. let nonce = Zeroizing::new(self.compute_nonce()); let pt = A::open(&self.cipher, &nonce[..A::NONCE_LEN], aad, ct)?; self.seq += 1; diff --git a/src/kem/dh.rs b/src/kem/dh.rs index 27614b1..a475272 100644 --- a/src/kem/dh.rs +++ b/src/kem/dh.rs @@ -432,12 +432,7 @@ impl Kem for DhKem { sk_r: &Self::PrivateKey, ) -> Result { let pk_e = D::pk_from_bytes(enc.as_ref())?; - // `D::dh` reports the RFC 9180 §7.1.4 all-zeros rejection as - // `EncapError`; relabel for the decapsulation direction. let dh = Zeroizing::new(D::dh(&sk_r.sk, &pk_e).map_err(|_| HpkeError::DecapError)?); - // `sk_r.pk_bytes` was cached at construction time; using it here - // saves the base-point scalar mult that `sk_to_pk` would otherwise - // perform on every decap. Ok(DhSharedSecret(extract_and_expand::( &dh, &[enc.as_ref(), &sk_r.pk_bytes], @@ -479,7 +474,6 @@ impl AuthKem for DhKem { pk_s: &Self::PublicKey, ) -> Result { let pk_e = D::pk_from_bytes(enc.as_ref())?; - // See `decap`: relabel the all-zeros rejection for this direction. let dh1 = Zeroizing::new(D::dh(&sk_r.sk, &pk_e).map_err(|_| HpkeError::DecapError)?); let dh2 = Zeroizing::new(D::dh(&sk_r.sk, &pk_s.0).map_err(|_| HpkeError::DecapError)?); let pk_sender = D::pk_to_bytes(&pk_s.0); From abfc0b96d3faabfad3ec2ec746dadc3b189a26e9 Mon Sep 17 00:00:00 2001 From: lodge Date: Tue, 28 Jul 2026 10:36:49 -0300 Subject: [PATCH 3/4] fix: remove unnecessary error handling in Diffie-Hellman key exchange --- src/kem/dh.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/kem/dh.rs b/src/kem/dh.rs index 511adda..2a93a1e 100644 --- a/src/kem/dh.rs +++ b/src/kem/dh.rs @@ -415,7 +415,7 @@ impl Kem for DhKem { sk_r: &Self::PrivateKey, ) -> Result { let pk_e = D::pk_from_bytes(enc.as_ref())?; - let dh = Zeroizing::new(D::dh(&sk_r.sk, &pk_e).map_err(|_| HpkeError::DecapError)?); + let dh = Zeroizing::new(D::dh(&sk_r.sk, &pk_e)?); Ok(DhSharedSecret(extract_and_expand::( &dh, &[enc.as_ref(), &sk_r.pk_bytes], From 15613e77a645611b4435df12b1ca4c4f3cc6b693 Mon Sep 17 00:00:00 2001 From: lodge Date: Tue, 28 Jul 2026 10:38:04 -0300 Subject: [PATCH 4/4] optimize: cache public key bytes in Diffie-Hellman decapsulation to reduce computation --- src/kem/dh.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/kem/dh.rs b/src/kem/dh.rs index 2a93a1e..fa1d32d 100644 --- a/src/kem/dh.rs +++ b/src/kem/dh.rs @@ -416,6 +416,9 @@ impl Kem for DhKem { ) -> Result { let pk_e = D::pk_from_bytes(enc.as_ref())?; let dh = Zeroizing::new(D::dh(&sk_r.sk, &pk_e)?); + // `sk_r.pk_bytes` was cached at construction time; using it here + // saves the base-point scalar mult that `sk_to_pk` would otherwise + // perform on every decap. Ok(DhSharedSecret(extract_and_expand::( &dh, &[enc.as_ref(), &sk_r.pk_bytes],