Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions x-wing/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
19 changes: 19 additions & 0 deletions x-wing/src/error.rs
Original file line number Diff line number Diff line change
@@ -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"),
}
}
}
95 changes: 87 additions & 8 deletions x-wing/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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 <https://github.com/RustCrypto/KEMs/issues/364> 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 {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made this an extra method and didn't change the default behaviour, in order to make this a non-breaking change. But then I remembered that changing from kem::Decapsulate to kem::TryDecapsulate is breaking anyway, so preserving behaviour is not technically necessary as long as we provide a way to reach either state.

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] {
Expand All @@ -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<SharedKey, Self::Error> {
let ct = CiphertextMessage::from(ct);
let (sk_m, sk_x, _pk_m, pk_x) = expand_key(&self.sk);

Expand All @@ -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))
}
}

Expand Down Expand Up @@ -255,7 +307,14 @@ impl KeyInit for DecapsulationKey {
fn new(key: &Key<Self>) -> 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,
}
}
}

Expand Down Expand Up @@ -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)));
}
}
6 changes: 4 additions & 2 deletions x-wing/tests/kats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}