From 5bc244cef4ebc7e366aa4884e10d7f2d86bffe39 Mon Sep 17 00:00:00 2001 From: Michael Rosenberg Date: Sun, 25 Oct 2026 13:16:44 -0400 Subject: [PATCH 1/3] dhkem: Regression test for checking unclamped serialized x25519 privkeys --- Cargo.lock | 6 ++++-- dhkem/Cargo.toml | 4 +++- dhkem/src/x25519_kem.rs | 14 ++++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9376a65..f2859e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -363,6 +363,7 @@ name = "dhkem" version = "0.1.0" dependencies = [ "ctutils", + "curve25519-dalek", "elliptic-curve", "getrandom", "hex-literal", @@ -372,6 +373,7 @@ dependencies = [ "p256", "p384", "p521", + "rand", "rand_core", "sha2", "x25519-dalek", @@ -1030,9 +1032,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom", diff --git a/dhkem/Cargo.toml b/dhkem/Cargo.toml index a6e7da4..22344da 100644 --- a/dhkem/Cargo.toml +++ b/dhkem/Cargo.toml @@ -26,12 +26,14 @@ p256 = { version = "0.14", optional = true, default-features = false, features = p384 = { version = "0.14", optional = true, default-features = false, features = ["arithmetic"] } p521 = { version = "0.14", optional = true, default-features = false, features = ["arithmetic"] } x25519 = { version = "3", package = "x25519-dalek", optional = true, default-features = false } +curve25519-dalek = { version = "5", optional = true, default-features = false } zeroize = { version = "1.9", optional = true, default-features = false } [dev-dependencies] getrandom = { version = "0.4", features = ["sys_rng"] } hex-literal = "1" hkdf = "0.13" +rand = "0.10.3" sha2 = "0.11" [features] @@ -43,7 +45,7 @@ k256 = ["dep:k256", "ecdh"] p256 = ["dep:p256", "ecdh"] p384 = ["dep:p384", "ecdh"] p521 = ["dep:p521", "ecdh"] -x25519 = ["dep:x25519", "x25519/static_secrets"] +x25519 = ["dep:x25519", "dep:curve25519-dalek", "x25519/static_secrets"] zeroize = ["dep:zeroize"] [lints] diff --git a/dhkem/src/x25519_kem.rs b/dhkem/src/x25519_kem.rs index db4df39..45040d7 100644 --- a/dhkem/src/x25519_kem.rs +++ b/dhkem/src/x25519_kem.rs @@ -179,3 +179,17 @@ impl Encapsulate for X25519EncapsulationKey { (pk.to_bytes().into(), ss.to_bytes().into()) } } + +// Regression test: DHKEM(X25519) serialized private keys MUST be clamped +#[test] +fn regression_unclamped_privkey() { + let mut rng = rand::rng(); + + // Clamping affects 5 bits. The likelihood that they're all set correctly by chance is 2^-5. + // The liklihood that this happens 30 times in a row is 2^(-150). + for _ in 0..30 { + let dk = X25519DecapsulationKey::generate_from_rng(&mut rng); + let dk_bytes = dk.to_bytes().into(); + assert_eq!(dk_bytes, curve25519_dalek::scalar::clamp_integer(dk_bytes)); + } +} From a8f08f708f315d5bda1ed417416b045323f7e078 Mon Sep 17 00:00:00 2001 From: Michael Rosenberg Date: Sun, 25 Oct 2026 13:19:14 -0400 Subject: [PATCH 2/3] x25519: Clamp dk on serialization --- dhkem/src/x25519_kem.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/dhkem/src/x25519_kem.rs b/dhkem/src/x25519_kem.rs index 45040d7..a89c6f3 100644 --- a/dhkem/src/x25519_kem.rs +++ b/dhkem/src/x25519_kem.rs @@ -2,6 +2,7 @@ use crate::{DecapsulationKey, EncapsulationKey, Error, HpkeKemId}; use ctutils::CtEq; +use curve25519_dalek::scalar::clamp_integer; use kem::{ Decapsulator, Encapsulate, Generate, InvalidKey, Kem, Key, KeyExport, KeyInit, KeySizeUser, TryDecapsulate, TryKeyInit, @@ -74,7 +75,7 @@ impl KeyInit for X25519DecapsulationKey { /// [RFC9810 ยง7.1.2]: https://datatracker.ietf.org/doc/html/rfc9180#section-7.1.2 impl KeyExport for X25519DecapsulationKey { fn to_bytes(&self) -> Key { - self.dk.to_bytes().into() + clamp_integer(self.dk.to_bytes()).into() } } @@ -190,6 +191,8 @@ fn regression_unclamped_privkey() { for _ in 0..30 { let dk = X25519DecapsulationKey::generate_from_rng(&mut rng); let dk_bytes = dk.to_bytes().into(); - assert_eq!(dk_bytes, curve25519_dalek::scalar::clamp_integer(dk_bytes)); + // You can test it's clamped by just clamping again and seeing if anything changed. This is + // because clamping is idempotent. + assert_eq!(dk_bytes, clamp_integer(dk_bytes)); } } From 5c6d9d6ea925a9dccd3ef2e601c7b29b48e5c3d2 Mon Sep 17 00:00:00 2001 From: Michael Rosenberg Date: Sun, 25 Oct 2026 13:45:58 -0400 Subject: [PATCH 3/3] Add note to regression test --- dhkem/src/x25519_kem.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/dhkem/src/x25519_kem.rs b/dhkem/src/x25519_kem.rs index a89c6f3..7f3b614 100644 --- a/dhkem/src/x25519_kem.rs +++ b/dhkem/src/x25519_kem.rs @@ -182,6 +182,7 @@ impl Encapsulate for X25519EncapsulationKey { } // Regression test: DHKEM(X25519) serialized private keys MUST be clamped +// Issue: https://github.com/RustCrypto/KEMs/issues/401 #[test] fn regression_unclamped_privkey() { let mut rng = rand::rng();