diff --git a/x-wing/CHANGELOG.md b/x-wing/CHANGELOG.md index e297df2..a490e68 100644 --- a/x-wing/CHANGELOG.md +++ b/x-wing/CHANGELOG.md @@ -5,5 +5,10 @@ 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 +- `DecapsulationKeyRejectNonContrib` +- `Error` enum. + ## 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..a06c1c6 100644 --- a/x-wing/src/lib.rs +++ b/x-wing/src/lib.rs @@ -26,9 +26,12 @@ //! assert_eq!(sk_sender, sk_receiver); //! ``` +mod error; + +pub use crate::error::Error; pub use kem::{ self, Decapsulate, Decapsulator, Encapsulate, Generate, InvalidKey, Kem, Key, KeyExport, - KeyInit, KeySizeUser, TryKeyInit, + KeyInit, KeySizeUser, TryDecapsulate, TryKeyInit, common::rand_core::{CryptoRng, TryCryptoRng}, }; @@ -180,6 +183,9 @@ impl TryFrom<&[u8]> for EncapsulationKey { } /// X-Wing decapsulation key or private key. +/// +/// This decapsulation key accepts "non-contributory behaviour" in the X25519 component of +/// X-Wing. To reject instead, use [`DecapsulationKeyRejectNonContrib`]. #[derive(Clone)] pub struct DecapsulationKey { sk: [u8; DECAPSULATION_KEY_SIZE], @@ -202,10 +208,11 @@ impl Debug for DecapsulationKey { } } -impl Decapsulate for DecapsulationKey { - #[allow(clippy::similar_names)] // So we can use the names as in the RFC - fn decapsulate(&self, ct: &Ciphertext) -> SharedKey { - let ct = CiphertextMessage::from(ct); +impl DecapsulationKey { + fn decapsulate_inner( + &self, + ct: &CiphertextMessage, + ) -> (SharedKey, x25519_dalek::SharedSecret, PublicKey) { let (sk_m, sk_x, _pk_m, pk_x) = expand_key(&self.sk); let ss_m = sk_m.decapsulate(&ct.ct_m); @@ -213,6 +220,16 @@ impl Decapsulate for DecapsulationKey { // equal to ss_x = x25519(sk_x, ct_x) let ss_x = sk_x.diffie_hellman(&ct.ct_x); + (ss_m, ss_x, pk_x) + } +} + +impl Decapsulate for DecapsulationKey { + #[allow(clippy::similar_names)] // So we can use the names as in the RFC + fn decapsulate(&self, ct: &Ciphertext) -> SharedKey { + let ct = CiphertextMessage::from(ct); + let (ss_m, ss_x, pk_x) = self.decapsulate_inner(&ct); + combiner(&ss_m, &ss_x, &ct.ct_x, &pk_x) } } @@ -268,6 +285,91 @@ impl KeyExport for DecapsulationKey { #[cfg(feature = "zeroize")] impl ZeroizeOnDrop for DecapsulationKey {} +/// X-Wing decapsulation key or private key. +/// +/// This decapsulation key rejects "non-contributory behaviour" in the X25519 component of +/// X-Wing. To accept instead, use [`DecapsulationKey`]. +/// +/// # 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`] 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, `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/ +#[derive(Clone, Debug)] +pub struct DecapsulationKeyRejectNonContrib(DecapsulationKey); + +impl TryDecapsulate for DecapsulationKeyRejectNonContrib { + type Error = Error; + + #[allow(clippy::similar_names)] // So we can use the names as in the RFC + fn try_decapsulate(&self, ct: &Ciphertext) -> Result { + let ct = CiphertextMessage::from(ct); + let (ss_m, ss_x, pk_x) = self.0.decapsulate_inner(&ct); + + if !ss_x.was_contributory() { + return Err(Error::Decapsulation); + } + + Ok(combiner(&ss_m, &ss_x, &ct.ct_x, &pk_x)) + } +} + +impl Decapsulator for DecapsulationKeyRejectNonContrib { + type Kem = XWingKem; + + fn encapsulation_key(&self) -> &EncapsulationKey { + self.0.encapsulation_key() + } +} + +impl From<[u8; DECAPSULATION_KEY_SIZE]> for DecapsulationKeyRejectNonContrib { + fn from(sk: [u8; DECAPSULATION_KEY_SIZE]) -> Self { + Self(sk.into()) + } +} + +impl Generate for DecapsulationKeyRejectNonContrib { + fn try_generate_from_rng(rng: &mut R) -> Result + where + R: TryCryptoRng + ?Sized, + { + DecapsulationKey::try_generate_from_rng(rng).map(Self) + } +} + +impl KeySizeUser for DecapsulationKeyRejectNonContrib { + type KeySize = U32; +} + +impl KeyInit for DecapsulationKeyRejectNonContrib { + fn new(key: &Key) -> Self { + Self(DecapsulationKey::new(key)) + } +} + +impl KeyExport for DecapsulationKeyRejectNonContrib { + fn to_bytes(&self) -> Key { + self.0.to_bytes() + } +} + fn expand_key( sk: &[u8; DECAPSULATION_KEY_SIZE], ) -> ( @@ -380,4 +482,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 = DecapsulationKeyRejectNonContrib(sk); + assert!(matches!(sk.try_decapsulate(&ct), Err(Error::Decapsulation))); + } }