diff --git a/Cargo.lock b/Cargo.lock index f37c0296750..5d58fac12c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,7 +14,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "generic-array", ] @@ -514,21 +514,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] -name = "blake2" -version = "0.10.6" +name = "block-buffer" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "digest", + "generic-array", ] [[package]] name = "block-buffer" -version = "0.10.4" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] @@ -773,7 +773,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "inout", "zeroize", ] @@ -827,6 +827,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.3" @@ -1109,6 +1115,17 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.2", + "hybrid-array", + "rand_core 0.10.0", +] + [[package]] name = "ctr" version = "0.9.2" @@ -1118,6 +1135,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "cuckoofilter" version = "0.5.0" @@ -1138,7 +1164,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest", + "digest 0.10.7", "fiat-crypto", "rustc_version", "subtle", @@ -1249,12 +1275,22 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", "const-oid", - "crypto-common", + "crypto-common 0.1.6", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", +] + [[package]] name = "dirs" version = "5.0.1" @@ -1374,7 +1410,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest", + "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", @@ -1419,7 +1455,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", @@ -2107,7 +2143,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -2189,6 +2225,16 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "ctutils", + "typenum", +] + [[package]] name = "hyper" version = "1.8.1" @@ -2804,6 +2850,26 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "keccak" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common 0.2.2", + "rand_core 0.10.0", +] + [[package]] name = "keygen" version = "0.1.0" @@ -3367,7 +3433,7 @@ dependencies = [ "quickcheck-ext", "rand 0.8.6", "salsa20", - "sha3", + "sha3 0.10.8", "tokio", "tracing", ] @@ -3927,7 +3993,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] @@ -4047,6 +4113,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-kem" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e15f3e5b957493873e396a66914e83e616b6afe335cdef7efe5c6e1216aba66" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "rand_core 0.10.0", + "sha3 0.11.0", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + [[package]] name = "moka" version = "0.12.10" @@ -5966,7 +6056,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", ] [[package]] @@ -5977,7 +6067,7 @@ checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", ] [[package]] @@ -5986,8 +6076,18 @@ version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" dependencies = [ - "digest", - "keccak", + "digest 0.10.7", + "keccak 0.1.6", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.0", ] [[package]] @@ -6029,7 +6129,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] @@ -6081,18 +6181,13 @@ dependencies = [ [[package]] name = "snow" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "850948bee068e713b8ab860fe1adc4d109676ab4c3b621fd8147f06b261f2f85" +version = "0.10.0" +source = "git+https://github.com/royzah/snow?branch=feat/ml-kem-hfs#407dd908c134c8872b39a228d17f151560ca7221" dependencies = [ - "aes-gcm", - "blake2", - "chacha20poly1305", "curve25519-dalek", - "rand_core 0.6.4", + "ml-kem", "ring", "rustc_version", - "sha2", "subtle", ] @@ -6918,7 +7013,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "subtle", ] diff --git a/Cargo.toml b/Cargo.toml index d59f9e15941..68a4852d31c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -159,6 +159,9 @@ web-time = "1.1.0" [patch.crates-io] +# snow fork carrying ML-KEM-768 (mcginty/snow#210), until it lands upstream. +snow = { git = "https://github.com/royzah/snow", branch = "feat/ml-kem-hfs" } + # Patch away `libp2p-identity` in our dependency tree with the workspace version. # `libp2p-identity` is a leaf dependency and used within `rust-multiaddr` which is **not** part of the workspace. # As a result, we cannot just reference the workspace version in our crates because the types would mismatch with what diff --git a/transports/noise/CHANGELOG.md b/transports/noise/CHANGELOG.md index 33c07373ff7..29746a1c10f 100644 --- a/transports/noise/CHANGELOG.md +++ b/transports/noise/CHANGELOG.md @@ -1,5 +1,9 @@ ## 0.47.0 +- Add an additive, off-by-default `mlkem-hfs` feature: a hybrid post-quantum + handshake (`Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256`) negotiated + alongside `/noise`, falling back to classical X25519 for older peers. + - Raise MSRV to 1.88.0. See [PR 6273](https://github.com/libp2p/rust-libp2p/pull/6273). diff --git a/transports/noise/Cargo.toml b/transports/noise/Cargo.toml index 7e21159331a..df92f4211c9 100644 --- a/transports/noise/Cargo.toml +++ b/transports/noise/Cargo.toml @@ -8,6 +8,11 @@ authors = ["Parity Technologies "] license = "MIT" repository = "https://github.com/libp2p/rust-libp2p" +[features] +# Hybrid PQ handshake, off by default. use-curve25519 satisfies snow's +# default-resolver guard (the ML-KEM impl lives there). +mlkem-hfs = ["snow/use-ml-kem", "snow/use-curve25519"] + [dependencies] asynchronous-codec = { workspace = true } bytes.workspace = true @@ -25,10 +30,10 @@ x25519-dalek = "2" zeroize = "1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -snow = { version = "0.9.6", features = ["ring-resolver"], default-features = false } +snow = { version = "0.10", features = ["ring-resolver"], default-features = false } [target.'cfg(target_arch = "wasm32")'.dependencies] -snow = { version = "0.9.5", features = ["default-resolver"], default-features = false } +snow = { version = "0.10", features = ["default-resolver"], default-features = false } [dev-dependencies] futures_ringbuf = "0.4.0" diff --git a/transports/noise/src/io/framed.rs b/transports/noise/src/io/framed.rs index 5364bc8d986..dc82c73c2f4 100644 --- a/transports/noise/src/io/framed.rs +++ b/transports/noise/src/io/framed.rs @@ -35,7 +35,11 @@ use crate::{Error, protocol::PublicKey}; /// Max. size of a noise message. const MAX_NOISE_MSG_LEN: usize = 65535; /// Space given to the encryption buffer to hold key material. +#[cfg(not(feature = "mlkem-hfs"))] const EXTRA_ENCRYPT_SPACE: usize = 1024; +/// Hybrid adds an ML-KEM-768 key (1184 B) or ciphertext (1088 B) per message. +#[cfg(feature = "mlkem-hfs")] +const EXTRA_ENCRYPT_SPACE: usize = 1024 + 1184; /// Max. length for Noise protocol message payloads. pub(crate) const MAX_FRAME_LEN: usize = MAX_NOISE_MSG_LEN - EXTRA_ENCRYPT_SPACE; static_assertions::const_assert! { diff --git a/transports/noise/src/lib.rs b/transports/noise/src/lib.rs index fc41ab26066..be6b8003df1 100644 --- a/transports/noise/src/lib.rs +++ b/transports/noise/src/lib.rs @@ -74,12 +74,20 @@ use multiaddr::Protocol; use multihash::Multihash; use snow::params::NoiseParams; +#[cfg(feature = "mlkem-hfs")] +use crate::protocol::PARAMS_XX_HFS; use crate::{ handshake::State, io::handshake, protocol::{AuthenticKeypair, Keypair, PARAMS_XX, noise_params_into_builder}, }; +const NOISE_PROTOCOL: &str = "/noise"; + +/// `Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256`. Provisional, pending a spec. +#[cfg(feature = "mlkem-hfs")] +const NOISE_MLKEM_HFS_PROTOCOL: &str = "/noise-mlkem768-hfs/0.1.0"; + /// The configuration for the noise handshake. #[derive(Clone)] pub struct Config { @@ -126,13 +134,22 @@ impl Config { self } + #[cfg_attr(not(feature = "mlkem-hfs"), allow(unused_variables))] + fn params_for(&self, info: &str) -> NoiseParams { + #[cfg(feature = "mlkem-hfs")] + if info == NOISE_MLKEM_HFS_PROTOCOL { + return PARAMS_XX_HFS.clone(); + } + self.params.clone() + } + fn into_responder(self, socket: S) -> Result, Error> { let session = noise_params_into_builder( self.params, &self.prologue, self.dh_keys.keypair.secret(), None, - ) + )? .build_responder()?; let state = State::new( @@ -152,7 +169,7 @@ impl Config { &self.prologue, self.dh_keys.keypair.secret(), None, - ) + )? .build_initiator()?; let state = State::new( @@ -169,10 +186,18 @@ impl Config { impl UpgradeInfo for Config { type Info = &'static str; - type InfoIter = std::iter::Once; + type InfoIter = std::vec::IntoIter; fn protocol_info(&self) -> Self::InfoIter { - std::iter::once("/noise") + // Hybrid PQ first, classical fallback. + #[cfg(feature = "mlkem-hfs")] + { + vec![NOISE_MLKEM_HFS_PROTOCOL, NOISE_PROTOCOL].into_iter() + } + #[cfg(not(feature = "mlkem-hfs"))] + { + vec![NOISE_PROTOCOL].into_iter() + } } } @@ -184,8 +209,9 @@ where type Error = Error; type Future = Pin> + Send>>; - fn upgrade_inbound(self, socket: T, _: Self::Info) -> Self::Future { + fn upgrade_inbound(mut self, socket: T, info: Self::Info) -> Self::Future { async move { + self.params = self.params_for(info); let mut state = self.into_responder(socket)?; handshake::recv_empty(&mut state).await?; @@ -208,8 +234,9 @@ where type Error = Error; type Future = Pin> + Send>>; - fn upgrade_outbound(self, socket: T, _: Self::Info) -> Self::Future { + fn upgrade_outbound(mut self, socket: T, info: Self::Info) -> Self::Future { async move { + self.params = self.params_for(info); let mut state = self.into_initiator(socket)?; handshake::send_empty(&mut state).await?; diff --git a/transports/noise/src/protocol.rs b/transports/noise/src/protocol.rs index f604e908418..54dedc16d3e 100644 --- a/transports/noise/src/protocol.rs +++ b/transports/noise/src/protocol.rs @@ -39,21 +39,29 @@ pub(crate) static PARAMS_XX: LazyLock = LazyLock::new(|| { .expect("Invalid protocol name") }); +/// Hybrid XX: X25519 auth plus an ML-KEM-768 (FIPS 203) ephemeral KEM. +#[cfg(feature = "mlkem-hfs")] +pub(crate) static PARAMS_XX_HFS: LazyLock = LazyLock::new(|| { + "Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256" + .parse() + .expect("Invalid protocol name") +}); + pub(crate) fn noise_params_into_builder<'b>( params: NoiseParams, prologue: &'b [u8], private_key: &'b SecretKey, remote_public_key: Option<&'b PublicKey>, -) -> snow::Builder<'b> { +) -> Result, snow::Error> { let mut builder = snow::Builder::with_resolver(params, Box::new(Resolver)) - .prologue(prologue.as_ref()) - .local_private_key(private_key.as_ref()); + .prologue(prologue.as_ref())? + .local_private_key(private_key.as_ref())?; if let Some(remote_public_key) = remote_public_key { - builder = builder.remote_public_key(remote_public_key.as_ref()); + builder = builder.remote_public_key(remote_public_key.as_ref())?; } - builder + Ok(builder) } /// DH keypair. @@ -207,33 +215,24 @@ impl snow::resolvers::CryptoResolver for Resolver { snow::resolvers::RingResolver.resolve_cipher(choice) } } + + // ring has no KEM; take it from the pure-Rust `DefaultResolver`. + #[cfg(feature = "mlkem-hfs")] + fn resolve_kem(&self, choice: &snow::params::KemChoice) -> Option> { + snow::resolvers::DefaultResolver.resolve_kem(choice) + } } /// Wrapper around a CSPRNG to implement `snow::Random` trait for. struct Rng(rand::rngs::StdRng); -impl rand::RngCore for Rng { - fn next_u32(&mut self) -> u32 { - self.0.next_u32() - } - - fn next_u64(&mut self) -> u64 { - self.0.next_u64() - } - - fn fill_bytes(&mut self, dest: &mut [u8]) { - self.0.fill_bytes(dest) - } - - fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand::Error> { - self.0.try_fill_bytes(dest) +impl snow::types::Random for Rng { + fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), snow::Error> { + rand::RngCore::fill_bytes(&mut self.0, dest); + Ok(()) } } -impl rand::CryptoRng for Rng {} - -impl snow::types::Random for Rng {} - impl Default for Keypair { fn default() -> Self { Self::new() @@ -274,12 +273,13 @@ impl snow::types::Dh for Keypair { secret.zeroize(); } - fn generate(&mut self, rng: &mut dyn snow::types::Random) { + fn generate(&mut self, rng: &mut dyn snow::types::Random) -> Result<(), snow::Error> { let mut secret = [0u8; 32]; - rng.fill_bytes(&mut secret); + rng.try_fill_bytes(&mut secret)?; self.secret = SecretKey(secret); // Copy self.public = PublicKey(x25519(secret, X25519_BASEPOINT_BYTES)); secret.zeroize(); + Ok(()) } fn dh(&self, pk: &[u8], shared_secret: &mut [u8]) -> Result<(), snow::Error> { @@ -320,7 +320,7 @@ mod tests { } fn xx_builder(prologue: &'static [u8]) -> snow::Builder<'static> { - noise_params_into_builder(PARAMS_XX.clone(), prologue, TEST_KEY.secret(), None) + noise_params_into_builder(PARAMS_XX.clone(), prologue, TEST_KEY.secret(), None).unwrap() } // Hack to work around borrow-checker. diff --git a/transports/noise/tests/mlkem_hfs.rs b/transports/noise/tests/mlkem_hfs.rs new file mode 100644 index 00000000000..7eb01a99c9b --- /dev/null +++ b/transports/noise/tests/mlkem_hfs.rs @@ -0,0 +1,62 @@ +// End-to-end hybrid Noise handshake (X25519 + ML-KEM-768), driven through the +// libp2p upgrade with the hybrid protocol id. Mirrors `smoke.rs`. +#![cfg(feature = "mlkem-hfs")] + +use futures::prelude::*; +use libp2p_core::upgrade::{InboundConnectionUpgrade, OutboundConnectionUpgrade}; +use libp2p_identity as identity; +use libp2p_noise as noise; + +// Must match `NOISE_MLKEM_HFS_PROTOCOL` in the crate (kept private there). +const HFS: &str = "/noise-mlkem768-hfs/0.1.0"; + +#[test] +fn xxhfs_mlkem768_handshake_and_transport() { + let server_id = identity::Keypair::generate_ed25519(); + let client_id = identity::Keypair::generate_ed25519(); + + let (client, server) = futures_ringbuf::Endpoint::pair(4096, 4096); + + futures::executor::block_on(async move { + let ((reported_client_id, mut server_session), (reported_server_id, mut client_session)) = + futures::future::try_join( + noise::Config::new(&server_id).unwrap().upgrade_inbound(server, HFS), + noise::Config::new(&client_id).unwrap().upgrade_outbound(client, HFS), + ) + .await + .unwrap(); + + assert_eq!(reported_client_id, client_id.public().to_peer_id()); + assert_eq!(reported_server_id, server_id.public().to_peer_id()); + + let msg = b"harvest now, decrypt never"; + let client_fut = async { + client_session.write_all(msg).await.expect("write"); + client_session.flush().await.expect("flush"); + }; + let server_fut = async { + let mut buf = vec![0u8; msg.len()]; + server_session.read_exact(&mut buf).await.expect("read"); + assert_eq!(&buf, msg); + }; + futures::future::join(client_fut, server_fut).await; + }); +} + +/// Hybrid initiator and classical responder negotiate down to `/noise`. +#[test] +fn falls_back_to_classical_when_peer_is_old() { + let server_id = identity::Keypair::generate_ed25519(); + let client_id = identity::Keypair::generate_ed25519(); + + let (client, server) = futures_ringbuf::Endpoint::pair(4096, 4096); + + futures::executor::block_on(async move { + let (_, _) = futures::future::try_join( + noise::Config::new(&server_id).unwrap().upgrade_inbound(server, "/noise"), + noise::Config::new(&client_id).unwrap().upgrade_outbound(client, "/noise"), + ) + .await + .unwrap(); + }); +}