Skip to content

Commit 6f87a32

Browse files
authored
fix: X25519 cofactor malleability (#843)
* test x25519 torsion * bind ies kdf * reject x25519 torsion * chore: Changelog * test: cover x25519 torsion rejection * chore: debug-assert x25519 all-zero shared secret * docs: clarify HKDF info context
1 parent 3829a57 commit 6f87a32

File tree

7 files changed

+145
-13
lines changed

7 files changed

+145
-13
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
- Added `Signature::from_der()` for ECDSA signatures over secp256k1 ([#842](https://github.com/0xMiden/crypto/pull/842)).
1515
- Fix possible panic in `XChaCha::decrypt_bytes_with_associated_data` and harden deserialization with fuzzing across 7 new targets ([#836](https://github.com/0xMiden/crypto/pull/836)).
1616
- Use read_from_bytes_with_budget instead of read_from_bytes for deserialization from untrusted sources, setting the budget to the actual input byte slice length. ([#846](https://github.com/0xMiden/crypto/pull/846)).
17+
- [BREAKING] Added info context field to secret box, bind IES HKDF info to a stable context string, scheme identifier, and ephemeral public key bytes. ([#843](https://github.com/0xMiden/crypto/pull/843)).
1718

1819
## 0.22.3 (2026-02-23)
1920

miden-crypto/src/ecdh/k256.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,10 +218,11 @@ impl KeyAgreementScheme for K256 {
218218
fn extract_key_material(
219219
shared_secret: &Self::SharedSecret,
220220
length: usize,
221+
info: &[u8],
221222
) -> Result<Vec<u8>, super::KeyAgreementError> {
222223
let hkdf = shared_secret.extract(None);
223224
let mut buf = vec![0_u8; length];
224-
hkdf.expand(&[], &mut buf)
225+
hkdf.expand(info, &mut buf)
225226
.map_err(|_| super::KeyAgreementError::HkdfExpansionFailed)?;
226227
Ok(buf)
227228
}

miden-crypto/src/ecdh/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,13 @@ pub(crate) trait KeyAgreementScheme {
4343
) -> Result<Self::SharedSecret, KeyAgreementError>;
4444

4545
/// Extracts key material from shared secret.
46+
///
47+
/// `info` is the HKDF context string for domain separation and binding to the IES scheme and
48+
/// ephemeral public key (see `CryptoBox::build_kdf_info`).
4649
fn extract_key_material(
4750
shared_secret: &Self::SharedSecret,
4851
length: usize,
52+
info: &[u8],
4953
) -> Result<Vec<u8>, KeyAgreementError>;
5054
}
5155

miden-crypto/src/ecdh/x25519.rs

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,17 @@ impl Serializable for EphemeralPublicKey {
155155
impl Deserializable for EphemeralPublicKey {
156156
fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
157157
let bytes: [u8; 32] = source.read_array()?;
158+
// Reject twist points and low-order points. We intentionally avoid the more expensive
159+
// torsion-free check; small-order rejection mitigates the most dangerous malleability
160+
// issues, even though it does not guarantee torsion-freeness.
161+
let mont = curve25519_dalek::montgomery::MontgomeryPoint(bytes);
162+
let edwards = mont.to_edwards(0).ok_or_else(|| {
163+
DeserializationError::InvalidValue("Invalid X25519 public key".into())
164+
})?;
165+
if edwards.is_small_order() {
166+
return Err(DeserializationError::InvalidValue("Invalid X25519 public key".into()));
167+
}
168+
158169
Ok(Self {
159170
inner: x25519_dalek::PublicKey::from(bytes),
160171
})
@@ -188,23 +199,28 @@ impl KeyAgreementScheme for X25519 {
188199
ephemeral_sk: Self::EphemeralSecretKey,
189200
static_pk: &Self::PublicKey,
190201
) -> Result<Self::SharedSecret, super::KeyAgreementError> {
191-
Ok(ephemeral_sk.diffie_hellman(static_pk))
202+
let shared = ephemeral_sk.diffie_hellman(static_pk);
203+
debug_assert!(!shared.as_ref().iter().all(|byte| *byte == 0), "all-zero shared secret");
204+
Ok(shared)
192205
}
193206

194207
fn exchange_static_ephemeral(
195208
static_sk: &Self::SecretKey,
196209
ephemeral_pk: &Self::EphemeralPublicKey,
197210
) -> Result<Self::SharedSecret, super::KeyAgreementError> {
198-
Ok(static_sk.get_shared_secret(ephemeral_pk.clone()))
211+
let shared = static_sk.get_shared_secret(ephemeral_pk.clone());
212+
debug_assert!(!shared.as_ref().iter().all(|byte| *byte == 0), "all-zero shared secret");
213+
Ok(shared)
199214
}
200215

201216
fn extract_key_material(
202217
shared_secret: &Self::SharedSecret,
203218
length: usize,
219+
info: &[u8],
204220
) -> Result<Vec<u8>, super::KeyAgreementError> {
205221
let hkdf = shared_secret.extract(None);
206222
let mut buf = vec![0_u8; length];
207-
hkdf.expand(&[], &mut buf)
223+
hkdf.expand(info, &mut buf)
208224
.map_err(|_| super::KeyAgreementError::HkdfExpansionFailed)?;
209225
Ok(buf)
210226
}
@@ -215,8 +231,12 @@ impl KeyAgreementScheme for X25519 {
215231

216232
#[cfg(test)]
217233
mod tests {
234+
use curve25519_dalek::{constants::EIGHT_TORSION, montgomery::MontgomeryPoint};
235+
218236
use super::*;
219-
use crate::{dsa::eddsa_25519_sha512::SecretKey, rand::test_utils::seeded_rng};
237+
use crate::{
238+
dsa::eddsa_25519_sha512::SecretKey, rand::test_utils::seeded_rng, utils::Deserializable,
239+
};
220240

221241
#[test]
222242
fn key_agreement() {
@@ -242,4 +262,30 @@ mod tests {
242262
// Check that the computed shared secret keys are equal
243263
assert_eq!(shared_secret_key_1.inner.to_bytes(), shared_secret_key_2.inner.to_bytes());
244264
}
265+
266+
#[test]
267+
fn ephemeral_public_key_rejects_small_order() {
268+
let bytes = EIGHT_TORSION[1].to_montgomery().to_bytes();
269+
let result = EphemeralPublicKey::read_from_bytes(&bytes);
270+
assert!(result.is_err());
271+
}
272+
273+
#[test]
274+
fn ephemeral_public_key_rejects_twist_point() {
275+
let bytes = find_twist_point_bytes();
276+
let result = EphemeralPublicKey::read_from_bytes(&bytes);
277+
assert!(result.is_err());
278+
}
279+
280+
fn find_twist_point_bytes() -> [u8; 32] {
281+
let mut bytes = [0u8; 32];
282+
for i in 0u16..=u16::MAX {
283+
bytes[0] = (i & 0xff) as u8;
284+
bytes[1] = (i >> 8) as u8;
285+
if MontgomeryPoint(bytes).to_edwards(0).is_none() {
286+
return bytes;
287+
}
288+
}
289+
panic!("no twist point found in 16-bit search space");
290+
}
245291
}

miden-crypto/src/ies/crypto_box.rs

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,13 @@ use alloc::vec::Vec;
88

99
use rand::{CryptoRng, RngCore};
1010

11-
use super::IesError;
12-
use crate::{Felt, aead::AeadScheme, ecdh::KeyAgreementScheme, utils::zeroize::Zeroizing};
11+
use super::{IesError, IesScheme};
12+
use crate::{
13+
Felt,
14+
aead::AeadScheme,
15+
ecdh::KeyAgreementScheme,
16+
utils::{Serializable, zeroize::Zeroizing},
17+
};
1318

1419
// CRYPTO BOX
1520
// ================================================================================================
@@ -20,12 +25,27 @@ pub(super) struct CryptoBox<K: KeyAgreementScheme, A: AeadScheme> {
2025
}
2126

2227
impl<K: KeyAgreementScheme, A: AeadScheme> CryptoBox<K, A> {
28+
const KDF_CONTEXT: &'static [u8] = b"miden-crypto/ies/hkdf-v1";
29+
30+
/// Builds the HKDF `info` used for IES key derivation.
31+
/// Layout: `[KDF_CONTEXT || scheme_id || ephemeral_public_key]` where `scheme_id = scheme as
32+
/// u8`.
33+
fn build_kdf_info(scheme: IesScheme, ephemeral_public_key: &K::EphemeralPublicKey) -> Vec<u8> {
34+
let mut info =
35+
Vec::with_capacity(Self::KDF_CONTEXT.len() + 1 + ephemeral_public_key.to_bytes().len());
36+
info.extend_from_slice(Self::KDF_CONTEXT);
37+
info.push(scheme as u8);
38+
info.extend_from_slice(&ephemeral_public_key.to_bytes());
39+
info
40+
}
41+
2342
// BYTE-SPECIFIC METHODS
2443
// --------------------------------------------------------------------------------------------
2544

2645
pub fn seal_bytes_with_associated_data<R: CryptoRng + RngCore>(
2746
rng: &mut R,
2847
recipient_public_key: &K::PublicKey,
48+
scheme: IesScheme,
2949
plaintext: &[u8],
3050
associated_data: &[u8],
3151
) -> Result<(Vec<u8>, K::EphemeralPublicKey), IesError> {
@@ -36,8 +56,9 @@ impl<K: KeyAgreementScheme, A: AeadScheme> CryptoBox<K, A> {
3656
.map_err(|_| IesError::KeyAgreementFailed)?,
3757
);
3858

59+
let kdf_info = Self::build_kdf_info(scheme, &ephemeral_public);
3960
let encryption_key_bytes = Zeroizing::new(
40-
K::extract_key_material(&shared_secret, <A as AeadScheme>::KEY_SIZE)
61+
K::extract_key_material(&shared_secret, <A as AeadScheme>::KEY_SIZE, &kdf_info)
4162
.map_err(|_| IesError::FailedExtractKeyMaterial)?,
4263
);
4364

@@ -55,6 +76,7 @@ impl<K: KeyAgreementScheme, A: AeadScheme> CryptoBox<K, A> {
5576
pub fn unseal_bytes_with_associated_data(
5677
recipient_private_key: &K::SecretKey,
5778
ephemeral_public_key: &K::EphemeralPublicKey,
79+
scheme: IesScheme,
5880
ciphertext: &[u8],
5981
associated_data: &[u8],
6082
) -> Result<Vec<u8>, IesError> {
@@ -63,8 +85,9 @@ impl<K: KeyAgreementScheme, A: AeadScheme> CryptoBox<K, A> {
6385
.map_err(|_| IesError::KeyAgreementFailed)?,
6486
);
6587

88+
let kdf_info = Self::build_kdf_info(scheme, ephemeral_public_key);
6689
let decryption_key_bytes = Zeroizing::new(
67-
K::extract_key_material(&shared_secret, <A as AeadScheme>::KEY_SIZE)
90+
K::extract_key_material(&shared_secret, <A as AeadScheme>::KEY_SIZE, &kdf_info)
6891
.map_err(|_| IesError::FailedExtractKeyMaterial)?,
6992
);
7093

@@ -83,6 +106,7 @@ impl<K: KeyAgreementScheme, A: AeadScheme> CryptoBox<K, A> {
83106
pub fn seal_elements_with_associated_data<R: CryptoRng + RngCore>(
84107
rng: &mut R,
85108
recipient_public_key: &K::PublicKey,
109+
scheme: IesScheme,
86110
plaintext: &[Felt],
87111
associated_data: &[Felt],
88112
) -> Result<(Vec<u8>, K::EphemeralPublicKey), IesError> {
@@ -93,8 +117,9 @@ impl<K: KeyAgreementScheme, A: AeadScheme> CryptoBox<K, A> {
93117
.map_err(|_| IesError::KeyAgreementFailed)?,
94118
);
95119

120+
let kdf_info = Self::build_kdf_info(scheme, &ephemeral_public);
96121
let encryption_key_bytes = Zeroizing::new(
97-
K::extract_key_material(&shared_secret, <A as AeadScheme>::KEY_SIZE)
122+
K::extract_key_material(&shared_secret, <A as AeadScheme>::KEY_SIZE, &kdf_info)
98123
.map_err(|_| IesError::FailedExtractKeyMaterial)?,
99124
);
100125

@@ -112,6 +137,7 @@ impl<K: KeyAgreementScheme, A: AeadScheme> CryptoBox<K, A> {
112137
pub fn unseal_elements_with_associated_data(
113138
recipient_private_key: &K::SecretKey,
114139
ephemeral_public_key: &K::EphemeralPublicKey,
140+
scheme: IesScheme,
115141
ciphertext: &[u8],
116142
associated_data: &[Felt],
117143
) -> Result<Vec<Felt>, IesError> {
@@ -120,8 +146,9 @@ impl<K: KeyAgreementScheme, A: AeadScheme> CryptoBox<K, A> {
120146
.map_err(|_| IesError::KeyAgreementFailed)?,
121147
);
122148

149+
let kdf_info = Self::build_kdf_info(scheme, ephemeral_public_key);
123150
let decryption_key_bytes = Zeroizing::new(
124-
K::extract_key_material(&shared_secret, <A as AeadScheme>::KEY_SIZE)
151+
K::extract_key_material(&shared_secret, <A as AeadScheme>::KEY_SIZE, &kdf_info)
125152
.map_err(|_| IesError::FailedExtractKeyMaterial)?,
126153
);
127154

miden-crypto/src/ies/keys.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,11 @@ macro_rules! impl_seal_bytes_with_associated_data {
4747
match self {
4848
$(
4949
$variant(key) => {
50+
let scheme = self.scheme();
5051
let (ciphertext, ephemeral) = <$crypto_box>::seal_bytes_with_associated_data(
5152
rng,
5253
key,
54+
scheme,
5355
plaintext,
5456
associated_data,
5557
)?;
@@ -82,9 +84,11 @@ macro_rules! impl_seal_elements_with_associated_data {
8284
match self {
8385
$(
8486
$variant(key) => {
87+
let scheme = self.scheme();
8588
let (ciphertext, ephemeral) = <$crypto_box>::seal_elements_with_associated_data(
8689
rng,
8790
key,
91+
scheme,
8892
plaintext,
8993
associated_data,
9094
)?;
@@ -130,7 +134,13 @@ macro_rules! impl_unseal_bytes_with_associated_data {
130134
match (self, ephemeral_key) {
131135
$(
132136
($variant(key), $ephemeral_variant(ephemeral)) => {
133-
<$crypto_box>::unseal_bytes_with_associated_data(key, &ephemeral, &ciphertext, associated_data)
137+
<$crypto_box>::unseal_bytes_with_associated_data(
138+
key,
139+
&ephemeral,
140+
self.scheme(),
141+
&ciphertext,
142+
associated_data,
143+
)
134144
}
135145
)*
136146
_ => Err(IesError::SchemeMismatch),
@@ -169,7 +179,13 @@ macro_rules! impl_unseal_elements_with_associated_data {
169179
match (self, ephemeral_key) {
170180
$(
171181
($variant(key), $ephemeral_variant(ephemeral)) => {
172-
<$crypto_box>::unseal_elements_with_associated_data(key, &ephemeral, &ciphertext, associated_data)
182+
<$crypto_box>::unseal_elements_with_associated_data(
183+
key,
184+
&ephemeral,
185+
self.scheme(),
186+
&ciphertext,
187+
associated_data,
188+
)
173189
}
174190
)*
175191
_ => Err(IesError::SchemeMismatch),

miden-crypto/src/ies/tests.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,8 @@ mod k256_xchacha_tests {
230230

231231
/// X25519 + XChaCha20-Poly1305 test suite
232232
mod x25519_xchacha_tests {
233+
use curve25519_dalek::{constants::EIGHT_TORSION, montgomery::MontgomeryPoint};
234+
233235
use super::*;
234236

235237
#[test]
@@ -315,6 +317,41 @@ mod x25519_xchacha_tests {
315317
assert!(result.is_err());
316318
}
317319

320+
// Scenario: if anti-replay is based on sealed-message bytes rather than decrypted identity,
321+
// malleability would allow repeated redemption of the same underlying coupon.
322+
#[test]
323+
fn test_x25519_ephemeral_torsion_rejected() {
324+
let mut rng = rand::rng();
325+
let plaintext = b"malleability check";
326+
327+
let secret_key = SecretKey25519::with_rng(&mut rng);
328+
let public_key = secret_key.public_key();
329+
let sealing_key = SealingKey::X25519XChaCha20Poly1305(public_key);
330+
let unsealing_key = UnsealingKey::X25519XChaCha20Poly1305(secret_key);
331+
332+
let mut sealed = sealing_key.seal_bytes(&mut rng, plaintext).unwrap();
333+
334+
let eph_bytes = sealed.ephemeral_key.to_bytes();
335+
let mut eph_array = [0u8; 32];
336+
eph_array.copy_from_slice(&eph_bytes);
337+
338+
let mont = MontgomeryPoint(eph_array);
339+
let edwards = mont.to_edwards(0).expect("ephemeral key should be on Curve25519");
340+
341+
let torsion = EIGHT_TORSION[1];
342+
let altered = (edwards + torsion).to_montgomery().to_bytes();
343+
344+
assert_ne!(altered, eph_array);
345+
346+
let altered_key =
347+
EphemeralPublicKey::from_bytes(IesScheme::X25519XChaCha20Poly1305, &altered).unwrap();
348+
349+
sealed.ephemeral_key = altered_key;
350+
351+
let result = unsealing_key.unseal_bytes(sealed);
352+
assert!(result.is_err());
353+
}
354+
318355
proptest! {
319356
#[test]
320357
fn prop_x25519_xchacha_bytes_comprehensive(

0 commit comments

Comments
 (0)