diff --git a/CHANGELOG.md b/CHANGELOG.md index d8410d8..7614394 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Additions + +* Added `kem::XWingRejectNonContrib` ## [0.14.0] — 2026-07-09 diff --git a/Cargo.lock b/Cargo.lock index 0290528..eb4b509 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1241,8 +1241,7 @@ dependencies = [ [[package]] name = "x-wing" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b51507b887016c3925c84591108dadb8c099f776eb04fec6a60ae3519fee856f" +source = "git+https://github.com/str4d/RustCrypto-KEMs.git?rev=f5dd74e238bfebc0b36754923ccc6157d75641e6#f5dd74e238bfebc0b36754923ccc6157d75641e6" dependencies = [ "kem", "ml-kem", diff --git a/Cargo.toml b/Cargo.toml index 3536d87..f6cf9eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,3 +95,6 @@ required-features = ["getrandom"] [lib] bench = false + +[patch.crates-io] +x-wing = { git = "https://github.com/str4d/RustCrypto-KEMs.git", rev = "f5dd74e238bfebc0b36754923ccc6157d75641e6" } diff --git a/src/aead.rs b/src/aead.rs index 41412d1..f4edb99 100644 --- a/src/aead.rs +++ b/src/aead.rs @@ -997,6 +997,29 @@ mod test { KdfTurboShake128, crate::kem::XWing ); + + test_export_idempotence!( + test_export_idempotence_xwing_reject_non_contrib, + KdfTurboShake128, + crate::kem::XWingRejectNonContrib + ); + test_exportonly_panics!( + test_exportonly_panics_xwing_reject_non_contrib_seal, + test_exportonly_panics_xwing_reject_non_contrib_open, + KdfTurboShake128, + crate::kem::XWingRejectNonContrib + ); + test_overflow!( + test_overflow_xwing_reject_non_contrib, + KdfTurboShake128, + crate::kem::XWingRejectNonContrib + ); + test_ctx_correctness!( + test_ctx_correctness_chacha_xwing_reject_non_contrib, + ChaCha20Poly1305, + KdfTurboShake128, + crate::kem::XWingRejectNonContrib + ); } /// Tests that Serialize::write_exact() panics when given a buffer of incorrect length diff --git a/src/danger/streaming_enc.rs b/src/danger/streaming_enc.rs index 1913289..1208881 100644 --- a/src/danger/streaming_enc.rs +++ b/src/danger/streaming_enc.rs @@ -350,5 +350,12 @@ mod test { KdfTurboShake128, crate::kem::XWing ); + + test_create_ctx_correctness!( + test_create_ctx_correctness_chacha_xwing_reject_non_contrib, + ChaCha20Poly1305, + KdfTurboShake128, + crate::kem::XWingRejectNonContrib + ); } } diff --git a/src/kat_tests.rs b/src/kat_tests.rs index b6fa5f2..61847e8 100644 --- a/src/kat_tests.rs +++ b/src/kat_tests.rs @@ -8,6 +8,7 @@ use crate::{ kem::{ DhP256HkdfSha256, DhP384HkdfSha384, DhP521HkdfSha512, Kem as KemTrait, MlKem768, MlKem768P256, MlKem1024, MlKem1024P384, SharedSecret, X25519HkdfSha256, XWing, + XWingRejectNonContrib, }, op_mode::{OpModeR, PskBundle}, setup::setup_receiver, @@ -493,5 +494,6 @@ fn hybrid() { // Note we skip "unpack_dk" because the XWing crate doesn't expose the secret key // internals to us test_hybrid_vector!(XWing, tv); + test_hybrid_vector!(XWingRejectNonContrib, tv); } } diff --git a/src/kem.rs b/src/kem.rs index 3a8444b..8fc89d5 100644 --- a/src/kem.rs +++ b/src/kem.rs @@ -32,7 +32,7 @@ pub use mlkem::mlkem1024::MlKem1024; #[cfg(all(feature = "mlkem", feature = "x25519"))] pub(crate) mod xwing; #[cfg(all(feature = "mlkem", feature = "x25519"))] -pub use xwing::XWing; +pub use xwing::{XWing, XWingRejectNonContrib}; /// Represents authenticated encryption functionality pub trait Kem: Sized { @@ -316,5 +316,15 @@ mod tests { test_encap_correctness!(test_encap_correctness_xwing, XWing, false); test_encapped_serialize!(test_encapped_serialize_xwing, XWing); + + test_encap_correctness!( + test_encap_correctness_xwing_reject_non_contrib, + XWingRejectNonContrib, + false + ); + test_encapped_serialize!( + test_encapped_serialize_xwing_reject_non_contrib, + XWingRejectNonContrib + ); } } diff --git a/src/kem/xwing.rs b/src/kem/xwing.rs index 5f8acae..d53751f 100644 --- a/src/kem/xwing.rs +++ b/src/kem/xwing.rs @@ -14,7 +14,10 @@ use hybrid_array::typenum::{Prod, Sum, U3, U32, U64, U1024, Unsigned}; use rand_core::CryptoRng; use shake::Shake256; use subtle::{Choice, ConstantTimeEq}; -use x_wing::{Decapsulator, KeyExport, TryKeyInit, kem::Decapsulate}; +use x_wing::{ + Decapsulator, KeyExport, TryKeyInit, + kem::{Decapsulate, TryDecapsulate}, +}; use zeroize::Zeroize; // Type-level size constants for X-Wing @@ -68,6 +71,50 @@ impl PartialEq for PrivateKey { } impl Eq for PrivateKey {} +#[derive(Clone)] +pub struct PrivateKeyRejectNonContrib(x_wing::DecapsulationKeyRejectNonContrib); + +impl Serializable for PrivateKeyRejectNonContrib { + // x_wing::DECAPSULATION_KEY_SIZE == 32 + type OutputSize = U32; + + fn write_exact(&self, buf: &mut [u8]) { + // Check the length is correct and panic if not + enforce_outbuf_len::(buf); + + buf.copy_from_slice(&self.0.to_bytes()); + } +} + +impl Deserializable for PrivateKeyRejectNonContrib { + fn from_bytes(encoded: &[u8]) -> Result { + // Check the input buf length is correct and error if not + enforce_equal_len(Self::OutputSize::USIZE, encoded.len())?; + + // Copy to a fixed-size array + let mut arr = [0u8; Self::OutputSize::USIZE]; + arr.copy_from_slice(encoded); + + let sk = PrivateKeyRejectNonContrib(arr.into()); + arr.zeroize(); + + Ok(sk) + } +} + +impl ConstantTimeEq for PrivateKeyRejectNonContrib { + fn ct_eq(&self, other: &Self) -> Choice { + self.0.to_bytes().ct_eq(&other.0.to_bytes()) + } +} + +impl PartialEq for PrivateKeyRejectNonContrib { + fn eq(&self, other: &Self) -> bool { + self.ct_eq(other).into() + } +} +impl Eq for PrivateKeyRejectNonContrib {} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct PublicKey(x_wing::EncapsulationKey); @@ -114,7 +161,10 @@ impl Deserializable for EncappedKey { } } -/// X-Wing (a.k.a ML-KEM 768 + X25519) hybrid post-quantum KEM +/// X-Wing (a.k.a ML-KEM 768 + X25519) hybrid post-quantum KEM. +/// +/// This KEM implementation accepts "non-contributory behaviour" in the X25519 component. +/// To reject instead, use [`XWingRejectNonContrib`]. pub struct XWing; impl XWing { @@ -232,6 +282,121 @@ impl crate::kat_tests::TestableKem for XWing { } } +/// X-Wing (a.k.a ML-KEM 768 + X25519) hybrid post-quantum KEM. +/// +/// This KEM implementation rejects "non-contributory behaviour" in the X25519 component. +/// To accept instead, use [`XWing`]. +/// +/// # Backstory +/// +/// [RFC 7748] defines the `X25519` function, and [specifies] that when used for ECDH +/// (as it is inside X-Wing), the implementation **MAY** abort if the all-zero value +/// is produced as a shared secret. X-Wing, as initially specified, used the X25519 +/// function without making any mention of this **MAY**, meaning that implementations +/// inherited whatever behaviour their underlying X25519 ECDH implementation provided. +/// +/// This crate initially did not check for non-contributory behaviour, which meant it +/// was incompatible with other implementations that did (in that it would accept +/// ciphertexts that other implementations reject). +/// +/// [CFRG have decided] that they will pick a single behaviour for the IETF X-Wing +/// standard. Until the corresponding RFC is published, this crate supports both +/// behaviours: [`XWing`] accepts non-contributory behaviour for backwards-compatibility +/// with existing usages, and this struct can be used to instead reject non-contributory +/// behaviour. Once the RFC is published, `XWing` will be altered to match it. +/// +/// [RFC 7748]: https://www.rfc-editor.org/info/rfc7748/#section-5 +/// [specifies]: https://www.rfc-editor.org/info/rfc7748/#section-6.1 +/// [CFRG have decided]: https://mailarchive.ietf.org/arch/msg/cfrg/v9fEHQj3QTUpdu72AzjyyrY4j2g/ +pub struct XWingRejectNonContrib; + +impl KemTrait for XWingRejectNonContrib { + const KEM_ID: u16 = XWing::KEM_ID; + type NSecret = ::NSecret; + type PublicKey = ::PublicKey; + type PrivateKey = PrivateKeyRejectNonContrib; + type EncappedKey = ::EncappedKey; + + fn sk_to_pk(sk: &PrivateKeyRejectNonContrib) -> PublicKey { + PublicKey(sk.0.encapsulation_key().clone()) + } + + fn derive_keypair(ikm: &[u8]) -> (PrivateKeyRejectNonContrib, PublicKey) { + let (sk, pk) = XWing::derive_keypair(ikm); + + let sk = PrivateKeyRejectNonContrib::from_bytes(&sk.to_bytes()).expect("valid"); + + (sk, pk) + } + + /// Decapsulate the encapsulated key using the recipient's private key. This DOES NOT support + /// authenticated encapsulation, i.e., `pk_sender_id` MUST be `None`. + /// + /// # Panics + /// Panics if `pk_sender_id` is `Some`. + fn decap( + sk_recip: &PrivateKeyRejectNonContrib, + pk_sender_id: Option<&PublicKey>, + encapped_key: &EncappedKey, + ) -> Result, HpkeError> { + assert!( + pk_sender_id.is_none(), + "X-Wing doesn't support authenticated encapsulation. Use Base or Psk operation mode." + ); + + let ss = sk_recip + .0 + .try_decapsulate(&encapped_key.0) + .map_err(|_| HpkeError::DecapError)?; + Ok(SharedSecret(ss)) + } + + /// Decapsulate the encapsulated key using the recipient's private key. This DOES NOT support + /// authenticated encapsulation, i.e., `sender_id_keypair` MUST be `None`. + /// + /// # Panics + /// Panics if `sender_id_keypair` is `Some`. + fn encap_with_rng( + pk_recip: &PublicKey, + sender_id_keypair: Option<(&PrivateKeyRejectNonContrib, &PublicKey)>, + csprng: &mut impl CryptoRng, + ) -> Result<(SharedSecret, EncappedKey), HpkeError> { + assert!( + sender_id_keypair.is_none(), + "X-Wing doesn't support authenticated encapsulation. Use Base or Psk operation mode." + ); + + XWing::encap_with_rng(pk_recip, None, csprng).map(|(ss, ek)| (SharedSecret(ss.0), ek)) + } +} + +// Impl the trait necessary for known-answer tests +#[cfg(all(test, feature = "kat"))] +impl crate::kat_tests::TestableKem for XWingRejectNonContrib { + // There is no encap-with-eph, since that only makes sense for DHKEMs + type EphemeralKey = core::convert::Infallible; + fn encap_with_eph( + _pk_recip: &Self::PublicKey, + _sender_id_keypair: Option<(&Self::PrivateKey, &Self::PublicKey)>, + _sk_eph: Self::EphemeralKey, + ) -> Result<(SharedSecret, Self::EncappedKey), HpkeError> { + unimplemented!() + } + + fn encap_det( + pk_recip: &Self::PublicKey, + sender_id_keypair: Option<(&Self::PrivateKey, &Self::PublicKey)>, + randomness: &[u8], + ) -> Result<(SharedSecret, Self::EncappedKey), HpkeError> { + assert!( + sender_id_keypair.is_none(), + "X-Wing does not support authenticated encapsulation" + ); + XWing::encap_deterministic(pk_recip, randomness.try_into().unwrap()) + .map(|(ss, ek)| (SharedSecret(ss.0), ek)) + } +} + #[cfg(test)] mod tests { use super::*; @@ -246,4 +411,17 @@ mod tests { XWing::decap(&sk, None, &EncappedKey(encapped_key.0)).expect("decapsulation failed"); assert_eq!(shared_secret.0, shared_secret_recipient.0); } + + #[test] + fn test_roundtrip_reject_non_contributory() { + let mut csprng = rand::rng(); + let (sk, pk) = XWingRejectNonContrib::gen_keypair_with_rng(&mut csprng); + let (shared_secret, encapped_key) = + XWingRejectNonContrib::encap_with_rng(&pk, None, &mut csprng) + .expect("encapsulation failed"); + let shared_secret_recipient = + XWingRejectNonContrib::decap(&sk, None, &EncappedKey(encapped_key.0)) + .expect("decapsulation failed"); + assert_eq!(shared_secret.0, shared_secret_recipient.0); + } } diff --git a/src/setup.rs b/src/setup.rs index 136982b..0d2e28e 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -510,5 +510,20 @@ mod test { XWing, false ); + + test_setup_correctness!( + test_setup_correctness_xwing_reject_non_contrib, + ChaCha20Poly1305, + KdfTurboShake128, + XWingRejectNonContrib, + false + ); + test_setup_soundness!( + test_setup_soundness_xwing_reject_non_contrib, + ChaCha20Poly1305, + KdfTurboShake128, + XWingRejectNonContrib, + false + ); } } diff --git a/src/single_shot.rs b/src/single_shot.rs index 66641a4..726a56e 100644 --- a/src/single_shot.rs +++ b/src/single_shot.rs @@ -396,5 +396,13 @@ mod test { XWing, false ); + + test_single_shot_correctness!( + test_single_shot_correctness_xwing_reject_non_contrib, + ChaCha20Poly1305, + KdfTurboShake128, + XWingRejectNonContrib, + false + ); } }