From faabbc960470b38a51f4aec07ce7f1d1c381d401 Mon Sep 17 00:00:00 2001 From: Jack Grigg Date: Mon, 3 Aug 2026 01:51:41 +0000 Subject: [PATCH] x-wing: Make X25519 non-contributory behaviour configurable Until the IETF RFC is published selecting a specific behaviour, we support both. Closes RustCrypto/KEMs#364. --- x-wing/CHANGELOG.md | 8 ++++ x-wing/src/error.rs | 19 +++++++++ x-wing/src/lib.rs | 95 ++++++++++++++++++++++++++++++++++++++++---- x-wing/tests/kats.rs | 6 ++- 4 files changed, 118 insertions(+), 10 deletions(-) create mode 100644 x-wing/src/error.rs diff --git a/x-wing/CHANGELOG.md b/x-wing/CHANGELOG.md index e297df2..097f3cd 100644 --- a/x-wing/CHANGELOG.md +++ b/x-wing/CHANGELOG.md @@ -5,5 +5,13 @@ 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 +### Added +- `DecapsulationKey::reject_x25519_non_contributory_behaviour` +- `Error` enum. + +### Changed +- `DecapsulationKey` now implements `kem::TryDecapsulate` instead of `kem::Decapsulate`. + ## 0.1.0 (2026-07-08) Initial release. diff --git a/x-wing/src/error.rs b/x-wing/src/error.rs new file mode 100644 index 0000000..0e84017 --- /dev/null +++ b/x-wing/src/error.rs @@ -0,0 +1,19 @@ +use core::fmt; + +/// Error type. +#[derive(Clone, Copy, Debug)] +#[non_exhaustive] +pub enum Error { + /// Decapsulation failed. + Decapsulation, +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Decapsulation => write!(f, "decapsulation error"), + } + } +} diff --git a/x-wing/src/lib.rs b/x-wing/src/lib.rs index 666fccd..a4587ba 100644 --- a/x-wing/src/lib.rs +++ b/x-wing/src/lib.rs @@ -17,22 +17,29 @@ //! // NOTE: requires the `getrandom` feature is enabled //! use x_wing::{ //! XWingKem, -//! kem::{Decapsulate, Encapsulate, Kem} +//! kem::{Encapsulate, Kem, TryDecapsulate} //! }; //! +//! # fn main() -> Result<(), x_wing::Error> { //! let (sk, pk) = XWingKem::generate_keypair(); //! let (ct, sk_sender) = pk.encapsulate(); -//! let sk_receiver = sk.decapsulate(&ct); +//! let sk_receiver = sk.try_decapsulate(&ct)?; //! assert_eq!(sk_sender, sk_receiver); +//! # Ok(()) +//! # } //! ``` +mod error; + +pub use crate::error::Error; pub use kem::{ - self, Decapsulate, Decapsulator, Encapsulate, Generate, InvalidKey, Kem, Key, KeyExport, - KeyInit, KeySizeUser, TryKeyInit, + self, Decapsulator, Encapsulate, Generate, InvalidKey, Kem, Key, KeyExport, KeyInit, + KeySizeUser, TryDecapsulate, TryKeyInit, common::rand_core::{CryptoRng, TryCryptoRng}, }; use core::fmt::{self, Debug}; +use kem::Decapsulate; use ml_kem::{ FromSeed, MlKem768, array::{ @@ -184,9 +191,48 @@ impl TryFrom<&[u8]> for EncapsulationKey { pub struct DecapsulationKey { sk: [u8; DECAPSULATION_KEY_SIZE], ek: EncapsulationKey, + /// Whether to accept or reject non-contributory behaviour in the X25519 component. + /// + /// See for details. + reject_non_contributory: bool, } impl DecapsulationKey { + /// Ensures this decapsulation key rejects "non-contributory behaviour" in the X25519 + /// component of X-Wing. + /// + /// # 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: constructing a `DecapsulationKey` via [`KeyInit`] accepts + /// non-contributory behaviour for backwards-compatibility with existing usages, and + /// this method can be used to instead reject non-contributory behaviour. Once the RFC + /// is published, the default behaviour of `DecapsulationKey` 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/ + #[must_use] + pub fn reject_x25519_non_contributory_behaviour(self) -> Self { + Self { + sk: self.sk, + ek: self.ek.clone(), + reject_non_contributory: true, + } + } + /// Private key as bytes. #[must_use] pub fn as_bytes(&self) -> &[u8; DECAPSULATION_KEY_SIZE] { @@ -202,9 +248,11 @@ impl Debug for DecapsulationKey { } } -impl Decapsulate for DecapsulationKey { +impl TryDecapsulate for DecapsulationKey { + type Error = Error; + #[allow(clippy::similar_names)] // So we can use the names as in the RFC - fn decapsulate(&self, ct: &Ciphertext) -> SharedKey { + fn try_decapsulate(&self, ct: &Ciphertext) -> Result { let ct = CiphertextMessage::from(ct); let (sk_m, sk_x, _pk_m, pk_x) = expand_key(&self.sk); @@ -213,7 +261,11 @@ impl Decapsulate for DecapsulationKey { // equal to ss_x = x25519(sk_x, ct_x) let ss_x = sk_x.diffie_hellman(&ct.ct_x); - combiner(&ss_m, &ss_x, &ct.ct_x, &pk_x) + if self.reject_non_contributory && !ss_x.was_contributory() { + return Err(Error::Decapsulation); + } + + Ok(combiner(&ss_m, &ss_x, &ct.ct_x, &pk_x)) } } @@ -255,7 +307,14 @@ impl KeyInit for DecapsulationKey { fn new(key: &Key) -> Self { let (_sk_m, _sk_x, pk_m, pk_x) = expand_key(key.as_ref()); let ek = EncapsulationKey { pk_m, pk_x }; - Self { sk: key.0, ek } + Self { + sk: key.0, + ek, + // Preserve the prior crate behaviour for now, until + // `draft-irtf-cfrg-concrete-hybrid-kems` is published with a decision on what + // the standard X-Wing protocol should do. + reject_non_contributory: false, + } } } @@ -380,4 +439,24 @@ mod tests { assert_eq!(sk.sk, sk_b.sk); assert!(pk == pk_b); } + + #[test] + #[cfg(feature = "getrandom")] + fn non_contributory() { + let (sk, pk) = XWingKem::generate_keypair(); + + // Construct a ciphertext with non-contributory behaviour. + let ct = CiphertextMessage { + ct_m: pk.pk_m.encapsulate().0, + ct_x: PublicKey::from([0; 32]), + } + .into(); + + // By default, sk accepts. + assert!(sk.try_decapsulate(&ct).is_ok()); + + // If rejecting non-contributory behaviour, sk errors. + let sk = sk.reject_x25519_non_contributory_behaviour(); + assert!(matches!(sk.try_decapsulate(&ct), Err(Error::Decapsulation))); + } } diff --git a/x-wing/tests/kats.rs b/x-wing/tests/kats.rs index 9129b8b..ba8cae1 100644 --- a/x-wing/tests/kats.rs +++ b/x-wing/tests/kats.rs @@ -3,7 +3,7 @@ use core::convert::Infallible; use rand_core::{TryCryptoRng, TryRng, utils}; use serde::Deserialize; -use x_wing::{Decapsulate, Encapsulate, Kem, KeyExport, XWingKem}; +use x_wing::{Encapsulate, Kem, KeyExport, TryDecapsulate, XWingKem}; #[derive(Deserialize)] struct TestVector { @@ -80,6 +80,8 @@ fn run_test(test_vector: TestVector) { assert_eq!(ss, test_vector.ss); assert_eq!(&*ct, test_vector.ct.as_slice()); - let ss = sk.decapsulate(&ct); + let ss = sk + .try_decapsulate(&ct) + .expect("accepting non-contributory behaviour so no errors can occur"); assert_eq!(ss, test_vector.ss); }