From 411fbee1f27853287214a8f8f80d75854e59887c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Duquette?= Date: Mon, 2 Nov 2026 14:02:57 -0500 Subject: [PATCH 1/4] feat(crypto): add X-Wing post-quantum hybrid KEM (ML-KEM-768 + X25519) 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. Bindings (FFI, uniffi, wasm, C#, CLI, fuzz) follow in a separate PR. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 8 + Cargo.lock | 106 +++++- Cargo.toml | 5 + src/ciphertext/ciphertext_v2.rs | 24 +- src/ciphertext/ciphertext_v3.rs | 97 ++++++ src/ciphertext/mod.rs | 212 +++++++++++- src/enums.rs | 33 +- src/key/key_v2.rs | 191 +++++++++++ src/key/mod.rs | 169 +++++++++- src/key_encapsulation/key_encapsulation_v1.rs | 73 +++++ src/key_encapsulation/mod.rs | 309 ++++++++++++++++++ src/lib.rs | 14 +- src/online_ciphertext/online_ciphertext_v1.rs | 8 +- src/utils.rs | 4 + tests/conformity.rs | 76 ++++- tests/low_order_points.rs | 52 +++ uniffi/devolutions-crypto-uniffi/src/lib.rs | 3 + 17 files changed, 1356 insertions(+), 28 deletions(-) create mode 100644 src/ciphertext/ciphertext_v3.rs create mode 100644 src/key/key_v2.rs create mode 100644 src/key_encapsulation/key_encapsulation_v1.rs create mode 100644 src/key_encapsulation/mod.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d4ee44c..d7e0fb84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## [Unreleased] - +### Breaking Changes + +- Rust: removed the public `From<&PublicKey> for x25519_dalek::PublicKey` and `From<&PrivateKey> for x25519_dalek::StaticSecret` implementations. + +### Added + +- 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. + ## [0.10.3] - 2026-10-26 ### Breaking Changes diff --git a/Cargo.lock b/Cargo.lock index 40facfd6..a49ac596 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -479,9 +479,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe3e9d21d39e6f39ff46e03d5b621424f88ee23d8a1672c6bfc9ccc7f2da4f6a" +checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f" dependencies = [ "cmov", ] @@ -554,6 +554,7 @@ dependencies = [ "ed25519-dalek", "getrandom 0.2.17", "getrandom 0.4.3", + "hkdf", "hmac", "js-sys", "num_enum", @@ -571,6 +572,7 @@ dependencies = [ "typed-builder", "wasm-bindgen", "wasm-bindgen-test", + "x-wing", "x25519-dalek", "zeroize", ] @@ -799,6 +801,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.13.0" @@ -814,7 +825,9 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ + "ctutils", "typenum", + "zeroize", ] [[package]] @@ -872,6 +885,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures", +] + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common", + "rand_core 0.10.1", +] + [[package]] name = "libc" version = "0.2.191" @@ -928,6 +961,33 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "ml-kem" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84ca78b66f46050d49fa21693ae8adab2081d8101b65d8067316b6b70b64a0d1" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "rand_core 0.10.1", + "sha3", + "shake", + "zeroize", +] + +[[package]] +name = "module-lattice" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a1e412c8ee83a1c678f427806219c49c96b2511b9f07497514f0e43fb26155" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", + "zeroize", +] + [[package]] name = "nom" version = "7.1.3" @@ -1310,6 +1370,28 @@ dependencies = [ "digest", ] +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest", + "keccak", + "sponge-cursor", +] + +[[package]] +name = "shake" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" +dependencies = [ + "digest", + "keccak", + "sponge-cursor", +] + [[package]] name = "shlex" version = "2.0.1" @@ -1343,6 +1425,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "static_assertions" version = "1.1.0" @@ -1853,6 +1941,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "x-wing" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01576e56599d01d75c5efcd5b30fa5de29e776f5a225e43fbf8c92c7302d4c8b" +dependencies = [ + "kem", + "ml-kem", + "sha3", + "shake", + "x25519-dalek", + "zeroize", +] + [[package]] name = "x25519-dalek" version = "3.0.0" diff --git a/Cargo.toml b/Cargo.toml index 2a3745ae..51aae823 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ name = "devolutions-crypto" version.workspace = true authors = ["Mathieu Morrissette ", "Sebastien Duquette "] edition = "2024" +rust-version = "1.85" readme = "README_RUST.md" license = "MIT OR Apache-2.0" repository = "https://github.com/Devolutions/devolutions-crypto" @@ -57,6 +58,10 @@ rust-argon2 = { workspace = true } ed25519-dalek = { version = "3", features = [ "rand_core" ] } x25519-dalek = { version = "3", features = [ "static_secrets" ] } +# X-Wing hybrid KEM (X25519 + ML-KEM-768). Do not enable the `getrandom` feature: +# randomness is supplied explicitly from the OS RNG. +x-wing = { version = "0.1.1", default-features = false, features = ["zeroize"] } +hkdf = "0.13" # used for fuzzing arbitrary = { version = "1", features = ["derive"], optional = true } diff --git a/src/ciphertext/ciphertext_v2.rs b/src/ciphertext/ciphertext_v2.rs index c09e39b4..3338b243 100644 --- a/src/ciphertext/ciphertext_v2.rs +++ b/src/ciphertext/ciphertext_v2.rs @@ -90,6 +90,16 @@ impl CiphertextV2Symmetric { // Derive key let key = Zeroizing::new(CiphertextV2Symmetric::derive_key(key)); + Self::encrypt_with_derived_key(data, &key, aad, header) + } + + /// Encrypts with an already-derived 32-byte key, skipping the SHA-256 key derivation of `encrypt`. + pub(crate) fn encrypt_with_derived_key( + data: &[u8], + key: &[u8; 32], + aad: &[u8], + header: &Header, + ) -> Result { // Generate nonce let mut nonce_bytes = [0u8; 24]; @@ -125,6 +135,16 @@ impl CiphertextV2Symmetric { // Derive key let key = Zeroizing::new(CiphertextV2Symmetric::derive_key(key)); + self.decrypt_with_derived_key(&key, aad, header) + } + + /// Decrypts with an already-derived 32-byte key, skipping the SHA-256 key derivation of `decrypt`. + pub(crate) fn decrypt_with_derived_key( + &self, + key: &[u8; 32], + aad: &[u8], + header: &Header, + ) -> Result> { // Authenticate the header let mut mac_data: Zeroizing> = Zeroizing::new(header.into()); mac_data.extend_from_slice(aad); @@ -184,7 +204,7 @@ impl CiphertextV2Asymmetric { aad: &[u8], header: &Header, ) -> Result { - let public_key = x25519_dalek::PublicKey::from(public_key); + let public_key = public_key.as_x25519()?; let ephemeral_private_key = StaticSecret::from(*crate::utils::random_bytes::<32>()?); let ephemeral_public_key = x25519_dalek::PublicKey::from(&ephemeral_private_key); @@ -209,7 +229,7 @@ impl CiphertextV2Asymmetric { aad: &[u8], header: &Header, ) -> Result> { - let private_key = StaticSecret::from(private_key); + let private_key = private_key.as_x25519()?; let key = private_key.diffie_hellman(&self.public_key); diff --git a/src/ciphertext/ciphertext_v3.rs b/src/ciphertext/ciphertext_v3.rs new file mode 100644 index 00000000..7c336079 --- /dev/null +++ b/src/ciphertext/ciphertext_v3.rs @@ -0,0 +1,97 @@ +//! Ciphertext V3: X-Wing (X25519 + ML-KEM-768) + HKDF-SHA-256 + XChaCha20Poly1305 +//! +//! Wire format of the payload (after the header): +//! `X-Wing ciphertext (1120 bytes) || nonce (24 bytes) || XChaCha20Poly1305 ciphertext and tag`. +use super::{PrivateKey, PublicKey}; + +use super::Error; +use super::Header; +use super::Result; + +use super::Ciphertext; +use super::ciphertext_v2::CiphertextV2Symmetric; + +use crate::key::key_v2::{KEM_CIPHERTEXT_SIZE, KemCiphertext}; + +use std::convert::TryFrom; + +#[cfg(feature = "fuzz")] +use arbitrary::Arbitrary; + +/// HKDF-SHA-256 `info` label binding the X-Wing shared secret to asymmetric ciphertext V3. +/// Part of the wire format: changing it breaks decryption of existing data. +const HKDF_INFO: &[u8] = b"devolutions_crypto ciphertext_v3"; + +#[derive(Clone, Debug)] +pub struct CiphertextV3Asymmetric { + kem_ciphertext: Box, + ciphertext: CiphertextV2Symmetric, +} + +#[cfg(feature = "fuzz")] +impl<'a> Arbitrary<'a> for CiphertextV3Asymmetric { + fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result { + let kem_ciphertext: [u8; KEM_CIPHERTEXT_SIZE] = Arbitrary::arbitrary(u)?; + let ciphertext = CiphertextV2Symmetric::arbitrary(u)?; + Ok(Self { + kem_ciphertext: Box::new(KemCiphertext::from(kem_ciphertext)), + ciphertext, + }) + } +} + +impl From for Vec { + fn from(cipher: CiphertextV3Asymmetric) -> Self { + let mut data = cipher.kem_ciphertext.to_vec(); + let mut ciphertext: Vec = cipher.ciphertext.into(); + data.append(&mut ciphertext); + data + } +} + +impl TryFrom<&[u8]> for CiphertextV3Asymmetric { + type Error = Error; + fn try_from(data: &[u8]) -> Result { + if data.len() <= KEM_CIPHERTEXT_SIZE { + return Err(Error::InvalidLength); + }; + + let kem_ciphertext = KemCiphertext::try_from(&data[..KEM_CIPHERTEXT_SIZE]) + .map_err(|_| Error::InvalidLength)?; + let ciphertext = CiphertextV2Symmetric::try_from(&data[KEM_CIPHERTEXT_SIZE..])?; + + Ok(Self { + kem_ciphertext: Box::new(kem_ciphertext), + ciphertext, + }) + } +} + +impl CiphertextV3Asymmetric { + pub fn encrypt( + data: &[u8], + public_key: &PublicKey, + aad: &[u8], + header: &Header, + ) -> Result { + let (kem_ciphertext, key) = public_key.x_wing_encapsulate(HKDF_INFO)?; + + let ciphertext = CiphertextV2Symmetric::encrypt_with_derived_key(data, &key, aad, header)?; + + Ok(Self { + kem_ciphertext, + ciphertext, + }) + } + + pub fn decrypt( + &self, + private_key: &PrivateKey, + aad: &[u8], + header: &Header, + ) -> Result> { + let key = private_key.x_wing_decapsulate(&self.kem_ciphertext, HKDF_INFO)?; + + self.ciphertext.decrypt_with_derived_key(&key, aad, header) + } +} diff --git a/src/ciphertext/mod.rs b/src/ciphertext/mod.rs index 483bfc8d..39b0e6c7 100644 --- a/src/ciphertext/mod.rs +++ b/src/ciphertext/mod.rs @@ -42,6 +42,7 @@ mod ciphertext_v1; mod ciphertext_v2; +mod ciphertext_v3; use super::CiphertextSubtype; pub use super::CiphertextVersion; @@ -55,6 +56,7 @@ use super::key::{PrivateKey, PublicKey, SecretKey}; use ciphertext_v1::CiphertextV1; use ciphertext_v2::{CiphertextV2Asymmetric, CiphertextV2Symmetric}; +use ciphertext_v3::CiphertextV3Asymmetric; use std::borrow::Borrow; use std::convert::TryFrom; @@ -85,6 +87,7 @@ enum CiphertextPayload { V1(CiphertextV1), V2Symmetric(CiphertextV2Symmetric), V2Asymmetric(CiphertextV2Asymmetric), + V3Asymmetric(CiphertextV3Asymmetric), } /// Returns a `Ciphertext` from cleartext data and a key. @@ -167,7 +170,9 @@ pub fn encrypt_with_aad( CiphertextVersion::V2 | CiphertextVersion::Latest => { header.version = CiphertextVersion::V2; CiphertextPayload::V2Symmetric(CiphertextV2Symmetric::encrypt(data, key, aad, &header)?) - } //_ => return Err(DevoCryptoError::UnknownVersion), + } + // V3 is asymmetric only. + CiphertextVersion::V3 => return Err(Error::UnknownVersion), }; Ok(Ciphertext { header, payload }) @@ -179,6 +184,8 @@ pub fn encrypt_with_aad( /// * `data` - The data to encrypt. /// * `public_key` - The `PublicKey` to use. Use `generate_keypair` to generate a keypair. /// * `version` - Version of the library to encrypt with. Use `CiphertextVersion::Latest` if you're not dealing with shared data. +/// `CiphertextVersion::V2` (and `Latest`) requires a `KeyVersion::V1` key; `CiphertextVersion::V3` (post-quantum) requires a `KeyVersion::V2` key. +/// A mismatched key returns `Error::InvalidDataType`. /// # Returns /// Returns a `Ciphertext` containing the encrypted data. /// # Example @@ -206,6 +213,8 @@ pub fn encrypt_asymmetric( /// * `public_key` - The `PublicKey` to use. Use `generate_keypair` to generate a keypair. /// * `aad` - Additionnal data to authenticate alongside the ciphertext. /// * `version` - Version of the library to encrypt with. Use `CiphertextVersion::Latest` if you're not dealing with shared data. +/// `CiphertextVersion::V2` (and `Latest`) requires a `KeyVersion::V1` key; `CiphertextVersion::V3` (post-quantum) requires a `KeyVersion::V2` key. +/// A mismatched key returns `Error::InvalidDataType`. /// # Returns /// Returns a `Ciphertext` containing the encrypted data. /// # Example @@ -236,6 +245,12 @@ pub fn encrypt_asymmetric_with_aad( data, public_key, aad, &header, )?) } + CiphertextVersion::V3 => { + header.version = CiphertextVersion::V3; + CiphertextPayload::V3Asymmetric(CiphertextV3Asymmetric::encrypt( + data, public_key, aad, &header, + )?) + } _ => return Err(Error::UnknownVersion), }; @@ -414,6 +429,7 @@ impl Ciphertext { ) -> Result> { match &self.payload { CiphertextPayload::V2Asymmetric(x) => x.decrypt(private_key, aad, &self.header), + CiphertextPayload::V3Asymmetric(x) => x.decrypt(private_key, aad, &self.header), CiphertextPayload::V1(_) => Err(Error::UnknownVersion), _ => Err(Error::InvalidDataType), } @@ -501,6 +517,12 @@ impl TryFrom<&[u8]> for Ciphertext { CiphertextV2Asymmetric::try_from(&data[Header::len()..])?, ), }, + CiphertextVersion::V3 => match header.data_subtype { + CiphertextSubtype::Asymmetric => CiphertextPayload::V3Asymmetric( + CiphertextV3Asymmetric::try_from(&data[Header::len()..])?, + ), + _ => return Err(Error::InvalidDataType), + }, _ => return Err(Error::UnknownVersion), }; @@ -514,6 +536,7 @@ impl From for Vec { CiphertextPayload::V1(x) => x.into(), CiphertextPayload::V2Symmetric(x) => x.into(), CiphertextPayload::V2Asymmetric(x) => x.into(), + CiphertextPayload::V3Asymmetric(x) => x.into(), } } } @@ -844,3 +867,190 @@ fn encrypt_decrypt_with_secret_key_v2() { assert_eq!(decrypted, data); } + +#[test] +fn asymmetric_v3_test() { + use super::key::{KeyVersion, generate_keypair}; + + let test_plaintext = b"this is a test data"; + + let keypair = generate_keypair(KeyVersion::V2); + + let encrypted_data = + encrypt_asymmetric(test_plaintext, &keypair.public_key, CiphertextVersion::V3).unwrap(); + assert_eq!(encrypted_data.header.version, CiphertextVersion::V3); + assert_eq!( + encrypted_data.header.data_subtype, + CiphertextSubtype::Asymmetric + ); + + let encrypted_data_vec: Vec = encrypted_data.into(); + + // Header + X-Wing ciphertext + nonce + data + Poly1305 tag + assert_eq!( + encrypted_data_vec.len(), + 8 + 1120 + 24 + test_plaintext.len() + 16 + ); + + let encrypted_data = Ciphertext::try_from(encrypted_data_vec.as_slice()).unwrap(); + + let decrypted_data = encrypted_data + .decrypt_asymmetric(&keypair.private_key) + .unwrap(); + + assert_eq!(decrypted_data, test_plaintext); + + // A deserialized private key decrypts too. + let private_bytes: Vec = keypair.private_key.into(); + let private_key = PrivateKey::try_from(private_bytes.as_slice()).unwrap(); + assert_eq!( + encrypted_data.decrypt_asymmetric(&private_key).unwrap(), + test_plaintext + ); +} + +#[test] +fn asymmetric_v3_aad_test() { + use super::key::{KeyVersion, generate_keypair}; + + let test_plaintext = b"this is a test data"; + let aad = b"This is some public data that we want to authenticate"; + let wrong_aad = b"this is some public data that we want to authenticate"; + + let keypair = generate_keypair(KeyVersion::V2); + + let encrypted_data = encrypt_asymmetric_with_aad( + test_plaintext, + &keypair.public_key, + aad, + CiphertextVersion::V3, + ) + .unwrap(); + + let encrypted_data_vec: Vec = encrypted_data.into(); + let encrypted_data = Ciphertext::try_from(encrypted_data_vec.as_slice()).unwrap(); + + let decrypted_data = encrypted_data + .decrypt_asymmetric_with_aad(&keypair.private_key, aad) + .unwrap(); + assert_eq!(decrypted_data, test_plaintext); + + assert!( + encrypted_data + .decrypt_asymmetric_with_aad(&keypair.private_key, wrong_aad) + .is_err() + ); + assert!( + encrypted_data + .decrypt_asymmetric(&keypair.private_key) + .is_err() + ); +} + +#[test] +fn asymmetric_v3_wrong_key_or_tampering_fails() { + use super::key::{KeyVersion, generate_keypair}; + + let keypair = generate_keypair(KeyVersion::V2); + let other = generate_keypair(KeyVersion::V2); + + let encrypted: Vec = + encrypt_asymmetric(b"data", &keypair.public_key, CiphertextVersion::V3) + .unwrap() + .into(); + + // ML-KEM implicit rejection: a wrong key yields a different AEAD key, so authentication fails. + let ciphertext = Ciphertext::try_from(encrypted.as_slice()).unwrap(); + assert!(matches!( + ciphertext.decrypt_asymmetric(&other.private_key), + Err(Error::InvalidMac) + )); + + // Flip a bit in the ML-KEM ciphertext, the nonce and the AEAD ciphertext. + for offset in [8, 8 + 1120, encrypted.len() - 1] { + let mut tampered = encrypted.clone(); + tampered[offset] ^= 1; + let tampered = Ciphertext::try_from(tampered.as_slice()).unwrap(); + assert!(tampered.decrypt_asymmetric(&keypair.private_key).is_err()); + } +} + +#[test] +fn asymmetric_v3_key_version_mismatch() { + use super::key::{KeyVersion, generate_keypair}; + + let v1 = generate_keypair(KeyVersion::V1); + let v2 = generate_keypair(KeyVersion::V2); + + // Encrypting with the wrong key version. + assert!(matches!( + encrypt_asymmetric(b"data", &v1.public_key, CiphertextVersion::V3), + Err(Error::InvalidDataType) + )); + assert!(matches!( + encrypt_asymmetric(b"data", &v2.public_key, CiphertextVersion::V2), + Err(Error::InvalidDataType) + )); + // `Latest` stays on V2, which requires a V1 key. + assert!(matches!( + encrypt_asymmetric(b"data", &v2.public_key, CiphertextVersion::Latest), + Err(Error::InvalidDataType) + )); + + // Decrypting with the wrong key version. + let v2_ciphertext = encrypt_asymmetric(b"data", &v1.public_key, CiphertextVersion::V2).unwrap(); + let v3_ciphertext = encrypt_asymmetric(b"data", &v2.public_key, CiphertextVersion::V3).unwrap(); + assert!(matches!( + v2_ciphertext.decrypt_asymmetric(&v2.private_key), + Err(Error::InvalidDataType) + )); + assert!(matches!( + v3_ciphertext.decrypt_asymmetric(&v1.private_key), + Err(Error::InvalidDataType) + )); +} + +#[test] +fn symmetric_v3_rejected() { + assert!(matches!( + encrypt(b"data", b"key", CiphertextVersion::V3), + Err(Error::UnknownVersion) + )); +} + +#[test] +fn v3_non_asymmetric_subtype_rejected() { + use super::key::{KeyVersion, generate_keypair}; + + let keypair = generate_keypair(KeyVersion::V2); + let mut encrypted: Vec = + encrypt_asymmetric(b"data", &keypair.public_key, CiphertextVersion::V3) + .unwrap() + .into(); + + // Rewrite the subtype field (bytes 4..6) to Symmetric. + encrypted[4..6].copy_from_slice(&u16::from(CiphertextSubtype::Symmetric).to_le_bytes()); + + assert!(matches!( + Ciphertext::try_from(encrypted.as_slice()), + Err(Error::InvalidDataType) + )); +} + +#[test] +fn v3_truncated_rejected() { + use super::key::{KeyVersion, generate_keypair}; + + let keypair = generate_keypair(KeyVersion::V2); + let encrypted: Vec = + encrypt_asymmetric(b"data", &keypair.public_key, CiphertextVersion::V3) + .unwrap() + .into(); + + for len in [8, 8 + 1120, 8 + 1120 + 24] { + assert!(matches!( + Ciphertext::try_from(&encrypted[..len]), + Err(Error::InvalidLength) + )); + } +} diff --git a/src/enums.rs b/src/enums.rs index 4d457842..f0e202ba 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -35,6 +35,8 @@ pub enum DataType { KeyDerivation = 8, /// A wrapped payload combining key derivation parameters and ciphertext. KdfEncryptedData = 9, + /// A wrapped KEM ciphertext produced by `key_encapsulation::encapsulate`. + EncapsulatedKey = 10, } /// The versions of the encryption scheme to use. @@ -49,8 +51,11 @@ pub enum CiphertextVersion { Latest = 0, /// Uses version 1: AES256-CBC-HMAC-SHA2-256. V1 = 1, - /// Uses version 2: XChaCha20-Poly1305. + /// Uses version 2: XChaCha20-Poly1305. Asymmetric encryption uses an X25519 (`KeyVersion::V1`) key. V2 = 2, + /// Uses version 3: X-Wing (X25519 + ML-KEM-768) hybrid post-quantum KEM, HKDF-SHA-256 and XChaCha20-Poly1305. + /// Asymmetric only; requires a `KeyVersion::V2` key. + V3 = 3, } /// The versions of the online encryption scheme to use. @@ -95,6 +100,9 @@ pub enum KeyVersion { Latest = 0, /// Uses version 1: Curve25519 keys and x25519 key exchange. V1 = 1, + /// Uses version 2: X-Wing (X25519 + ML-KEM-768) hybrid post-quantum KEM keys. + /// Usable with `CiphertextVersion::V3` and the `key_encapsulation` module, not with `mix_key_exchange`. + V2 = 2, } #[cfg_attr(feature = "wbindgen", wasm_bindgen())] @@ -236,3 +244,26 @@ pub enum KdfEncryptedDataSubtype { #[default] None = 0, } + +/// The versions of the key encapsulation scheme to use. +#[cfg_attr(feature = "wbindgen", wasm_bindgen())] +#[cfg_attr(feature = "fuzz", derive(Arbitrary))] +#[derive(Clone, Copy, PartialEq, Eq, Zeroize, IntoPrimitive, TryFromPrimitive, Debug)] +#[repr(u16)] +#[derive(Default)] +pub enum KeyEncapsulationVersion { + /// Uses the latest version. + #[default] + Latest = 0, + /// Uses version 1: X-Wing (X25519 + ML-KEM-768) with HKDF-SHA-256. Requires a `KeyVersion::V2` key. + V1 = 1, +} + +#[derive(Clone, Copy, PartialEq, Eq, Zeroize, IntoPrimitive, TryFromPrimitive, Debug)] +#[cfg_attr(feature = "fuzz", derive(Arbitrary))] +#[repr(u16)] +#[derive(Default)] +pub enum EncapsulatedKeySubtype { + #[default] + None = 0, +} diff --git a/src/key/key_v2.rs b/src/key/key_v2.rs new file mode 100644 index 00000000..a5aebbc2 --- /dev/null +++ b/src/key/key_v2.rs @@ -0,0 +1,191 @@ +//! Key V2: X-Wing hybrid KEM (X25519 + ML-KEM-768) +//! +//! The raw X-Wing shared secret never leaves this module: every encapsulation or +//! decapsulation goes through HKDF-SHA-256 with a caller-supplied, use-specific label. +use super::Error; +use super::Result; + +use hkdf::Hkdf; +use rand::rand_core::UnwrapErr; +use rand::rngs::SysRng; +use sha2::Sha256; +use x_wing::{ + DecapsulationKeyRejectNonContrib, Decapsulator, Encapsulate, EncapsulationKey, KeyExport, + KeyInit, TryDecapsulate, +}; +use zeroize::Zeroizing; + +use std::convert::TryFrom; + +#[cfg(feature = "fuzz")] +use arbitrary::Arbitrary; + +/// Serialized size of an X-Wing private (decapsulation) key: a 32-byte seed. +pub const PRIVATE_KEY_SIZE: usize = x_wing::DECAPSULATION_KEY_SIZE; +/// Serialized size of an X-Wing public (encapsulation) key: ML-KEM-768 (1184) || X25519 (32). +pub const PUBLIC_KEY_SIZE: usize = x_wing::ENCAPSULATION_KEY_SIZE; +/// Size of an X-Wing KEM ciphertext: ML-KEM-768 (1088) || X25519 (32). +pub const KEM_CIPHERTEXT_SIZE: usize = x_wing::CIPHERTEXT_SIZE; + +/// Raw X-Wing KEM ciphertext. +pub type KemCiphertext = x_wing::Ciphertext; + +#[derive(Clone)] +pub struct KeyV2Pair { + pub private_key: KeyV2Private, + pub public_key: KeyV2Public, +} + +/// Rejects ciphertexts whose X25519 component is non-contributory (low-order point), +/// matching the behaviour of the X25519-based V1 keys. +#[derive(Clone)] +pub struct KeyV2Private { + key: Box, +} + +impl core::fmt::Debug for KeyV2Private { + fn fmt(&self, f: &mut core::fmt::Formatter) -> std::result::Result<(), core::fmt::Error> { + write!(f, "Private Key") + } +} + +#[derive(Clone, Debug)] +pub struct KeyV2Public { + key: Box, +} + +impl KeyV2Private { + fn from_seed(seed: &[u8]) -> Result { + let key = DecapsulationKeyRejectNonContrib::new_from_slice(seed) + .map_err(|_| Error::InvalidLength)?; + Ok(Self { key: Box::new(key) }) + } + + fn public_key(&self) -> KeyV2Public { + KeyV2Public { + key: Box::new(self.key.encapsulation_key().clone()), + } + } + + /// Decapsulates `ciphertext` and derives a 32-byte key from the shared secret with + /// HKDF-SHA-256 (no salt) using `info` as the label. + pub fn decapsulate( + &self, + ciphertext: &KemCiphertext, + info: &[u8], + ) -> Result> { + let shared_secret = Zeroizing::new( + self.key + .try_decapsulate(ciphertext) + .map_err(|_| Error::InvalidData)?, + ); + + Ok(derive_key(shared_secret.as_slice(), info)) + } +} + +impl KeyV2Public { + /// Encapsulates a fresh shared secret to this key and derives a 32-byte key from it with + /// HKDF-SHA-256 (no salt) using `info` as the label. + /// + /// # Panics + /// Panics if the OS random number generator is unavailable, like `generate_keypair`. + pub fn encapsulate(&self, info: &[u8]) -> (Box, Zeroizing<[u8; 32]>) { + let (ciphertext, shared_secret) = self.key.encapsulate_with_rng(&mut UnwrapErr(SysRng)); + let shared_secret = Zeroizing::new(shared_secret); + + ( + Box::new(ciphertext), + derive_key(shared_secret.as_slice(), info), + ) + } +} + +fn derive_key(shared_secret: &[u8], info: &[u8]) -> Zeroizing<[u8; 32]> { + let mut okm = Zeroizing::new([0u8; 32]); + Hkdf::::new(None, shared_secret) + .expand(info, okm.as_mut_slice()) + .expect("32 bytes is a valid HKDF-SHA-256 output length"); + okm +} + +#[cfg(feature = "fuzz")] +impl<'a> Arbitrary<'a> for KeyV2Private { + fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result { + let seed: [u8; PRIVATE_KEY_SIZE] = Arbitrary::arbitrary(u)?; + Ok(Self::from_seed(&seed).expect("seed length is hardcoded and correct")) + } +} + +#[cfg(feature = "fuzz")] +impl<'a> Arbitrary<'a> for KeyV2Public { + fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result { + Ok(KeyV2Private::arbitrary(u)?.public_key()) + } +} + +impl From for Vec { + fn from(key: KeyV2Private) -> Self { + Zeroizing::new(key.key.to_bytes()).to_vec() + } +} + +impl From for Vec { + fn from(key: KeyV2Public) -> Self { + key.key.to_bytes().to_vec() + } +} + +impl TryFrom<&[u8]> for KeyV2Private { + type Error = Error; + + fn try_from(key: &[u8]) -> Result { + if key.len() != PRIVATE_KEY_SIZE { + return Err(Error::InvalidLength); + } + + Self::from_seed(key) + } +} + +impl TryFrom<&[u8]> for KeyV2Public { + type Error = Error; + + fn try_from(key: &[u8]) -> Result { + if key.len() != PUBLIC_KEY_SIZE { + return Err(Error::InvalidLength); + } + + // Rejects ML-KEM encapsulation keys that fail the FIPS 203 modulus check. + let encapsulation_key = EncapsulationKey::try_from(key).map_err(|_| Error::InvalidData)?; + + // Like V1 keys, reject a low-order X25519 component (the last 32 bytes), which would make + // the X25519 half of the hybrid contribute nothing. With a clamped scalar (a multiple of 8), + // the X25519 output is all-zero exactly when the point is of low order. + let mut x25519_public = [0u8; 32]; + x25519_public.copy_from_slice(&key[PUBLIC_KEY_SIZE - 32..]); + if !x25519_dalek::StaticSecret::from([1u8; 32]) + .diffie_hellman(&x25519_dalek::PublicKey::from(x25519_public)) + .was_contributory() + { + return Err(Error::InvalidData); + } + + Ok(Self { + key: Box::new(encapsulation_key), + }) + } +} + +pub fn generate_keypair() -> KeyV2Pair { + let seed = crate::utils::random_bytes::() + .expect("the OS entropy source should be available"); + let private_key = + KeyV2Private::from_seed(seed.as_slice()).expect("seed length is hardcoded and correct"); + let public_key = private_key.public_key(); + + KeyV2Pair { + private_key, + public_key, + } +} diff --git a/src/key/mod.rs b/src/key/mod.rs index 2e0a4c45..5ec7e6ce 100644 --- a/src/key/mod.rs +++ b/src/key/mod.rs @@ -39,6 +39,7 @@ //! ``` mod key_v1; +pub(crate) mod key_v2; mod secret_key_v1; use super::DataType; @@ -50,6 +51,7 @@ pub use super::KeyVersion; use super::Result; use key_v1::{KeyV1Private, KeyV1Public}; +use key_v2::{KemCiphertext, KeyV2Private, KeyV2Public}; use secret_key_v1::SecretKeyV1; use std::borrow::Borrow; @@ -120,17 +122,21 @@ impl HeaderType for PrivateKey { #[cfg_attr(feature = "fuzz", derive(Arbitrary))] enum PrivateKeyPayload { V1(KeyV1Private), + V2(KeyV2Private), } #[derive(Clone, Debug)] #[cfg_attr(feature = "fuzz", derive(Arbitrary))] enum PublicKeyPayload { V1(KeyV1Public), + V2(KeyV2Public), } /// Generates a `KeyPair` to use in a key exchange or to encrypt data. /// # Arguments /// * `version` - Version of the key scheme to use. Use `KeyVersion::Latest` if you're not dealing with shared data. +/// `KeyVersion::V2` generates a post-quantum X-Wing (X25519 + ML-KEM-768) keypair, usable with +/// `CiphertextVersion::V3` and the `key_encapsulation` module but not with `mix_key_exchange`. /// # Returns /// Returns a `KeyPair` containing the private key and the public key. /// # Example @@ -150,6 +156,13 @@ pub fn generate_keypair(version: KeyVersion) -> KeyPair { PublicKeyPayload::V1(keypair.public_key), ) } + KeyVersion::V2 => { + let keypair = key_v2::generate_keypair(); + ( + PrivateKeyPayload::V2(keypair.private_key), + PublicKeyPayload::V2(keypair.public_key), + ) + } }; KeyPair { @@ -171,6 +184,8 @@ pub fn generate_keypair(version: KeyVersion) -> KeyPair { /// # Returns /// Returns a shared secret in the form of a `Vec`, which can then be used /// as an encryption key between the two parties. +/// Returns `Error::InvalidDataType` if either key is not a `KeyVersion::V1` key: X-Wing (`KeyVersion::V2`) +/// keys are a KEM and cannot perform a non-interactive key exchange. Use the `key_encapsulation` module instead. /// # Example /// ```rust /// use std::convert::TryFrom as _; @@ -213,7 +228,8 @@ pub fn mix_key_exchange(private_key: &PrivateKey, public_key: &PublicKey) -> Res match (&private_key.payload, &public_key.payload) { (PrivateKeyPayload::V1(private_key), PublicKeyPayload::V1(public_key)) => { key_v1::mix_key_exchange(private_key, public_key) - } //_ => Err(DevoCryptoError::InvalidDataType), + } + _ => Err(Error::InvalidDataType), } } @@ -226,6 +242,10 @@ fn keypair_headers(version: KeyVersion) -> (Header, Header { + private_header.version = KeyVersion::V2; + public_header.version = KeyVersion::V2; + } } (private_header, public_header) @@ -258,6 +278,7 @@ impl TryFrom<&[u8]> for PublicKey { let payload = match header.version { KeyVersion::V1 => PublicKeyPayload::V1(KeyV1Public::try_from(&data[Header::len()..])?), + KeyVersion::V2 => PublicKeyPayload::V2(KeyV2Public::try_from(&data[Header::len()..])?), _ => return Err(Error::UnknownVersion), }; @@ -294,6 +315,9 @@ impl TryFrom<&[u8]> for PrivateKey { KeyVersion::V1 => { PrivateKeyPayload::V1(KeyV1Private::try_from(&data[Header::len()..])?) } + KeyVersion::V2 => { + PrivateKeyPayload::V2(KeyV2Private::try_from(&data[Header::len()..])?) + } _ => return Err(Error::UnknownVersion), }; @@ -305,6 +329,7 @@ impl From for Vec { fn from(data: PrivateKeyPayload) -> Self { match data { PrivateKeyPayload::V1(x) => x.into(), + PrivateKeyPayload::V2(x) => x.into(), } } } @@ -313,24 +338,52 @@ impl From for Vec { fn from(data: PublicKeyPayload) -> Self { match data { PublicKeyPayload::V1(x) => x.into(), + PublicKeyPayload::V2(x) => x.into(), } } } -impl From<&PublicKey> for x25519_dalek::PublicKey { - fn from(data: &PublicKey) -> Self { - match &data.payload { - PublicKeyPayload::V1(x) => Self::from(x), - //_ => Err(DevoCryptoError::InvalidDataType), +impl PublicKey { + /// Returns the X25519 key of a `KeyVersion::V1` key, or `Error::InvalidDataType` for other versions. + pub(crate) fn as_x25519(&self) -> Result { + match &self.payload { + PublicKeyPayload::V1(x) => Ok(x25519_dalek::PublicKey::from(x)), + _ => Err(Error::InvalidDataType), + } + } + + /// Encapsulates to a `KeyVersion::V2` (X-Wing) key and returns the KEM ciphertext along with + /// HKDF-SHA-256(shared secret, `info`). Returns `Error::InvalidDataType` for other versions. + pub(crate) fn x_wing_encapsulate( + &self, + info: &[u8], + ) -> Result<(Box, Zeroizing<[u8; 32]>)> { + match &self.payload { + PublicKeyPayload::V2(x) => Ok(x.encapsulate(info)), + _ => Err(Error::InvalidDataType), } } } -impl From<&PrivateKey> for x25519_dalek::StaticSecret { - fn from(data: &PrivateKey) -> Self { - match &data.payload { - PrivateKeyPayload::V1(x) => Self::from(x), - //_ => Err(DevoCryptoError::InvalidDataType), +impl PrivateKey { + /// Returns the X25519 secret of a `KeyVersion::V1` key, or `Error::InvalidDataType` for other versions. + pub(crate) fn as_x25519(&self) -> Result { + match &self.payload { + PrivateKeyPayload::V1(x) => Ok(x25519_dalek::StaticSecret::from(x)), + _ => Err(Error::InvalidDataType), + } + } + + /// Decapsulates with a `KeyVersion::V2` (X-Wing) key and returns HKDF-SHA-256(shared secret, `info`). + /// Returns `Error::InvalidDataType` for other versions. + pub(crate) fn x_wing_decapsulate( + &self, + ciphertext: &KemCiphertext, + info: &[u8], + ) -> Result> { + match &self.payload { + PrivateKeyPayload::V2(x) => x.decapsulate(ciphertext, info), + _ => Err(Error::InvalidDataType), } } } @@ -384,6 +437,7 @@ pub(crate) fn secret_key_from_raw(bytes: Zeroizing>) -> Result SecretKey { let mut header: Header = Header::default(); match version { - KeyVersion::V1 | KeyVersion::Latest => { + KeyVersion::V1 | KeyVersion::V2 | KeyVersion::Latest => { header.version = KeyVersion::V1; } } @@ -488,3 +542,94 @@ fn secret_key_wrong_length_rejected() { let result = SecretKey::try_from(short.as_slice()); assert!(matches!(result, Err(Error::InvalidLength))); } + +#[test] +fn keypair_v2_roundtrip() { + let keypair = generate_keypair(KeyVersion::V2); + assert_eq!(keypair.private_key.header.version, KeyVersion::V2); + assert_eq!(keypair.public_key.header.version, KeyVersion::V2); + + let private_bytes: Vec = keypair.private_key.into(); + let public_bytes: Vec = keypair.public_key.into(); + assert_eq!(private_bytes.len(), 8 + 32); + assert_eq!(public_bytes.len(), 8 + 1216); + + let private_key = PrivateKey::try_from(private_bytes.as_slice()).unwrap(); + let public_key = PublicKey::try_from(public_bytes.as_slice()).unwrap(); + assert_eq!(private_key.header.version, KeyVersion::V2); + assert_eq!(public_key.header.version, KeyVersion::V2); + + assert_eq!(Vec::::from(private_key), private_bytes); + assert_eq!(Vec::::from(public_key), public_bytes); +} + +#[test] +fn keypair_v2_private_key_debug_is_redacted() { + let keypair = generate_keypair(KeyVersion::V2); + let debug = format!("{:?}", keypair.private_key); + assert!(debug.contains("Private Key")); +} + +#[test] +fn keypair_v2_invalid_lengths_rejected() { + let keypair = generate_keypair(KeyVersion::V2); + let private_bytes: Vec = keypair.private_key.into(); + let public_bytes: Vec = keypair.public_key.into(); + + assert!(matches!( + PrivateKey::try_from(&private_bytes[..private_bytes.len() - 1]), + Err(Error::InvalidLength) + )); + assert!(matches!( + PublicKey::try_from(&public_bytes[..public_bytes.len() - 1]), + Err(Error::InvalidLength) + )); + + let mut longer = public_bytes.clone(); + longer.push(0); + assert!(matches!( + PublicKey::try_from(longer.as_slice()), + Err(Error::InvalidLength) + )); +} + +#[test] +fn keypair_v2_invalid_ml_kem_public_key_rejected() { + let keypair = generate_keypair(KeyVersion::V2); + let mut public_bytes: Vec = keypair.public_key.into(); + + // The first 12-bit ML-KEM coefficient becomes 0xFFF, which is not reduced modulo q = 3329. + public_bytes[8] = 0xFF; + public_bytes[9] |= 0x0F; + + assert!(matches!( + PublicKey::try_from(public_bytes.as_slice()), + Err(Error::InvalidData) + )); +} + +#[test] +fn mix_key_exchange_rejects_v2_keys() { + let v1 = generate_keypair(KeyVersion::V1); + let v2 = generate_keypair(KeyVersion::V2); + + assert!(matches!( + mix_key_exchange(&v2.private_key, &v2.public_key), + Err(Error::InvalidDataType) + )); + assert!(matches!( + mix_key_exchange(&v1.private_key, &v2.public_key), + Err(Error::InvalidDataType) + )); + assert!(matches!( + mix_key_exchange(&v2.private_key, &v1.public_key), + Err(Error::InvalidDataType) + )); +} + +#[test] +fn latest_keypair_is_still_v1() { + let keypair = generate_keypair(KeyVersion::Latest); + assert_eq!(keypair.private_key.header.version, KeyVersion::V1); + assert_eq!(keypair.public_key.header.version, KeyVersion::V1); +} diff --git a/src/key_encapsulation/key_encapsulation_v1.rs b/src/key_encapsulation/key_encapsulation_v1.rs new file mode 100644 index 00000000..f3453d3b --- /dev/null +++ b/src/key_encapsulation/key_encapsulation_v1.rs @@ -0,0 +1,73 @@ +//! Key encapsulation V1: X-Wing (X25519 + ML-KEM-768) + HKDF-SHA-256 +//! +//! Wire format of the payload (after the header): `X-Wing ciphertext (1120 bytes)`. +use super::Error; +use super::Result; + +use super::{PrivateKey, PublicKey, SecretKey}; + +use crate::key::key_v2::{KEM_CIPHERTEXT_SIZE, KemCiphertext}; +use crate::key::secret_key_from_raw; + +use std::convert::TryFrom; + +use zeroize::Zeroizing; + +#[cfg(feature = "fuzz")] +use arbitrary::Arbitrary; + +/// HKDF-SHA-256 `info` label binding the X-Wing shared secret to key encapsulation V1. +/// Distinct from the ciphertext V3 label so a V3 ciphertext's KEM bytes never decapsulate to its AEAD key. +/// Part of the wire format: changing it breaks decapsulation of existing data. +const HKDF_INFO: &[u8] = b"devolutions_crypto key_encapsulation_v1"; + +#[derive(Clone, Debug)] +pub struct EncapsulatedKeyV1 { + kem_ciphertext: Box, +} + +#[cfg(feature = "fuzz")] +impl<'a> Arbitrary<'a> for EncapsulatedKeyV1 { + fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result { + let kem_ciphertext: [u8; KEM_CIPHERTEXT_SIZE] = Arbitrary::arbitrary(u)?; + Ok(Self { + kem_ciphertext: Box::new(KemCiphertext::from(kem_ciphertext)), + }) + } +} + +impl EncapsulatedKeyV1 { + pub fn encapsulate(public_key: &PublicKey) -> Result<(Self, SecretKey)> { + let (kem_ciphertext, key) = public_key.x_wing_encapsulate(HKDF_INFO)?; + let secret_key = secret_key_from_raw(Zeroizing::new(key.to_vec()))?; + + Ok((Self { kem_ciphertext }, secret_key)) + } + + pub fn decapsulate(&self, private_key: &PrivateKey) -> Result { + let key = private_key.x_wing_decapsulate(&self.kem_ciphertext, HKDF_INFO)?; + secret_key_from_raw(Zeroizing::new(key.to_vec())) + } +} + +impl From for Vec { + fn from(data: EncapsulatedKeyV1) -> Self { + data.kem_ciphertext.to_vec() + } +} + +impl TryFrom<&[u8]> for EncapsulatedKeyV1 { + type Error = Error; + + fn try_from(data: &[u8]) -> Result { + if data.len() != KEM_CIPHERTEXT_SIZE { + return Err(Error::InvalidLength); + } + + let kem_ciphertext = KemCiphertext::try_from(data).map_err(|_| Error::InvalidLength)?; + + Ok(Self { + kem_ciphertext: Box::new(kem_ciphertext), + }) + } +} diff --git a/src/key_encapsulation/mod.rs b/src/key_encapsulation/mod.rs new file mode 100644 index 00000000..07c40dcc --- /dev/null +++ b/src/key_encapsulation/mod.rs @@ -0,0 +1,309 @@ +//! Module for post-quantum key encapsulation (KEM). +//! +//! A sender uses the recipient's `PublicKey` to produce a fresh random `SecretKey` along with an +//! `EncapsulatedKey`. Only the holder of the matching `PrivateKey` can recover the same `SecretKey` +//! from the `EncapsulatedKey`. The resulting `SecretKey` can then be used with the symmetric +//! `ciphertext` functions. +//! +//! This requires a `KeyVersion::V2` (X-Wing: X25519 + ML-KEM-768) keypair. Unlike `mix_key_exchange`, +//! this is a one-way scheme: the sender must transmit the `EncapsulatedKey` to the recipient. +//! +//! ```rust +//! use std::convert::TryFrom as _; +//! use devolutions_crypto::key::{generate_keypair, KeyVersion}; +//! use devolutions_crypto::key_encapsulation::{decapsulate, encapsulate, EncapsulatedKey, KeyEncapsulationVersion}; +//! +//! // The recipient generates a post-quantum keypair and publishes the public key. +//! let keypair = generate_keypair(KeyVersion::V2); +//! +//! // The sender encapsulates a fresh secret key to the recipient's public key. +//! let (encapsulated, sender_key) = encapsulate(&keypair.public_key, KeyEncapsulationVersion::Latest).expect("encapsulation shouldn't fail"); +//! let encapsulated_bytes: Vec = encapsulated.into(); +//! +//! // The recipient recovers the same secret key. +//! let encapsulated = EncapsulatedKey::try_from(encapsulated_bytes.as_slice()).expect("parsing shouldn't fail"); +//! let recipient_key = decapsulate(&keypair.private_key, &encapsulated).expect("decapsulation shouldn't fail"); +//! +//! assert_eq!(sender_key.as_bytes(), recipient_key.as_bytes()); +//! ``` + +mod key_encapsulation_v1; + +use super::DataType; +use super::EncapsulatedKeySubtype; +use super::Error; +use super::Header; +use super::HeaderType; +pub use super::KeyEncapsulationVersion; +use super::Result; + +use super::key::{PrivateKey, PublicKey, SecretKey}; + +use key_encapsulation_v1::EncapsulatedKeyV1; + +use std::borrow::Borrow; +use std::convert::TryFrom; + +#[cfg(feature = "fuzz")] +use arbitrary::Arbitrary; + +#[cfg(feature = "wbindgen")] +use wasm_bindgen::prelude::*; + +/// A versioned encapsulated key. Send it to the holder of the `PrivateKey` so they can recover the `SecretKey`. +#[cfg_attr(feature = "wbindgen", wasm_bindgen(inspectable))] +#[cfg_attr(feature = "fuzz", derive(Arbitrary))] +#[derive(Clone, Debug)] +pub struct EncapsulatedKey { + pub(crate) header: Header, + payload: EncapsulatedKeyPayload, +} + +impl HeaderType for EncapsulatedKey { + type Version = KeyEncapsulationVersion; + type Subtype = EncapsulatedKeySubtype; + + fn data_type() -> DataType { + DataType::EncapsulatedKey + } +} + +#[derive(Clone, Debug)] +#[cfg_attr(feature = "fuzz", derive(Arbitrary))] +enum EncapsulatedKeyPayload { + V1(EncapsulatedKeyV1), +} + +/// Generates a fresh random `SecretKey` and encapsulates it to a `PublicKey`. +/// # Arguments +/// * `public_key` - The recipient's `PublicKey`. Must be a `KeyVersion::V2` key. +/// * `version` - Version of the key encapsulation scheme to use. Use `KeyEncapsulationVersion::Latest` if you're not dealing with shared data. +/// # Returns +/// Returns the `EncapsulatedKey` to send to the recipient and the `SecretKey` it encapsulates. +/// Returns `Error::InvalidDataType` if the public key is not a `KeyVersion::V2` key. +/// # Panics +/// Panics if the OS random number generator is unavailable, like `generate_keypair`. +/// # Example +/// ```rust +/// use devolutions_crypto::key::{generate_keypair, KeyVersion}; +/// use devolutions_crypto::key_encapsulation::{encapsulate, KeyEncapsulationVersion}; +/// +/// let keypair = generate_keypair(KeyVersion::V2); +/// let (encapsulated, secret_key) = encapsulate(&keypair.public_key, KeyEncapsulationVersion::Latest).unwrap(); +/// ``` +pub fn encapsulate( + public_key: &PublicKey, + version: KeyEncapsulationVersion, +) -> Result<(EncapsulatedKey, SecretKey)> { + let mut header = Header::default(); + + let (payload, secret_key) = match version { + KeyEncapsulationVersion::V1 | KeyEncapsulationVersion::Latest => { + header.version = KeyEncapsulationVersion::V1; + let (payload, secret_key) = EncapsulatedKeyV1::encapsulate(public_key)?; + (EncapsulatedKeyPayload::V1(payload), secret_key) + } + }; + + Ok((EncapsulatedKey { header, payload }, secret_key)) +} + +/// Recovers the `SecretKey` encapsulated in an `EncapsulatedKey`. +/// # Arguments +/// * `private_key` - The recipient's `PrivateKey`. Must match the `PublicKey` used by `encapsulate`. +/// * `encapsulated_key` - The `EncapsulatedKey` produced by `encapsulate`. +/// # Returns +/// Returns the encapsulated `SecretKey`. +/// Returns `Error::InvalidDataType` if the private key is not a `KeyVersion::V2` key. +/// Note that, as with any KEM, a wrong private key does not produce an error: it produces a +/// different `SecretKey`, which will fail to authenticate when used for decryption. +/// # Example +/// ```rust +/// use devolutions_crypto::key::{generate_keypair, KeyVersion}; +/// use devolutions_crypto::key_encapsulation::{decapsulate, encapsulate, KeyEncapsulationVersion}; +/// +/// let keypair = generate_keypair(KeyVersion::V2); +/// let (encapsulated, sender_key) = encapsulate(&keypair.public_key, KeyEncapsulationVersion::Latest).unwrap(); +/// let recipient_key = decapsulate(&keypair.private_key, &encapsulated).unwrap(); +/// +/// assert_eq!(sender_key.as_bytes(), recipient_key.as_bytes()); +/// ``` +pub fn decapsulate( + private_key: &PrivateKey, + encapsulated_key: &EncapsulatedKey, +) -> Result { + match &encapsulated_key.payload { + EncapsulatedKeyPayload::V1(x) => x.decapsulate(private_key), + } +} + +impl From for Vec { + /// Serialize the structure into a `Vec`, for storage, transmission or use in another language. + fn from(data: EncapsulatedKey) -> Self { + let mut header: Self = data.header.borrow().into(); + let mut payload: Self = data.payload.into(); + header.append(&mut payload); + header + } +} + +impl TryFrom<&[u8]> for EncapsulatedKey { + type Error = Error; + + /// Parses the data. Can return an Error if the data is invalid or unrecognized. + fn try_from(data: &[u8]) -> Result { + if data.len() < Header::len() { + return Err(Error::InvalidLength); + }; + + let header = Header::try_from(&data[0..Header::len()])?; + + let payload = match header.version { + KeyEncapsulationVersion::V1 => { + EncapsulatedKeyPayload::V1(EncapsulatedKeyV1::try_from(&data[Header::len()..])?) + } + _ => return Err(Error::UnknownVersion), + }; + + Ok(Self { header, payload }) + } +} + +impl From for Vec { + fn from(data: EncapsulatedKeyPayload) -> Self { + match data { + EncapsulatedKeyPayload::V1(x) => x.into(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::key::{KeyVersion, generate_keypair}; + + #[test] + fn encapsulate_decapsulate_roundtrip() { + let keypair = generate_keypair(KeyVersion::V2); + + let (encapsulated, sender_key) = + encapsulate(&keypair.public_key, KeyEncapsulationVersion::Latest).unwrap(); + + let serialized: Vec = encapsulated.into(); + assert_eq!(serialized.len(), 1128); + + let encapsulated = EncapsulatedKey::try_from(serialized.as_slice()).unwrap(); + assert_eq!(encapsulated.header.version, KeyEncapsulationVersion::V1); + + let recipient_key = decapsulate(&keypair.private_key, &encapsulated).unwrap(); + assert_eq!(sender_key.as_bytes(), recipient_key.as_bytes()); + assert_eq!(sender_key.as_bytes().len(), 32); + } + + #[test] + fn encapsulate_is_randomized() { + let keypair = generate_keypair(KeyVersion::V2); + + let (encapsulated1, key1) = + encapsulate(&keypair.public_key, KeyEncapsulationVersion::V1).unwrap(); + let (encapsulated2, key2) = + encapsulate(&keypair.public_key, KeyEncapsulationVersion::V1).unwrap(); + + assert_ne!(key1.as_bytes(), key2.as_bytes()); + assert_ne!( + Vec::::from(encapsulated1), + Vec::::from(encapsulated2) + ); + } + + #[test] + fn decapsulate_wrong_key_yields_different_secret() { + let keypair = generate_keypair(KeyVersion::V2); + let other = generate_keypair(KeyVersion::V2); + + let (encapsulated, sender_key) = + encapsulate(&keypair.public_key, KeyEncapsulationVersion::Latest).unwrap(); + let wrong_key = decapsulate(&other.private_key, &encapsulated).unwrap(); + + assert_ne!(sender_key.as_bytes(), wrong_key.as_bytes()); + } + + #[test] + fn v1_keys_rejected() { + let keypair = generate_keypair(KeyVersion::V1); + let v2_keypair = generate_keypair(KeyVersion::V2); + + assert!(matches!( + encapsulate(&keypair.public_key, KeyEncapsulationVersion::Latest), + Err(Error::InvalidDataType) + )); + + let (encapsulated, _) = + encapsulate(&v2_keypair.public_key, KeyEncapsulationVersion::Latest).unwrap(); + assert!(matches!( + decapsulate(&keypair.private_key, &encapsulated), + Err(Error::InvalidDataType) + )); + } + + #[test] + fn invalid_lengths_rejected() { + let keypair = generate_keypair(KeyVersion::V2); + let (encapsulated, _) = + encapsulate(&keypair.public_key, KeyEncapsulationVersion::Latest).unwrap(); + let serialized: Vec = encapsulated.into(); + + for len in [0, Header::len(), serialized.len() - 1] { + assert!(matches!( + EncapsulatedKey::try_from(&serialized[..len]), + Err(Error::InvalidLength) + )); + } + + let mut longer = serialized.clone(); + longer.push(0); + assert!(matches!( + EncapsulatedKey::try_from(longer.as_slice()), + Err(Error::InvalidLength) + )); + } + + #[test] + fn other_data_type_rejected() { + let keypair = generate_keypair(KeyVersion::V2); + let public_bytes: Vec = keypair.public_key.into(); + + assert!(EncapsulatedKey::try_from(public_bytes.as_slice()).is_err()); + } + + /// The HKDF labels provide domain separation: feeding a V3 ciphertext's KEM bytes to + /// `decapsulate` must not reveal that ciphertext's AEAD key. + #[test] + fn ciphertext_v3_kem_does_not_decapsulate_to_its_aead_key() { + use crate::ciphertext::{CiphertextVersion, encrypt_asymmetric}; + use crate::key::key_v2::{KEM_CIPHERTEXT_SIZE, KemCiphertext}; + + let keypair = generate_keypair(KeyVersion::V2); + let ciphertext: Vec = + encrypt_asymmetric(b"data", &keypair.public_key, CiphertextVersion::V3) + .unwrap() + .into(); + let kem_bytes = &ciphertext[Header::len()..Header::len() + KEM_CIPHERTEXT_SIZE]; + + // Splice the KEM bytes into an `EncapsulatedKey`. + let (template, _) = encapsulate(&keypair.public_key, KeyEncapsulationVersion::V1).unwrap(); + let mut encapsulated: Vec = template.into(); + encapsulated[Header::len()..].copy_from_slice(kem_bytes); + let encapsulated = EncapsulatedKey::try_from(encapsulated.as_slice()).unwrap(); + + let encapsulation_key = decapsulate(&keypair.private_key, &encapsulated).unwrap(); + + let kem_ciphertext = KemCiphertext::try_from(kem_bytes).unwrap(); + let aead_key = keypair + .private_key + .x_wing_decapsulate(&kem_ciphertext, b"devolutions_crypto ciphertext_v3") + .unwrap(); + + assert_ne!(encapsulation_key.as_bytes(), aead_key.as_slice()); + } +} diff --git a/src/lib.rs b/src/lib.rs index ad30436a..6bf2a48a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -259,6 +259,9 @@ //! * Symmetric cryptography uses XChaCha20Poly1305 //! * Asymmetric cryptography uses Curve25519. //! * Asymmetric encryption uses ECIES. +//! * Post-quantum asymmetric encryption (opt-in, `KeyVersion::V2` + `CiphertextVersion::V3`) uses the +//! X-Wing hybrid KEM (X25519 + ML-KEM-768), HKDF-SHA-256 and XChaCha20Poly1305. +//! * Key encapsulation uses X-Wing with HKDF-SHA-256 //! * Key derivation uses Argon2 or PBKDF2 //! * Key exchange uses x25519, or ECDH over Curve25519 //! * Password Hashing uses PBKDF2-HMAC-SHA2-256 @@ -274,6 +277,7 @@ pub mod ciphertext; pub mod derive_encrypt; pub mod key; pub mod key_derivation; +pub mod key_encapsulation; pub mod online_ciphertext; pub mod password_hash; pub mod secret_sharing; @@ -281,13 +285,15 @@ pub mod signature; pub mod signing_key; pub mod utils; -use enums::{CiphertextSubtype, PasswordHashSubtype, ShareSubtype, SignatureSubtype}; +use enums::{ + CiphertextSubtype, EncapsulatedKeySubtype, PasswordHashSubtype, ShareSubtype, SignatureSubtype, +}; pub use header::{Header, HeaderType}; pub use enums::{ - CiphertextVersion, DataType, KdfEncryptedDataVersion, KeyDerivationVersion, KeySubtype, - KeyVersion, OnlineCiphertextVersion, PasswordHashVersion, SecretSharingVersion, - SignatureVersion, SigningKeyVersion, + CiphertextVersion, DataType, KdfEncryptedDataVersion, KeyDerivationVersion, + KeyEncapsulationVersion, KeySubtype, KeyVersion, OnlineCiphertextVersion, PasswordHashVersion, + SecretSharingVersion, SignatureVersion, SigningKeyVersion, }; pub use argon2::Variant as Argon2Variant; diff --git a/src/online_ciphertext/online_ciphertext_v1.rs b/src/online_ciphertext/online_ciphertext_v1.rs index 013ea347..1b26796d 100644 --- a/src/online_ciphertext/online_ciphertext_v1.rs +++ b/src/online_ciphertext/online_ciphertext_v1.rs @@ -326,8 +326,8 @@ impl OnlineCiphertextV1Encryptor { mut aad: Vec, chunk_size: u32, ) -> Result { - // Perform a ECDH exchange as per ECIES - let public_key = x25519_dalek::PublicKey::from(public_key); + // Perform a ECDH exchange as per ECIES. Only X25519 (`KeyVersion::V1`) keys are supported. + let public_key = public_key.as_x25519()?; let ephemeral_private_key = StaticSecret::from(*crate::utils::random_bytes::<32>()?); let ephemeral_public_key = x25519_dalek::PublicKey::from(&ephemeral_private_key); @@ -394,8 +394,8 @@ impl OnlineCiphertextV1Decryptor { mut aad: Vec, header: OnlineCiphertextV1HeaderAsymmetric, ) -> Result { - // Perform a ECDH exchange as per ECIES - let private_key = x25519_dalek::StaticSecret::from(private_key); + // Perform a ECDH exchange as per ECIES. Only X25519 (`KeyVersion::V1`) keys are supported. + let private_key = private_key.as_x25519()?; let key = private_key.diffie_hellman(&header.public_key); diff --git a/src/utils.rs b/src/utils.rs index 3990d0b2..cfaec285 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -154,6 +154,10 @@ pub fn validate_header(data: &[u8], data_type: DataType) -> bool { DataType::KdfEncryptedData => { Header::::try_from(&data[0..Header::len()]).is_ok() } + DataType::EncapsulatedKey => { + use super::key_encapsulation::EncapsulatedKey; + Header::::try_from(&data[0..Header::len()]).is_ok() + } } } diff --git a/tests/conformity.rs b/tests/conformity.rs index ebc9f9e2..40934507 100644 --- a/tests/conformity.rs +++ b/tests/conformity.rs @@ -1,8 +1,9 @@ use base64::{Engine as _, engine::general_purpose}; use devolutions_crypto::{ Argon2Parameters, KdfEncryptedData, - ciphertext::Ciphertext, - key::{PrivateKey, SecretKey}, + ciphertext::{Ciphertext, CiphertextVersion, encrypt_asymmetric}, + key::{PrivateKey, PublicKey, SecretKey}, + key_encapsulation::{EncapsulatedKey, decapsulate}, password_hash::PasswordHash, utils::{derive_key_argon2, derive_key_pbkdf2}, }; @@ -395,3 +396,74 @@ fn test_online_ciphertext_decrypt_symmetric_v1() { assert_eq!(result, expected_plaintext); } + +// Post-quantum (X-Wing) vectors. All share the same `KeyVersion::V2` keypair. +const X_WING_PRIVATE_KEY: &str = "DQwBAAEAAgA1nERi4Of6p1Sg60NSw98tDx4OuBZtVR+CpVe+n1VDLg=="; +const X_WING_PUBLIC_KEY: &str = "DQwBAAIAAgBJegPKg7n38TKlpy2f+rrR0Sw7NZQlGG6SxcqX5S+TtRKMib0VA1Q7QV9I2U2L2GuJq5ZPfDRFiSd48bFSkxe6SyMioVFTE13HJ3dR9o5qmSmCzA+MJnyDsyWhRRc5l0/mFWIM7F3uABS8pAIsVBh3ghOq8ZZLSgpuyBd810yJKI8sFXZDKDnFWgpZMM9q12P29wr4p72WVsc+Mq1JUWVpCLyl5EyadXW7VUzct0GTyrZuR1BRA5q5e6HukbDnI5TKrEZkIpFkY6fu9Mk1oxK45Ai/YpL46wIN2wAtU1wWlk+zYXedcYjlMs0WYmDqjIHLusbdyQvnNDKx+09QRVLFAb2Xd8vklkd5ena1O4cSoVAo4CZfYS+7PMTO1VDYuCK3R1d4o39PqqPda5nQbCuRCYKQRIny2K0yVKQkZWaHVAN3ASeRFgvwV3Hhg1ifiqL/8p5sAb+EyRQHMWeDQFQUdJlOOkTDAqLMbK4s+3a4BbHVY1RaeEVCJ3hECYZDYFnA0WV7kWOwoQY1kXJLdU+a5sjgCUI7HMZi0UVwyh64JI+fJIJgEwLTCWW1zK8hCJ60HB0EhmkQdFW1OxdTGQ5wAqTUAsLeuzA7MD+AljEHx8pQGHNoRTWKALBkkVA+eFp/q7ra1LtqoJKlKMQWk2wpGGKS9D4YLEpDsgTQE5bWe1KuwCppswcN9bclhgHKZTQ5I3bLp2NO6icn8wHGxquK+0PXkGim2bTiSYDCGKtlGaAc+4ZBfLN6009adixR8sRQusmGeJMWomd6l8KFEpnxNG0/E1KJZwhDm8wdV7c9SggK5cH5Kqx28bBpQYn7MWKI+7ZLsrgnXGFFwEtrlFXBYLyIAMXOIwSucW42w5yWtwjsoF/RhM2/JFiYB4MvaTSoNJUTCZExyDsDbG3IF1+R0XGba2kqEhuqFbnEpoOhOnYMxQnaaZ1jkLhVSyt7Jwtfgsm++5wK+HpiJQKhhmWAJhq5V09Jgn+pSyHPQsV1R6qTC7APCRCqAo6e2RTCMWzINXopLMBUkjG1xWyYQ4mWoHZF+DmFI3U74A/wtSfnMJxW5AicGCuPKFcXGs7s+YTAko86ypHbeLpVp2PH8HH5ELEcHBi+AnWfE7P4y1XdlpXFlmeCF0c3oc2QSWg9mJ39iq2EFwqkyp23Ug6EkG3rurIv6BbEO1TOamw25Gtryz3+sxpj2ICvqzeyJZnkxFF9trlM5J0mci+ndw4A9yXN+7Oows9W90hFcCYilhaEsLjuUVCdsS7UywPrdJt5zJP73DhgLGVf/A+JOcL3iWmtclTmwgkbZn2K6U4r5ZcCinusdJms2ATXi1fkuHqZUwr0wihqwcTvy8ZVbBvFjEX5Ky9toz/X9o1Z55UHGFFp5KuZOztdWW206KnW2sob6maag0FCNg9slBCz1CO/IbOgOM75JWSdJoo8t0GaGWF8d3fW8zGOsrHTFl4dRxK/I03QUaqAJqNOdM4FRD7W6BBlwrRuEbll0nMMwowQfDDF4T4NwpXD82oIWa6CYs2tsglNJEAuMlyXXICQP2WWI4Q6lOqS6dSKk9lkl7wD2DNIvsqd4QaW4ewdDmjqFEV8m7KjJi2BStGVYaAGkpXoAj19"; + +fn x_wing_private_key() -> PrivateKey { + PrivateKey::try_from( + general_purpose::STANDARD + .decode(X_WING_PRIVATE_KEY) + .unwrap() + .as_slice(), + ) + .unwrap() +} + +#[test] +fn test_x_wing_keypair_v2() { + let private_key = x_wing_private_key(); + let public_key = PublicKey::try_from( + general_purpose::STANDARD + .decode(X_WING_PUBLIC_KEY) + .unwrap() + .as_slice(), + ) + .unwrap(); + + let ciphertext = encrypt_asymmetric(b"testdata", &public_key, CiphertextVersion::V3).unwrap(); + + assert_eq!( + ciphertext.decrypt_asymmetric(&private_key).unwrap(), + b"testdata" + ); +} + +#[test] +fn test_asymmetric_decrypt_v3() { + let ciphertext = Ciphertext::try_from(general_purpose::STANDARD.decode("DQwCAAIAAwCm2YjCoBofihIBaK5pfKKOfY0Ijhhvv+X8fw9bOWk/6TdrsxZBU9bucUBY37Ot1IuMGwBUU+eV5MQz/rTG5NoBai2licmRGBL/EJePhCPg3rgDtOajBszyyOkVVEHt4C+uznm8pJCaiw+zcG3E2pwLBRakUuUhqzHxolzVxRdZ8BaQUhNZSExDB7us3BoEhyiGpkXqKb8JWWrexzWRU/1p/ie6oP+lPG/uHK9ifAZ7k/pOty0lnIaH7R6XQ8tffM6kvyA2Cp7QHqzmGWUct8lKxy4BI7wRzNjpmIbhyHRwgZU5svTiHcccvFIYEqcZoU9gY9zBps9bkJIVK4v9AIlPtY1Ay55kE3Ze5gtHxBEvo1UaXT1+EOmOolqCqIv9Q8sbJIgFhqfkpoX3zy395FfI3C5mgqzTtW+xBOdpzxtnE3u3+M9ModNg4j6cMu6F242TFFJABdKk1CAV+jZ4WeBqt8c3VCnJGvJWGUpM8E6x3UL/C5tsDR7Ifzf39zoYGjEuo6pL9RFfA/VV1iGDFBdRmeVmoGnX0RME5epUuSET/ooymu4Vqn5INi+RTy3nIJwcyeSHEBlMYC0xVWi/veTKkoYfI6qxSYI8oxJXM8uSuZnB7TeKLshMOmt3jFM6bSyarAPlgMkbGDH+zXWe478Po4UcveequOsNABECYNET7fq6J/3ovfZ+6lMBIi2Hl2IMA5Ii/WXAv4N9ihUppFgYdPXooAOaYFoan8ntZKT+88L+s6hzUce9AhTjl+Ts2nWmdowo3rc/sGbH7LqllvGEBVohanBT8Tjy+4Q+zeN6xVpQIW3qcbeI2EE91uRrkVJwTKarB2TEYuTyys2Q3PLUX7/WUj99XBqZfOUEB9Na1F6JgPIL1+PL4Rfe5vSZySsdQi6qk+0bpd9tcauiUQokhzKiBZeLIi9VRimFpU0Qng3NmbopOKPTIBekowLhsyZgrFCqEANdOc8Jhy/OrssharUIu0mNZXgoFfJHn6kmsXgJejQikBRwUq/eUXqoz+k/ddcidKkKlJ1Mr5i7nzMPgODX8/WK229/qsIXq/cGZM9844fgeEcSkQQN9CPwbkw2k/dgWf28xfKGzMAzWpSnUwkC7toubDtYWrMoQWkpTN/nhMbAtTDBn8xrdMu5gkpERf6EeN1QURgje6dx10KpniC6/Vy7Zgg7lmn6h9o3PVpWv4oSlr3YObnUM8UgDsiRommWcBdJ8axl4/yvfeEa882DRJUAWU6nZKyunm1TgSrtFevb/72kJidpNQwmfXmy3gDC/rSXcsmnFSMGdc7a40dNFsmDZVui5386GsQk/Uta6h1zsZmqoW7AuaSDqhNo44Gbr5b57zjSwP9okkPvUVi2avFVIZsZFBl7FlWLVwb0d6HEzWVX93ZRxFosUBo5aS+btaT4YF9z++4xjST83whVU3o4TOQTq9mgAV/91JRB5+fT6P2wkOuuUuPe6gemH50LV0cYbxAHECt4OggFaAjHodoPqW0VRkU74M3xzZbKusiQv0YHDWsvqRzUuJ46XVWl+ZW2jVulTgmaSg/s").unwrap().as_slice()).unwrap(); + + let result = ciphertext + .decrypt_asymmetric(&x_wing_private_key()) + .unwrap(); + + assert_eq!(result, b"testdata"); +} + +#[test] +fn test_asymmetric_decrypt_aad_v3() { + let ciphertext = Ciphertext::try_from(general_purpose::STANDARD.decode("DQwCAAIAAwCY+aboyvWcG4QDu2lQfA8io0hemIghqdHjzt2b/nUpa3p9hMmnquY2zaMaugi3Lq1DyQuiau7BgDbv2Ew0fvumqvNi6EtmOOTgTp4cyCFQxBxq1dw4k1o1kdGm4I7W1h+NMnJfQ5tPjE2xRDKgDlkoOqdWv9AJmY2jFBQ65x4tVOl+a2VyicbFUNz6wpjN1vytLaWe3KZhckYt/yx6bZOJ13U7weaGezcGJwbcPTKUALSGqe6apJbOzVYxByq33j5652Cc8uL8USEJJ0pgvAYsukvgAtqycnz9wOi7dtmmmBa/2Ubzyo5U/5O6LBqHjv1irvQ3YIqn4XRXaXJ4Odik70xlG0LsiTBymr0SgGMQq0yIzoxfDOCyM6O5rBDpqlFKs+KdyN/luU+FzvJuSfR+WechpzJKW7pT5iKB/UQefXb5Dr8IMmRH7ca90Miq30yk+2lYnhI7i/K9kDoh/rM6j1xfk/j4UMWWVLnbAK3ZRbioCYF4H47ya1yQqYdrj3ZpiNLcLy2Oj8suAeVDyJFqIwDHFlsCfrgSnr1OJ5kWRkb5y/BW0S/33MTJpewAc7RztE/Dv5UfpY15GjVlY5LRj8L3oXmfF8qOvKAnzoo4pxqZMnKXCZkbyAf8FRYEF2e/J29KrzWBBVTAQMhMNqaHMw55Q2U8Cv5/eioPw1YmHYDU9E7+xmTM0KLK1/fcRQCnmFY8nVs3FRL1DdkIowjSWV5OwiWrHKYcMth5Kc556XI6VN46zOKYCEdjRwNuz9o3bCTCTeHmVFYspcwey3dRkzlKKJpmg69kQlM+5V/Ts+PY4yQ0eY0yx/vcZAmdp3H3t6LddHiOSd811z9SlsGK0YvbDgx8iRjEPFnwRlm6aly1fBYXWlk3JxvzOJCYwTqo2Q3xi4wMRA6FVwDRmDlazMSZ/qu+l5QbkXqfXAK4dCZbxksr9IXUINDcakLK3laD+iTBw+PSRfb0nyKkOoq+KbBZHRI94cGEm1Pn0Q88gPME2X02lfiMNe3fPwGPMlf6D0Ovykl0DI+IvahRqBE5h4LhneHD9j6PKuJzSy83EKsS+uLQ7nuYGoNpZFu/8wNEdkylVpCvY4SRVmAcmzBcbvv8V1NZek6sYY8BtKY7cCeyIr9wq9MMw3ZypMufpE1n5wIaQ+Y9Smgc9Vb8eV5Gsjsaam6mhzwJ38HqKmFBWVf886MhE4OuuBUEI76EeSXf3TAG+MIFqoKwfqJsLmtznDNBGDbmqC9g1J6jTplMH0zPl95LNL0SMS6+ObnVPHxJ/QBExSMbIvW7yTQ03dX2WawBbdNTee41Ev6cGQsXWnMjnOfAlIfwj1z6ZB2umYeAsRtju+0idLmqARnLnVDp2oIn4nAckkT8qoOMOVw81xvL5UYoepJREOr/k3X3tfLtFeaY4VtBrpGvUIo37hWYiDHP/vqdR7yzrkx3O3b/UVsV3JLGTIjf/brGErTsNmly+IAkR4MzwbQfezCxgCcpalHS/3GWUTWf3ureWok3q7aVj8slI7zmfFMpgdLFvVekdKUCVsF/1BMhB6gwfWuV").unwrap().as_slice()).unwrap(); + let aad = b"this is some public data"; + + let result = ciphertext + .decrypt_asymmetric_with_aad(&x_wing_private_key(), aad) + .unwrap(); + + assert_eq!(result, b"testdata"); +} + +#[test] +fn test_key_decapsulation_v1() { + let encapsulated_key = EncapsulatedKey::try_from(general_purpose::STANDARD.decode("DQwKAAAAAQAdAidiTcgaZ0lFtIEr7TWNrzDq7XkFBWLSDqU+tWi68zmUXyMMxwXPSwYuX0QLAhArzNyMJXqo88zrYR+AfAeeeBVpRLq4naSDQ12j0d3gsNFbhBRzCbaa/WL92DwNQSZoA3Z1bSToRkxepJW7kcqTHs6DfFn9WAWBpU8Qul3iHWfiFqY0/xNdzMfb0H61qaLlB9C762rXwqKw5jGF1XOih6q2jwZjKOp7bZK+JNXMTb9X5BqJl/spn3FKLIhOLp85M26hDdJM1THqNCkm8/xYWVA6alkdfL9ZGE1IxJv9i8B5rW0qK7sG54a+ZuGnSaf3TZiSSR1D519WHttp+rlI/yNNWXxsOcmZmOEKw4DX2XQS5IQ9S1wflnaBwRVyBIfMUILnKSWZ0EwlgCs6XKnFy/5NqRNyZbDSAcJ+ROA/8H7yMrYQV0ilwl9mXAx9Ip9AHYsrrjC2IM3iWUBR6gqM7bwmBVb/eN51WwSXK8Vcy4FPeeii6VvFND/rEiiERhmRBRFhDNYmf5tARfBgccPw62GO56IjkBuyZhoUZ6DB8Uy8nVxcoe0hpN6fiCW10S8cBWdKtyJObkl9oqRbxmB21bKSJS/yz3owxqivTSaaLAT5HQy6FnPFYoOoG57Vp/WEIU00C5GwuAs6MUbuBBiZtjeJMRkV/HJidgH1WPIx0Xs7EKf/N4kkVok2D9ZdL9jSbLV69QR5SeW8M2U9B+1oqZghXpI4en20WEvGxEJ1ALCd2VlQqK76vLZpXX0DSlhMv14VzR7fUiZiurpH8S/tbieCMijdeWt9a5lnWTzWE0IVgALHLH2T82R6RJSIbBE7EwWziYO3Vm0Tus3iY+GxzSRqaBHc0oCsbFDFG3BkWF7gWOeTElnz7taRRqKnolN4VWfPRQ6Glmq8ml/83HTQFJfW7yI5jKp+I9vSCZ/tAcOTaneUDZ6cveDfPIWnLsSld2t/1MtXlzbRHGqGfe3nyU94TU7WDeFGcgL+EykMu58RVxVT5yefqy6kTlnkUWqr7j2EwrayzT0D5d4dvzJp38SXDsHL2/W2bsmv7WipnjFAp8xh0gNd5AGysTf+xJ8+Qv6D/aQF8JgEvVn+E7i5JcAWFIHelVupk6JJPX7KNalqN3LqBwuFm2gb7rTkdGWGoFw23+1NeyfuLPWSPq79NuzKw/5PqhQWlwDxOC88aF8lYLB3QrT8AcF/2ciwaT2j33YpRdhkFVbPrwNUPFsVV+LA4NplDywB81mABsfS3JSHhiXPLAKZhHV56qmM2gFG5dicrHOm6k7s3mDAfH/LUNrHgUs7ihYr3BzCocIfq//1RDf9NmJWj9rKbj3NpeAGnkMD68W82xv5SAYpkC6E5mZMJD1O6CBmgnPkLc6/YNxWfE/O37o18gTvjpYZourUxsX1BwAnoLjz+nptQJqJHzJeeA/GqaaoP+8F+LyEAxKFBIFD/XYK4xJ6N3v4OtforFb9QtvwCoMKnZvbeMEd").unwrap().as_slice()).unwrap(); + + let secret_key = decapsulate(&x_wing_private_key(), &encapsulated_key).unwrap(); + + assert_eq!( + secret_key.as_bytes(), + general_purpose::STANDARD + .decode("hqnsrwF5qEgJsI0G4aURCcbbsshKl7Z590eicp0ArtA=") + .unwrap() + .as_slice() + ); +} diff --git a/tests/low_order_points.rs b/tests/low_order_points.rs index 25c76d96..95ffb6f7 100644 --- a/tests/low_order_points.rs +++ b/tests/low_order_points.rs @@ -1,6 +1,9 @@ use devolutions_crypto::Error; use devolutions_crypto::ciphertext::{Ciphertext, CiphertextVersion, encrypt_asymmetric}; use devolutions_crypto::key::{KeyVersion, PublicKey, generate_keypair, mix_key_exchange}; +use devolutions_crypto::key_encapsulation::{ + EncapsulatedKey, KeyEncapsulationVersion, decapsulate, encapsulate, +}; use devolutions_crypto::online_ciphertext::{ OnlineCiphertextEncryptor, OnlineCiphertextHeader, OnlineCiphertextVersion, }; @@ -117,3 +120,52 @@ fn online_ciphertext_rejects_low_order_points() { assert!(matches!(result, Err(Error::InvalidData))); } } + +// X-Wing (KeyVersion::V2): the X25519 public key is the last 32 bytes of the encapsulation key, +// and the X25519 ephemeral key is the last 32 bytes of the KEM ciphertext. +const XWING_KEM_CIPHERTEXT_SIZE: usize = 1120; + +#[test] +fn x_wing_public_key_rejects_low_order_points() { + let template: Vec = generate_keypair(KeyVersion::V2).public_key.into(); + + for point in &LOW_ORDER_POINTS { + let result = PublicKey::try_from(with_last_32(&template, point).as_slice()); + assert!(matches!(result, Err(Error::InvalidData))); + } +} + +#[test] +fn decrypt_asymmetric_v3_rejects_low_order_points() { + let keypair = generate_keypair(KeyVersion::V2); + let ciphertext: Vec = + encrypt_asymmetric(b"data", &keypair.public_key, CiphertextVersion::V3) + .unwrap() + .into(); + + for point in &LOW_ORDER_POINTS { + // The X25519 ephemeral key ends the KEM ciphertext, which follows the 8-byte header + let mut forged = ciphertext.clone(); + let end = 8 + XWING_KEM_CIPHERTEXT_SIZE; + forged[end - 32..end].copy_from_slice(point); + + let forged = Ciphertext::try_from(forged.as_slice()).unwrap(); + let result = forged.decrypt_asymmetric(&keypair.private_key); + assert!(matches!(result, Err(Error::InvalidData))); + } +} + +#[test] +fn decapsulate_rejects_low_order_points() { + let keypair = generate_keypair(KeyVersion::V2); + let (encapsulated, _) = + encapsulate(&keypair.public_key, KeyEncapsulationVersion::Latest).unwrap(); + let encapsulated: Vec = encapsulated.into(); + + for point in &LOW_ORDER_POINTS { + let forged = + EncapsulatedKey::try_from(with_last_32(&encapsulated, point).as_slice()).unwrap(); + let result = decapsulate(&keypair.private_key, &forged); + assert!(matches!(result, Err(Error::InvalidData))); + } +} diff --git a/uniffi/devolutions-crypto-uniffi/src/lib.rs b/uniffi/devolutions-crypto-uniffi/src/lib.rs index c6b64f3a..641d01fb 100644 --- a/uniffi/devolutions-crypto-uniffi/src/lib.rs +++ b/uniffi/devolutions-crypto-uniffi/src/lib.rs @@ -38,6 +38,7 @@ pub enum DataType { OnlineCiphertext, KeyDerivation, KdfEncryptedData, + EncapsulatedKey, } #[uniffi::remote(Enum)] @@ -45,12 +46,14 @@ pub enum CiphertextVersion { Latest, V1, V2, + V3, } #[uniffi::remote(Enum)] pub enum KeyVersion { Latest, V1, + V2, } #[uniffi::remote(Enum)] From 288829bc68158f2c3da539a8c2d7d7e53850a84b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Duquette?= Date: Mon, 9 Nov 2026 11:09:19 -0500 Subject: [PATCH 2/4] docs: apply review wording changes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.toml | 2 +- src/ciphertext/mod.rs | 4 ++-- src/enums.rs | 4 ++-- src/key/mod.rs | 2 +- src/key_encapsulation/mod.rs | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 51aae823..cf498f9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,7 +58,7 @@ rust-argon2 = { workspace = true } ed25519-dalek = { version = "3", features = [ "rand_core" ] } x25519-dalek = { version = "3", features = [ "static_secrets" ] } -# X-Wing hybrid KEM (X25519 + ML-KEM-768). Do not enable the `getrandom` feature: +# Do not enable the `getrandom` feature: # randomness is supplied explicitly from the OS RNG. x-wing = { version = "0.1.1", default-features = false, features = ["zeroize"] } hkdf = "0.13" diff --git a/src/ciphertext/mod.rs b/src/ciphertext/mod.rs index 39b0e6c7..3059d727 100644 --- a/src/ciphertext/mod.rs +++ b/src/ciphertext/mod.rs @@ -184,7 +184,7 @@ pub fn encrypt_with_aad( /// * `data` - The data to encrypt. /// * `public_key` - The `PublicKey` to use. Use `generate_keypair` to generate a keypair. /// * `version` - Version of the library to encrypt with. Use `CiphertextVersion::Latest` if you're not dealing with shared data. -/// `CiphertextVersion::V2` (and `Latest`) requires a `KeyVersion::V1` key; `CiphertextVersion::V3` (post-quantum) requires a `KeyVersion::V2` key. +/// `CiphertextVersion::V2` (and `Latest`) requires a `KeyVersion::V1` key; `CiphertextVersion::V3` requires a `KeyVersion::V2` key. /// A mismatched key returns `Error::InvalidDataType`. /// # Returns /// Returns a `Ciphertext` containing the encrypted data. @@ -213,7 +213,7 @@ pub fn encrypt_asymmetric( /// * `public_key` - The `PublicKey` to use. Use `generate_keypair` to generate a keypair. /// * `aad` - Additionnal data to authenticate alongside the ciphertext. /// * `version` - Version of the library to encrypt with. Use `CiphertextVersion::Latest` if you're not dealing with shared data. -/// `CiphertextVersion::V2` (and `Latest`) requires a `KeyVersion::V1` key; `CiphertextVersion::V3` (post-quantum) requires a `KeyVersion::V2` key. +/// `CiphertextVersion::V2` (and `Latest`) requires a `KeyVersion::V1` key; `CiphertextVersion::V3` requires a `KeyVersion::V2` key. /// A mismatched key returns `Error::InvalidDataType`. /// # Returns /// Returns a `Ciphertext` containing the encrypted data. diff --git a/src/enums.rs b/src/enums.rs index f0e202ba..6285fa98 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -53,7 +53,7 @@ pub enum CiphertextVersion { V1 = 1, /// Uses version 2: XChaCha20-Poly1305. Asymmetric encryption uses an X25519 (`KeyVersion::V1`) key. V2 = 2, - /// Uses version 3: X-Wing (X25519 + ML-KEM-768) hybrid post-quantum KEM, HKDF-SHA-256 and XChaCha20-Poly1305. + /// Uses version 3: X-Wing, HKDF-SHA-256 and XChaCha20-Poly1305. /// Asymmetric only; requires a `KeyVersion::V2` key. V3 = 3, } @@ -100,7 +100,7 @@ pub enum KeyVersion { Latest = 0, /// Uses version 1: Curve25519 keys and x25519 key exchange. V1 = 1, - /// Uses version 2: X-Wing (X25519 + ML-KEM-768) hybrid post-quantum KEM keys. + /// Uses version 2: X-Wing (X25519 + ML-KEM-768) keys. /// Usable with `CiphertextVersion::V3` and the `key_encapsulation` module, not with `mix_key_exchange`. V2 = 2, } diff --git a/src/key/mod.rs b/src/key/mod.rs index 5ec7e6ce..9788544c 100644 --- a/src/key/mod.rs +++ b/src/key/mod.rs @@ -135,7 +135,7 @@ enum PublicKeyPayload { /// Generates a `KeyPair` to use in a key exchange or to encrypt data. /// # Arguments /// * `version` - Version of the key scheme to use. Use `KeyVersion::Latest` if you're not dealing with shared data. -/// `KeyVersion::V2` generates a post-quantum X-Wing (X25519 + ML-KEM-768) keypair, usable with +/// `KeyVersion::V2` generates a X-Wing keypair, usable with /// `CiphertextVersion::V3` and the `key_encapsulation` module but not with `mix_key_exchange`. /// # Returns /// Returns a `KeyPair` containing the private key and the public key. diff --git a/src/key_encapsulation/mod.rs b/src/key_encapsulation/mod.rs index 07c40dcc..8f83cb7d 100644 --- a/src/key_encapsulation/mod.rs +++ b/src/key_encapsulation/mod.rs @@ -5,7 +5,7 @@ //! from the `EncapsulatedKey`. The resulting `SecretKey` can then be used with the symmetric //! `ciphertext` functions. //! -//! This requires a `KeyVersion::V2` (X-Wing: X25519 + ML-KEM-768) keypair. Unlike `mix_key_exchange`, +//! This requires a `KeyVersion::V2` keypair. Unlike `mix_key_exchange`, //! this is a one-way scheme: the sender must transmit the `EncapsulatedKey` to the recipient. //! //! ```rust From 9577d4227122cc3bfbc83ac944eccbba56a6605f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Duquette?= Date: Tue, 10 Nov 2026 10:10:25 -0500 Subject: [PATCH 3/4] fix(key): return RandomError instead of panicking when encapsulating Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/key/key_v2.rs | 22 +++++++++++++--------- src/key/mod.rs | 2 +- src/key_encapsulation/mod.rs | 5 ++--- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/key/key_v2.rs b/src/key/key_v2.rs index a5aebbc2..a786fad2 100644 --- a/src/key/key_v2.rs +++ b/src/key/key_v2.rs @@ -6,12 +6,12 @@ use super::Error; use super::Result; use hkdf::Hkdf; -use rand::rand_core::UnwrapErr; +use rand::TryRng; use rand::rngs::SysRng; use sha2::Sha256; use x_wing::{ - DecapsulationKeyRejectNonContrib, Decapsulator, Encapsulate, EncapsulationKey, KeyExport, - KeyInit, TryDecapsulate, + DecapsulationKeyRejectNonContrib, Decapsulator, EncapsulationKey, KeyExport, KeyInit, + TryDecapsulate, }; use zeroize::Zeroizing; @@ -88,16 +88,20 @@ impl KeyV2Public { /// Encapsulates a fresh shared secret to this key and derives a 32-byte key from it with /// HKDF-SHA-256 (no salt) using `info` as the label. /// - /// # Panics - /// Panics if the OS random number generator is unavailable, like `generate_keypair`. - pub fn encapsulate(&self, info: &[u8]) -> (Box, Zeroizing<[u8; 32]>) { - let (ciphertext, shared_secret) = self.key.encapsulate_with_rng(&mut UnwrapErr(SysRng)); + /// Returns `Error::RandomError` if the OS random number generator is unavailable. + pub fn encapsulate(&self, info: &[u8]) -> Result<(Box, Zeroizing<[u8; 32]>)> { + let mut randomness = Zeroizing::new([0u8; x_wing::ENCAPSULATION_RANDOMNESS_SIZE]); + SysRng + .try_fill_bytes(randomness.as_mut_slice()) + .map_err(|_| Error::RandomError)?; + + let (ciphertext, shared_secret) = self.key.encapsulate_deterministic((&*randomness).into()); let shared_secret = Zeroizing::new(shared_secret); - ( + Ok(( Box::new(ciphertext), derive_key(shared_secret.as_slice(), info), - ) + )) } } diff --git a/src/key/mod.rs b/src/key/mod.rs index 9788544c..cfe7e995 100644 --- a/src/key/mod.rs +++ b/src/key/mod.rs @@ -359,7 +359,7 @@ impl PublicKey { info: &[u8], ) -> Result<(Box, Zeroizing<[u8; 32]>)> { match &self.payload { - PublicKeyPayload::V2(x) => Ok(x.encapsulate(info)), + PublicKeyPayload::V2(x) => x.encapsulate(info), _ => Err(Error::InvalidDataType), } } diff --git a/src/key_encapsulation/mod.rs b/src/key_encapsulation/mod.rs index 8f83cb7d..22cff6f1 100644 --- a/src/key_encapsulation/mod.rs +++ b/src/key_encapsulation/mod.rs @@ -80,9 +80,8 @@ enum EncapsulatedKeyPayload { /// * `version` - Version of the key encapsulation scheme to use. Use `KeyEncapsulationVersion::Latest` if you're not dealing with shared data. /// # Returns /// Returns the `EncapsulatedKey` to send to the recipient and the `SecretKey` it encapsulates. -/// Returns `Error::InvalidDataType` if the public key is not a `KeyVersion::V2` key. -/// # Panics -/// Panics if the OS random number generator is unavailable, like `generate_keypair`. +/// Returns `Error::InvalidDataType` if the public key is not a `KeyVersion::V2` key, +/// or `Error::RandomError` if the OS random number generator is unavailable. /// # Example /// ```rust /// use devolutions_crypto::key::{generate_keypair, KeyVersion}; From 3f3cca8403cc0e577ad4d09738ad92ec48b24343 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Duquette?= Date: Tue, 10 Nov 2026 10:37:58 -0500 Subject: [PATCH 4/4] Revert "fix(key): return RandomError instead of panicking when encapsulating" The OS RNG failure case keeps panicking, consistent with generate_keypair. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/key/key_v2.rs | 22 +++++++++------------- src/key/mod.rs | 2 +- src/key_encapsulation/mod.rs | 5 +++-- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/key/key_v2.rs b/src/key/key_v2.rs index a786fad2..a5aebbc2 100644 --- a/src/key/key_v2.rs +++ b/src/key/key_v2.rs @@ -6,12 +6,12 @@ use super::Error; use super::Result; use hkdf::Hkdf; -use rand::TryRng; +use rand::rand_core::UnwrapErr; use rand::rngs::SysRng; use sha2::Sha256; use x_wing::{ - DecapsulationKeyRejectNonContrib, Decapsulator, EncapsulationKey, KeyExport, KeyInit, - TryDecapsulate, + DecapsulationKeyRejectNonContrib, Decapsulator, Encapsulate, EncapsulationKey, KeyExport, + KeyInit, TryDecapsulate, }; use zeroize::Zeroizing; @@ -88,20 +88,16 @@ impl KeyV2Public { /// Encapsulates a fresh shared secret to this key and derives a 32-byte key from it with /// HKDF-SHA-256 (no salt) using `info` as the label. /// - /// Returns `Error::RandomError` if the OS random number generator is unavailable. - pub fn encapsulate(&self, info: &[u8]) -> Result<(Box, Zeroizing<[u8; 32]>)> { - let mut randomness = Zeroizing::new([0u8; x_wing::ENCAPSULATION_RANDOMNESS_SIZE]); - SysRng - .try_fill_bytes(randomness.as_mut_slice()) - .map_err(|_| Error::RandomError)?; - - let (ciphertext, shared_secret) = self.key.encapsulate_deterministic((&*randomness).into()); + /// # Panics + /// Panics if the OS random number generator is unavailable, like `generate_keypair`. + pub fn encapsulate(&self, info: &[u8]) -> (Box, Zeroizing<[u8; 32]>) { + let (ciphertext, shared_secret) = self.key.encapsulate_with_rng(&mut UnwrapErr(SysRng)); let shared_secret = Zeroizing::new(shared_secret); - Ok(( + ( Box::new(ciphertext), derive_key(shared_secret.as_slice(), info), - )) + ) } } diff --git a/src/key/mod.rs b/src/key/mod.rs index cfe7e995..9788544c 100644 --- a/src/key/mod.rs +++ b/src/key/mod.rs @@ -359,7 +359,7 @@ impl PublicKey { info: &[u8], ) -> Result<(Box, Zeroizing<[u8; 32]>)> { match &self.payload { - PublicKeyPayload::V2(x) => x.encapsulate(info), + PublicKeyPayload::V2(x) => Ok(x.encapsulate(info)), _ => Err(Error::InvalidDataType), } } diff --git a/src/key_encapsulation/mod.rs b/src/key_encapsulation/mod.rs index 22cff6f1..8f83cb7d 100644 --- a/src/key_encapsulation/mod.rs +++ b/src/key_encapsulation/mod.rs @@ -80,8 +80,9 @@ enum EncapsulatedKeyPayload { /// * `version` - Version of the key encapsulation scheme to use. Use `KeyEncapsulationVersion::Latest` if you're not dealing with shared data. /// # Returns /// Returns the `EncapsulatedKey` to send to the recipient and the `SecretKey` it encapsulates. -/// Returns `Error::InvalidDataType` if the public key is not a `KeyVersion::V2` key, -/// or `Error::RandomError` if the OS random number generator is unavailable. +/// Returns `Error::InvalidDataType` if the public key is not a `KeyVersion::V2` key. +/// # Panics +/// Panics if the OS random number generator is unavailable, like `generate_keypair`. /// # Example /// ```rust /// use devolutions_crypto::key::{generate_keypair, KeyVersion};