Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 48 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions transports/noise/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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).

Expand Down
5 changes: 5 additions & 0 deletions transports/noise/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ authors = ["Parity Technologies <admin@parity.io>"]
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
Expand Down
4 changes: 4 additions & 0 deletions transports/noise/src/io/framed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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! {
Expand Down
35 changes: 31 additions & 4 deletions transports/noise/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<S: AsyncRead + AsyncWrite>(self, socket: S) -> Result<State<S>, Error> {
let session = noise_params_into_builder(
self.params,
Expand Down Expand Up @@ -169,10 +186,18 @@ impl Config {

impl UpgradeInfo for Config {
type Info = &'static str;
type InfoIter = std::iter::Once<Self::Info>;
type InfoIter = std::vec::IntoIter<Self::Info>;

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()
}
}
}

Expand All @@ -184,8 +209,9 @@ where
type Error = Error;
type Future = Pin<Box<dyn Future<Output = Result<Self::Output, Self::Error>> + 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?;
Expand All @@ -208,8 +234,9 @@ where
type Error = Error;
type Future = Pin<Box<dyn Future<Output = Result<Self::Output, Self::Error>> + 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?;
Expand Down
14 changes: 14 additions & 0 deletions transports/noise/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ pub(crate) static PARAMS_XX: LazyLock<NoiseParams> = 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<NoiseParams> = 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],
Expand Down Expand Up @@ -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<Box<dyn snow::types::Kem>> {
snow::resolvers::DefaultResolver.resolve_kem(choice)
}
}

/// Wrapper around a CSPRNG to implement `snow::Random` trait for.
Expand Down
70 changes: 70 additions & 0 deletions transports/noise/tests/mlkem_hfs.rs
Original file line number Diff line number Diff line change
@@ -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();
});
}