Skip to main content

crypto/
xwing.rs

1//! X-Wing: hybrid post-quantum key encapsulation mechanism (KEM) algorithm (ML-KEM-768 with X25519).
2
3use crate::{
4    RandomError,
5    curve25519::x25519,
6    mlkem::{self, MlKemError},
7    sha3::{Sha3_256, Shake256},
8};
9
10/// Size of the X-Wing secret key (32 bytes).
11pub const SECRET_KEY_SIZE: usize = 32;
12/// Size of the X-Wing public key in bytes (ML-KEM-768 pk + X25519 pk = 1216).
13pub const PUBLIC_KEY_SIZE: usize = mlkem::PUBLIC_KEY_SIZE_768 + x25519::KEY_SIZE; // 1216
14/// Size of the X-Wing ciphertext in bytes (ML-KEM-768 ct + X25519 shared secret = 1120).
15pub const CIPHERTEXT_SIZE: usize = mlkem::CIPHERTEXT_SIZE_768 + x25519::SHARED_SECRET_SIZE; // 1120
16/// Size of the X-Wing shared secret (32 bytes).
17pub const SHARED_SECRET_SIZE: usize = 32;
18
19const XWING_LABEL: &[u8; 6] = b"\\.//^\\";
20
21/// X-Wing error type.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum XWingError {
24    MlKem(MlKemError),
25    Random(RandomError),
26}
27
28impl From<MlKemError> for XWingError {
29    fn from(err: MlKemError) -> Self {
30        XWingError::MlKem(err)
31    }
32}
33
34impl From<RandomError> for XWingError {
35    fn from(err: RandomError) -> Self {
36        XWingError::Random(err)
37    }
38}
39
40impl core::fmt::Display for XWingError {
41    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
42        match self {
43            XWingError::MlKem(err) => write!(f, "ML-KEM error: {err}"),
44            XWingError::Random(err) => write!(f, "{err}"),
45        }
46    }
47}
48
49/// Generate an X-Wing keypair.
50///
51/// See [`SecretKey`] for a usage example. Returns [`XWingError::Random`]
52/// when the operating system's random number generator is unavailable or
53/// fails.
54#[cfg(feature = "random")]
55pub fn generate_keypair() -> Result<(SecretKey, PublicKey), XWingError> {
56    let seed: [u8; SECRET_KEY_SIZE] = crate::random::bytes()?;
57    Ok(generate_keypair_derand(&seed))
58}
59
60/// Generates a deterministic X-Wing keypair from the given 32-byte seed.
61///
62/// This is the deterministic variant of [`generate_keypair`], intended for
63/// protocols that need reproducible key generation (e.g. HPKE's
64/// `DeriveKeyPair` and test vectors). The same seed always yields the same
65/// keypair.
66pub fn generate_keypair_derand(secret_key: &[u8; SECRET_KEY_SIZE]) -> (SecretKey, PublicKey) {
67    let (mlkem_sk, x25519_sk, mlkem_pk, x25519_pk) = expand_decapsulation_key(secret_key);
68
69    let secret_key = SecretKey {
70        bytes: *secret_key,
71        x25519_secret_key: x25519_sk,
72        x25519_public_key_bytes: x25519_pk.to_bytes(),
73        mlkem_secret_key: mlkem_sk,
74    };
75
76    let public_key = PublicKey {
77        mlkem_public_key: mlkem_pk,
78        x25519_public_key: x25519_pk,
79    };
80
81    (secret_key, public_key)
82}
83
84/// X-Wing hybrid KEM decapsulation (secret) key.
85///
86/// Combines an ML-KEM-768 and an X25519 secret key as specified in the
87/// X-Wing draft. The shared secret is derived via a combiner that hashes
88/// both component secrets together.
89///
90/// # Example
91///
92/// ```ignore
93/// use crypto::xwing::{generate_keypair, SecretKey, PublicKey};
94///
95/// let (secret_key, public_key) = generate_keypair().unwrap();
96/// let (shared_secret, ciphertext) = public_key.encapsulate().unwrap();
97/// let decapsulated = secret_key.decapsulate(&ciphertext).unwrap();
98/// assert_eq!(shared_secret, decapsulated);
99/// ```
100#[derive(Clone, PartialEq, Eq)]
101#[cfg_attr(feature = "zeroize", derive(zeroize::Zeroize, zeroize::ZeroizeOnDrop))]
102pub struct SecretKey {
103    bytes: [u8; SECRET_KEY_SIZE],
104    x25519_secret_key: x25519::SecretKey,
105    x25519_public_key_bytes: [u8; x25519::KEY_SIZE],
106    mlkem_secret_key: mlkem::SecretKey768,
107}
108
109impl SecretKey {
110    pub fn to_bytes(&self) -> [u8; SECRET_KEY_SIZE] {
111        self.bytes
112    }
113
114    /// Returns the corresponding encapsulation (public) key.
115    ///
116    /// The public key is recomputed from the decapsulation key material, as
117    /// specified by the X-Wing draft.
118    pub fn public_key(&self) -> PublicKey {
119        return PublicKey {
120            mlkem_public_key: self.mlkem_secret_key.public_key(),
121            x25519_public_key: x25519::PublicKey::from_bytes(&self.x25519_public_key_bytes),
122        };
123    }
124
125    pub fn decapsulate(&self, ct: &[u8; CIPHERTEXT_SIZE]) -> Result<[u8; SHARED_SECRET_SIZE], XWingError> {
126        let ct_m = &ct[..mlkem::CIPHERTEXT_SIZE_768].try_into().unwrap();
127        let ct_x = x25519::PublicKey::from_bytes(&ct[mlkem::CIPHERTEXT_SIZE_768..].try_into().unwrap());
128
129        let ss_m = self.mlkem_secret_key.decapsulate(ct_m)?;
130        // A low-order `ct_x` yields an all-zero (non-contributory) X25519 shared
131        // secret. X-Wing is safe here because the combiner binds `ct_x` and the
132        // recipient's `pk_x`, and the draft specifies that Decapsulate always
133        // returns a shared secret, so the degenerate value is absorbed rather
134        // than treated as an error.
135        let ss_x = self.x25519_secret_key.ecdh(&ct_x).unwrap_or_default();
136
137        Ok(combiner(&ss_m, &ss_x, &ct_x.to_bytes(), &self.x25519_public_key_bytes))
138    }
139}
140
141/// X-Wing hybrid KEM encapsulation (public) key.
142///
143/// See [`SecretKey`] for a full usage example.
144#[derive(Clone, Debug, PartialEq, Eq)]
145pub struct PublicKey {
146    mlkem_public_key: mlkem::PublicKey768,
147    x25519_public_key: x25519::PublicKey,
148}
149
150impl PublicKey {
151    /// Parses an encapsulation key from its 1216-byte encoding
152    /// (ML-KEM-768 encapsulation key followed by the X25519 public key).
153    ///
154    /// # Errors
155    ///
156    /// Returns [`MlKemError::InvalidKey`] when the ML-KEM-768 part is not a
157    /// valid encapsulation key (FIPS 203 modulus check).
158    pub fn from_bytes(bytes: &[u8; PUBLIC_KEY_SIZE]) -> Result<PublicKey, MlKemError> {
159        let mlkem_public_key =
160            mlkem::PublicKey768::from_bytes(&bytes[..mlkem::PUBLIC_KEY_SIZE_768].try_into().unwrap())?;
161        let x25519_public_key = x25519::PublicKey::from_bytes(&bytes[mlkem::PUBLIC_KEY_SIZE_768..].try_into().unwrap());
162
163        return Ok(PublicKey {
164            mlkem_public_key,
165            x25519_public_key,
166        });
167    }
168
169    pub fn to_bytes(&self) -> [u8; PUBLIC_KEY_SIZE] {
170        let mut bytes = [0u8; PUBLIC_KEY_SIZE];
171        bytes[..mlkem::PUBLIC_KEY_SIZE_768].copy_from_slice(&self.mlkem_public_key.to_bytes());
172        bytes[mlkem::PUBLIC_KEY_SIZE_768..].copy_from_slice(&self.x25519_public_key.to_bytes());
173        bytes
174    }
175
176    /// Encapsulates a fresh random shared secret against this public key.
177    ///
178    /// Returns [`XWingError::Random`] when the operating system's random
179    /// number generator is unavailable or fails.
180    #[cfg(feature = "random")]
181    pub fn encapsulate(&self) -> Result<([u8; SHARED_SECRET_SIZE], [u8; CIPHERTEXT_SIZE]), XWingError> {
182        let eseed: [u8; 64] = crate::random::bytes()?;
183        Ok(self.encapsulate_derand(&eseed))
184    }
185
186    /// Encapsulates a shared secret against this public key using the given
187    /// 64-byte seed, returning the shared secret and the ciphertext.
188    ///
189    /// This is the deterministic variant of [`encapsulate`](Self::encapsulate),
190    /// intended for protocols that need reproducible encapsulation (e.g. HPKE
191    /// and test vectors). The seed MUST be uniformly random and MUST NOT be
192    /// reused across encapsulations.
193    pub fn encapsulate_derand(&self, eseed: &[u8; 64]) -> ([u8; SHARED_SECRET_SIZE], [u8; CIPHERTEXT_SIZE]) {
194        let ek_x = x25519::SecretKey::from_bytes(&eseed[32..64].try_into().unwrap());
195        let ct_x = ek_x.public_key();
196        // See `decapsulate`: an all-zero X25519 shared secret (low-order peer
197        // key) is safe to absorb because the combiner binds `ct_x` and `pk_x`.
198        let ss_x = ek_x.ecdh(&self.x25519_public_key).unwrap_or_default();
199
200        let m = &eseed[..32].try_into().unwrap();
201        let (ss_m, ct_m) = self.mlkem_public_key.encapsulate_derand(m);
202
203        let ss = combiner(&ss_m, &ss_x, &ct_x.to_bytes(), &self.x25519_public_key.to_bytes());
204
205        let mut ct = [0u8; CIPHERTEXT_SIZE];
206        ct[..mlkem::CIPHERTEXT_SIZE_768].copy_from_slice(&ct_m);
207        ct[mlkem::CIPHERTEXT_SIZE_768..].copy_from_slice(&ct_x.to_bytes());
208
209        (ss, ct)
210    }
211}
212
213fn expand_decapsulation_key(
214    secret_key: &[u8; 32],
215) -> (mlkem::SecretKey768, x25519::SecretKey, mlkem::PublicKey768, x25519::PublicKey) {
216    let mut expanded_secret_key = [0u8; 96];
217    Shake256::hash(secret_key, &mut expanded_secret_key);
218
219    let (sk_m, pk_m) = mlkem::SecretKey768::generate_derand(&expanded_secret_key[..64].try_into().unwrap());
220
221    let sk_x = x25519::SecretKey::from_bytes(&expanded_secret_key[64..96].try_into().unwrap());
222    let pk_x = sk_x.public_key();
223
224    (sk_m, sk_x, pk_m, pk_x)
225}
226
227fn combiner(
228    ss_m: &[u8; mlkem::SHARED_SECRET_SIZE],
229    ss_x: &[u8; x25519::KEY_SIZE],
230    ct_x: &[u8; x25519::KEY_SIZE],
231    pk_x: &[u8; x25519::KEY_SIZE],
232) -> [u8; SHARED_SECRET_SIZE] {
233    use crate::Hasher;
234    let mut hasher = Sha3_256::new();
235    hasher.update(ss_m);
236    hasher.update(ss_x);
237    hasher.update(ct_x);
238    hasher.update(pk_x);
239    hasher.update(XWING_LABEL);
240    hasher.sum().as_ref().try_into().unwrap()
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    fn hex_to_array<const N: usize>(hex_str: &str) -> [u8; N] {
248        let bytes = hex::decode(hex_str).unwrap();
249        return bytes.try_into().unwrap();
250    }
251
252    #[test]
253    fn constants() {
254        assert!(PUBLIC_KEY_SIZE == 1216);
255        assert!(CIPHERTEXT_SIZE == 1120);
256    }
257
258    #[cfg(feature = "zeroize")]
259    #[test]
260    fn secret_key_zeroize_clears_bytes() {
261        use zeroize::Zeroize;
262
263        let (mut secret_key, _) = generate_keypair_derand(&[1u8; SECRET_KEY_SIZE]);
264        assert_ne!(secret_key.to_bytes(), [0u8; SECRET_KEY_SIZE]);
265        secret_key.zeroize();
266        assert_eq!(secret_key.to_bytes(), [0u8; SECRET_KEY_SIZE]);
267    }
268
269    struct TestVector {
270        seed: &'static str,
271        eseed: &'static str,
272        ss: &'static str,
273    }
274
275    const TEST_VECTORS: [TestVector; 3] = [
276        TestVector {
277            seed: "7f9c2ba4e88f827d616045507605853ed73b8093f6efbc88eb1a6eacfa66ef26",
278            eseed: "3cb1eea988004b93103cfb0aeefd2a686e01fa4a58e8a3639ca8a1e3f9ae57e235b8cc873c23dc62b8d260169afa2f75ab916a58d974918835d25e6a435085b2",
279            ss: "d2df0522128f09dd8e2c92b1e905c793d8f57a54c3da25861f10bf4ca613e384",
280        },
281        TestVector {
282            seed: "badfd6dfaac359a5efbb7bcc4b59d538df9a04302e10c8bc1cbf1a0b3a5120ea",
283            eseed: "17cda7cfad765f5623474d368ccca8af0007cd9f5e4c849f167a580b14aabdefaee7eef47cb0fca9767be1fda69419dfb927e9df07348b196691abaeb580b32d",
284            ss: "f2e86241c64d60f6649fbc6c5b7d17180b780a3f34355e64a85749949c45f150",
285        },
286        TestVector {
287            seed: "ef58538b8d23f87732ea63b02b4fa0f4873360e2841928cd60dd4cee8cc0d4c9",
288            eseed: "22a96188d032675c8ac850933c7aff1533b94c834adbb69c6115bad4692d8619f90b0cdf8a7b9c264029ac185b70b83f2801f2f4b3f70c593ea3aeeb613a7f1b",
289            ss: "953f7f4e8c5b5049bdc771d1dffada0dd961477d1a2ae0988baa7ea6898d893f",
290        },
291    ];
292
293    #[test]
294    fn test_vectors_from_draft() {
295        for (i, tv) in TEST_VECTORS.iter().enumerate() {
296            let seed: [u8; 32] = hex_to_array(tv.seed);
297            let eseed: [u8; 64] = hex_to_array(tv.eseed);
298            let expected_ss: [u8; 32] = hex_to_array(tv.ss);
299
300            let (secret_key, pk) = generate_keypair_derand(&seed);
301            assert_eq!(secret_key.to_bytes(), seed, "vector {i}: sk mismatch");
302
303            let (ss, ct) = pk.encapsulate_derand(&eseed);
304            assert_eq!(ss, expected_ss, "vector {i}: encaps ss mismatch");
305
306            let decapsulated_ss = secret_key.decapsulate(&ct).unwrap();
307            assert_eq!(decapsulated_ss, expected_ss, "vector {i}: decaps ss mismatch");
308        }
309    }
310
311    #[test]
312    fn round_trip() {
313        let (secret_key, public_key) = generate_keypair().unwrap();
314        let (ss, ct) = public_key.encapsulate().unwrap();
315        let decapsulated = secret_key.decapsulate(&ct).unwrap();
316        assert_eq!(ss, decapsulated);
317    }
318
319    #[test]
320    fn round_trip_many() {
321        for _ in 0..10 {
322            let (secret_key, public_key) = generate_keypair().unwrap();
323            let (ss, ct) = public_key.encapsulate().unwrap();
324            let decapsulated = secret_key.decapsulate(&ct).unwrap();
325            assert_eq!(ss, decapsulated);
326        }
327    }
328
329    #[test]
330    fn decapsulation_with_wrong_key_produces_different_secret() {
331        let (_, pk_a) = generate_keypair().unwrap();
332        let (sk_b, _) = generate_keypair().unwrap();
333
334        let (ss_a, ct) = pk_a.encapsulate().unwrap();
335        let ss_b = sk_b.decapsulate(&ct).unwrap();
336        assert_ne!(ss_a, ss_b);
337    }
338
339    #[test]
340    fn tampered_ciphertext_produces_different_secret() {
341        let (secret_key, public_key) = generate_keypair().unwrap();
342        let (ss, mut ct) = public_key.encapsulate().unwrap();
343
344        ct[0] ^= 0x80;
345
346        let tampered_ss = secret_key.decapsulate(&ct).unwrap();
347        assert_ne!(ss, tampered_ss);
348    }
349
350    #[test]
351    fn derandomized_keygen_is_deterministic() {
352        let seed: [u8; 32] = hex_to_array("7f9c2ba4e88f827d616045507605853ed73b8093f6efbc88eb1a6eacfa66ef26");
353        let (sk1, pk1) = generate_keypair_derand(&seed);
354        let (sk2, pk2) = generate_keypair_derand(&seed);
355        assert_eq!(sk1.to_bytes(), sk2.to_bytes());
356        assert_eq!(pk1.to_bytes(), pk2.to_bytes());
357    }
358
359    #[test]
360    fn derandomized_encaps_is_deterministic() {
361        let seed: [u8; 32] = hex_to_array("7f9c2ba4e88f827d616045507605853ed73b8093f6efbc88eb1a6eacfa66ef26");
362        let eseed: [u8; 64] = hex_to_array(
363            "3cb1eea988004b93103cfb0aeefd2a686e01fa4a58e8a3639ca8a1e3f9ae57e235b8cc873c23dc62b8d260169afa2f75ab916a58d974918835d25e6a435085b2",
364        );
365        let (_, pk) = generate_keypair_derand(&seed);
366
367        let (ss1, ct1) = pk.encapsulate_derand(&eseed);
368        let (ss2, ct2) = pk.encapsulate_derand(&eseed);
369        assert_eq!(ct1, ct2);
370        assert_eq!(ss1, ss2);
371    }
372
373    #[test]
374    fn xwing_label_is_correct() {
375        assert_eq!(XWING_LABEL.len(), 6);
376        assert_eq!(hex::encode(XWING_LABEL), "5c2e2f2f5e5c");
377    }
378
379    #[test]
380    fn expand_decapsulation_key_is_deterministic() {
381        let seed: [u8; 32] = hex_to_array("7f9c2ba4e88f827d616045507605853ed73b8093f6efbc88eb1a6eacfa66ef26");
382
383        let (sk_m1, sk_x1, pk_m1, pk_x1) = expand_decapsulation_key(&seed);
384        let (sk_m2, sk_x2, pk_m2, pk_x2) = expand_decapsulation_key(&seed);
385        assert_eq!(sk_m1.to_bytes(), sk_m2.to_bytes());
386        assert_eq!(sk_x1.to_bytes(), sk_x2.to_bytes());
387        assert_eq!(pk_m1, pk_m2);
388        assert_eq!(pk_x1, pk_x2);
389    }
390
391    #[test]
392    fn combiner_is_deterministic() {
393        let ss_m = [0x01u8; 32];
394        let ss_x = [0x02u8; 32];
395        let ct_x = [0x03u8; 32];
396        let pk_x = [0x04u8; 32];
397
398        let result1 = combiner(&ss_m, &ss_x, &ct_x, &pk_x);
399        let result2 = combiner(&ss_m, &ss_x, &ct_x, &pk_x);
400        assert_eq!(result1, result2);
401    }
402}