diff --git a/Cargo.lock b/Cargo.lock index 98d2db605da..21cee9d6109 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2269,6 +2269,7 @@ version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ + "ctutils", "subtle", "typenum", "zeroize", @@ -2803,6 +2804,16 @@ dependencies = [ "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.1", +] + [[package]] name = "keygen" version = "0.1.0" @@ -4019,6 +4030,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.1", + "sha3", +] + +[[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.15" @@ -5923,6 +5958,16 @@ dependencies = [ "digest 0.11.3", ] +[[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", +] + [[package]] name = "shake" version = "0.1.0" @@ -6040,17 +6085,17 @@ dependencies = [ [[package]] name = "snow" version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "599b506ccc4aff8cf7844bc42cf783009a434c1e26c964432560fb6d6ad02d82" +source = "git+https://github.com/royzah/snow?branch=feat%2Fml-kem-hfs#858dc27ca4b847e4eae582b3878f5906fc595e6a" dependencies = [ "aes-gcm", "blake2", "chacha20poly1305", "curve25519-dalek 4.1.3", "getrandom 0.3.4", + "ml-kem", "ring", "rustc_version", - "sha2 0.10.9", + "sha2 0.11.0", "subtle", ] diff --git a/Cargo.toml b/Cargo.toml index 092e759eed0..594d63e5f05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -164,6 +164,9 @@ web-time = "1.1" [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..63cc82b76f3 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+MLKEM768_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 38f22d7e543..758a5b973ac 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 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 cd1cbf2c648..831feb9701b 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+MLKEM768_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,6 +134,15 @@ 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, @@ -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 4ea956a7e7b..555ac1554a4 100644 --- a/transports/noise/src/protocol.rs +++ b/transports/noise/src/protocol.rs @@ -39,6 +39,14 @@ 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+MLKEM768_ChaChaPoly_SHA256" + .parse() + .expect("Invalid protocol name") +}); + pub(crate) fn noise_params_into_builder<'b>( params: NoiseParams, prologue: &'b [u8], @@ -207,6 +215,12 @@ 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. diff --git a/transports/noise/tests/mlkem_hfs.rs b/transports/noise/tests/mlkem_hfs.rs new file mode 100644 index 00000000000..e6f724e28e3 --- /dev/null +++ b/transports/noise/tests/mlkem_hfs.rs @@ -0,0 +1,70 @@ +// 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(); + }); +}