Skip to content

Commit ebf21cb

Browse files
feat(crypto): add X-Wing post-quantum hybrid KEM (ML-KEM-768 + X25519) (#422)
Add opt-in post-quantum public-key cryptography to the Rust core, based on the X-Wing hybrid KEM (ML-KEM-768 + X25519). See SEC-15167. - KeyVersion::V2: X-Wing key pairs (public key 1224 B, private key 40 B) - CiphertextVersion::V3: KEM-DEM asymmetric ciphertext. The X-Wing shared secret is run through HKDF-SHA-256 with the label "devolutions_crypto ciphertext_v3", then used with XChaCha20-Poly1305. - New key_encapsulation module: encapsulate / decapsulate producing an EncapsulatedKey (DataType::EncapsulatedKey = 10) and a SecretKey. HKDF label: "devolutions_crypto key_encapsulation_v1". - Latest still selects the existing versions; PQ is opt-in. - Decapsulation rejects non-contributory X25519 components; V2 public keys with a low-order X25519 half are rejected on parse. - mix_key_exchange and online ciphertexts reject V2 keys. - Conformity vectors and low-order tests added. Breaking change (Rust API): the public From<&PublicKey> for x25519_dalek::PublicKey and From<&PrivateKey> for StaticSecret impls are removed in favour of crate-private fallible accessors.
1 parent c42c1a4 commit ebf21cb

17 files changed

Lines changed: 1356 additions & 28 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
66

77
## [Unreleased] -
88

9+
### Breaking Changes
10+
11+
- Rust: removed the public `From<&PublicKey> for x25519_dalek::PublicKey` and `From<&PrivateKey> for x25519_dalek::StaticSecret` implementations.
12+
13+
### Added
14+
15+
- Post-quantum hybrid public-key cryptography using X-Wing (ML-KEM-768 + X25519): `KeyVersion::V2` key pairs, `CiphertextVersion::V3` asymmetric ciphertexts, and a new `key_encapsulation` module (`encapsulate` / `decapsulate`, `DataType::EncapsulatedKey`). This is opt-in: `Latest` still selects the existing versions.
16+
917
## [0.10.3] - 2026-10-26
1018

1119
### Breaking Changes

‎Cargo.lock‎

Lines changed: 104 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ name = "devolutions-crypto"
2222
version.workspace = true
2323
authors = ["Mathieu Morrissette <mmorrissette@devolutions.net>", "Sebastien Duquette <sduquette@devolutions.net>"]
2424
edition = "2024"
25+
rust-version = "1.85"
2526
readme = "README_RUST.md"
2627
license = "MIT OR Apache-2.0"
2728
repository = "https://github.com/Devolutions/devolutions-crypto"
@@ -57,6 +58,10 @@ rust-argon2 = { workspace = true }
5758

5859
ed25519-dalek = { version = "3", features = [ "rand_core" ] }
5960
x25519-dalek = { version = "3", features = [ "static_secrets" ] }
61+
# Do not enable the `getrandom` feature:
62+
# randomness is supplied explicitly from the OS RNG.
63+
x-wing = { version = "0.1.1", default-features = false, features = ["zeroize"] }
64+
hkdf = "0.13"
6065

6166
# used for fuzzing
6267
arbitrary = { version = "1", features = ["derive"], optional = true }

‎src/ciphertext/ciphertext_v2.rs‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,16 @@ impl CiphertextV2Symmetric {
9090
// Derive key
9191
let key = Zeroizing::new(CiphertextV2Symmetric::derive_key(key));
9292

93+
Self::encrypt_with_derived_key(data, &key, aad, header)
94+
}
95+
96+
/// Encrypts with an already-derived 32-byte key, skipping the SHA-256 key derivation of `encrypt`.
97+
pub(crate) fn encrypt_with_derived_key(
98+
data: &[u8],
99+
key: &[u8; 32],
100+
aad: &[u8],
101+
header: &Header<Ciphertext>,
102+
) -> Result<Self> {
93103
// Generate nonce
94104
let mut nonce_bytes = [0u8; 24];
95105

@@ -125,6 +135,16 @@ impl CiphertextV2Symmetric {
125135
// Derive key
126136
let key = Zeroizing::new(CiphertextV2Symmetric::derive_key(key));
127137

138+
self.decrypt_with_derived_key(&key, aad, header)
139+
}
140+
141+
/// Decrypts with an already-derived 32-byte key, skipping the SHA-256 key derivation of `decrypt`.
142+
pub(crate) fn decrypt_with_derived_key(
143+
&self,
144+
key: &[u8; 32],
145+
aad: &[u8],
146+
header: &Header<Ciphertext>,
147+
) -> Result<Vec<u8>> {
128148
// Authenticate the header
129149
let mut mac_data: Zeroizing<Vec<u8>> = Zeroizing::new(header.into());
130150
mac_data.extend_from_slice(aad);
@@ -184,7 +204,7 @@ impl CiphertextV2Asymmetric {
184204
aad: &[u8],
185205
header: &Header<Ciphertext>,
186206
) -> Result<Self> {
187-
let public_key = x25519_dalek::PublicKey::from(public_key);
207+
let public_key = public_key.as_x25519()?;
188208

189209
let ephemeral_private_key = StaticSecret::from(*crate::utils::random_bytes::<32>()?);
190210
let ephemeral_public_key = x25519_dalek::PublicKey::from(&ephemeral_private_key);
@@ -209,7 +229,7 @@ impl CiphertextV2Asymmetric {
209229
aad: &[u8],
210230
header: &Header<Ciphertext>,
211231
) -> Result<Vec<u8>> {
212-
let private_key = StaticSecret::from(private_key);
232+
let private_key = private_key.as_x25519()?;
213233

214234
let key = private_key.diffie_hellman(&self.public_key);
215235

‎src/ciphertext/ciphertext_v3.rs‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
//! Ciphertext V3: X-Wing (X25519 + ML-KEM-768) + HKDF-SHA-256 + XChaCha20Poly1305
2+
//!
3+
//! Wire format of the payload (after the header):
4+
//! `X-Wing ciphertext (1120 bytes) || nonce (24 bytes) || XChaCha20Poly1305 ciphertext and tag`.
5+
use super::{PrivateKey, PublicKey};
6+
7+
use super::Error;
8+
use super::Header;
9+
use super::Result;
10+
11+
use super::Ciphertext;
12+
use super::ciphertext_v2::CiphertextV2Symmetric;
13+
14+
use crate::key::key_v2::{KEM_CIPHERTEXT_SIZE, KemCiphertext};
15+
16+
use std::convert::TryFrom;
17+
18+
#[cfg(feature = "fuzz")]
19+
use arbitrary::Arbitrary;
20+
21+
/// HKDF-SHA-256 `info` label binding the X-Wing shared secret to asymmetric ciphertext V3.
22+
/// Part of the wire format: changing it breaks decryption of existing data.
23+
const HKDF_INFO: &[u8] = b"devolutions_crypto ciphertext_v3";
24+
25+
#[derive(Clone, Debug)]
26+
pub struct CiphertextV3Asymmetric {
27+
kem_ciphertext: Box<KemCiphertext>,
28+
ciphertext: CiphertextV2Symmetric,
29+
}
30+
31+
#[cfg(feature = "fuzz")]
32+
impl<'a> Arbitrary<'a> for CiphertextV3Asymmetric {
33+
fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
34+
let kem_ciphertext: [u8; KEM_CIPHERTEXT_SIZE] = Arbitrary::arbitrary(u)?;
35+
let ciphertext = CiphertextV2Symmetric::arbitrary(u)?;
36+
Ok(Self {
37+
kem_ciphertext: Box::new(KemCiphertext::from(kem_ciphertext)),
38+
ciphertext,
39+
})
40+
}
41+
}
42+
43+
impl From<CiphertextV3Asymmetric> for Vec<u8> {
44+
fn from(cipher: CiphertextV3Asymmetric) -> Self {
45+
let mut data = cipher.kem_ciphertext.to_vec();
46+
let mut ciphertext: Vec<u8> = cipher.ciphertext.into();
47+
data.append(&mut ciphertext);
48+
data
49+
}
50+
}
51+
52+
impl TryFrom<&[u8]> for CiphertextV3Asymmetric {
53+
type Error = Error;
54+
fn try_from(data: &[u8]) -> Result<Self> {
55+
if data.len() <= KEM_CIPHERTEXT_SIZE {
56+
return Err(Error::InvalidLength);
57+
};
58+
59+
let kem_ciphertext = KemCiphertext::try_from(&data[..KEM_CIPHERTEXT_SIZE])
60+
.map_err(|_| Error::InvalidLength)?;
61+
let ciphertext = CiphertextV2Symmetric::try_from(&data[KEM_CIPHERTEXT_SIZE..])?;
62+
63+
Ok(Self {
64+
kem_ciphertext: Box::new(kem_ciphertext),
65+
ciphertext,
66+
})
67+
}
68+
}
69+
70+
impl CiphertextV3Asymmetric {
71+
pub fn encrypt(
72+
data: &[u8],
73+
public_key: &PublicKey,
74+
aad: &[u8],
75+
header: &Header<Ciphertext>,
76+
) -> Result<Self> {
77+
let (kem_ciphertext, key) = public_key.x_wing_encapsulate(HKDF_INFO)?;
78+
79+
let ciphertext = CiphertextV2Symmetric::encrypt_with_derived_key(data, &key, aad, header)?;
80+
81+
Ok(Self {
82+
kem_ciphertext,
83+
ciphertext,
84+
})
85+
}
86+
87+
pub fn decrypt(
88+
&self,
89+
private_key: &PrivateKey,
90+
aad: &[u8],
91+
header: &Header<Ciphertext>,
92+
) -> Result<Vec<u8>> {
93+
let key = private_key.x_wing_decapsulate(&self.kem_ciphertext, HKDF_INFO)?;
94+
95+
self.ciphertext.decrypt_with_derived_key(&key, aad, header)
96+
}
97+
}

0 commit comments

Comments
 (0)