diff --git a/Cargo.lock b/Cargo.lock index 51249c98f26..4055073ced9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2709,6 +2709,8 @@ dependencies = [ "libp2p-core", "libp2p-identity", "log", + "multiaddr", + "multihash", "once_cell", "quick-protobuf", "quickcheck-ext", diff --git a/transports/noise/CHANGELOG.md b/transports/noise/CHANGELOG.md index 27b37a81aef..33ca5036c77 100644 --- a/transports/noise/CHANGELOG.md +++ b/transports/noise/CHANGELOG.md @@ -5,8 +5,12 @@ - Remove deprecated APIs. See [PR 3511]. +- Add `Config::with_webtransport_certhashes`. See [PR 3991]. + This can be used by WebTransport implementers to send (responder) or verify (initiator) certhashes. + [PR 3511]: https://github.com/libp2p/rust-libp2p/pull/3511 [PR 3715]: https://github.com/libp2p/rust-libp2p/pull/3715 +[PR 3715]: https://github.com/libp2p/rust-libp2p/pull/3991 ## 0.42.2 diff --git a/transports/noise/Cargo.toml b/transports/noise/Cargo.toml index 35648f376a2..397d87714ba 100644 --- a/transports/noise/Cargo.toml +++ b/transports/noise/Cargo.toml @@ -15,8 +15,10 @@ futures = "0.3.28" libp2p-core = { workspace = true } libp2p-identity = { workspace = true, features = ["ed25519"] } log = "0.4" -quick-protobuf = "0.8" +multiaddr = { workspace = true } +multihash = { workspace = true } once_cell = "1.18.0" +quick-protobuf = "0.8" rand = "0.8.3" sha2 = "0.10.0" static_assertions = "1" @@ -35,7 +37,7 @@ env_logger = "0.10.0" futures_ringbuf = "0.4.0" quickcheck = { workspace = true } -# Passing arguments to the docsrs builder in order to properly document cfg's. +# Passing arguments to the docsrs builder in order to properly document cfg's. # More information: https://docs.rs/about/builds#cross-compiling [package.metadata.docs.rs] all-features = true diff --git a/transports/noise/src/generated/payload.proto b/transports/noise/src/generated/payload.proto index 1893dc55037..68bd01edd75 100644 --- a/transports/noise/src/generated/payload.proto +++ b/transports/noise/src/generated/payload.proto @@ -1,11 +1,15 @@ syntax = "proto3"; - package payload.proto; // Payloads for Noise handshake messages. +message NoiseExtensions { + repeated bytes webtransport_certhashes = 1; + repeated string stream_muxers = 2; +} + message NoiseHandshakePayload { bytes identity_key = 1; bytes identity_sig = 2; - bytes data = 3; + optional NoiseExtensions extensions = 4; } diff --git a/transports/noise/src/generated/payload/proto.rs b/transports/noise/src/generated/payload/proto.rs index 7b17a58ef37..98808ed466a 100644 --- a/transports/noise/src/generated/payload/proto.rs +++ b/transports/noise/src/generated/payload/proto.rs @@ -13,12 +13,48 @@ use quick_protobuf::{MessageInfo, MessageRead, MessageWrite, BytesReader, Writer use quick_protobuf::sizeofs::*; use super::super::*; +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Debug, Default, PartialEq, Clone)] +pub struct NoiseExtensions { + pub webtransport_certhashes: Vec>, + pub stream_muxers: Vec, +} + +impl<'a> MessageRead<'a> for NoiseExtensions { + fn from_reader(r: &mut BytesReader, bytes: &'a [u8]) -> Result { + let mut msg = Self::default(); + while !r.is_eof() { + match r.next_tag(bytes) { + Ok(10) => msg.webtransport_certhashes.push(r.read_bytes(bytes)?.to_owned()), + Ok(18) => msg.stream_muxers.push(r.read_string(bytes)?.to_owned()), + Ok(t) => { r.read_unknown(bytes, t)?; } + Err(e) => return Err(e), + } + } + Ok(msg) + } +} + +impl MessageWrite for NoiseExtensions { + fn get_size(&self) -> usize { + 0 + + self.webtransport_certhashes.iter().map(|s| 1 + sizeof_len((s).len())).sum::() + + self.stream_muxers.iter().map(|s| 1 + sizeof_len((s).len())).sum::() + } + + fn write_message(&self, w: &mut Writer) -> Result<()> { + for s in &self.webtransport_certhashes { w.write_with_tag(10, |w| w.write_bytes(&**s))?; } + for s in &self.stream_muxers { w.write_with_tag(18, |w| w.write_string(&**s))?; } + Ok(()) + } +} + #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Debug, Default, PartialEq, Clone)] pub struct NoiseHandshakePayload { pub identity_key: Vec, pub identity_sig: Vec, - pub data: Vec, + pub extensions: Option, } impl<'a> MessageRead<'a> for NoiseHandshakePayload { @@ -28,7 +64,7 @@ impl<'a> MessageRead<'a> for NoiseHandshakePayload { match r.next_tag(bytes) { Ok(10) => msg.identity_key = r.read_bytes(bytes)?.to_owned(), Ok(18) => msg.identity_sig = r.read_bytes(bytes)?.to_owned(), - Ok(26) => msg.data = r.read_bytes(bytes)?.to_owned(), + Ok(34) => msg.extensions = Some(r.read_message::(bytes)?), Ok(t) => { r.read_unknown(bytes, t)?; } Err(e) => return Err(e), } @@ -42,13 +78,13 @@ impl MessageWrite for NoiseHandshakePayload { 0 + if self.identity_key.is_empty() { 0 } else { 1 + sizeof_len((&self.identity_key).len()) } + if self.identity_sig.is_empty() { 0 } else { 1 + sizeof_len((&self.identity_sig).len()) } - + if self.data.is_empty() { 0 } else { 1 + sizeof_len((&self.data).len()) } + + self.extensions.as_ref().map_or(0, |m| 1 + sizeof_len((m).get_size())) } fn write_message(&self, w: &mut Writer) -> Result<()> { if !self.identity_key.is_empty() { w.write_with_tag(10, |w| w.write_bytes(&**&self.identity_key))?; } if !self.identity_sig.is_empty() { w.write_with_tag(18, |w| w.write_bytes(&**&self.identity_sig))?; } - if !self.data.is_empty() { w.write_with_tag(26, |w| w.write_bytes(&**&self.data))?; } + if let Some(ref s) = self.extensions { w.write_with_tag(34, |w| w.write_message(s))?; } Ok(()) } } diff --git a/transports/noise/src/io/framed.rs b/transports/noise/src/io/framed.rs index 8c1a49fc7b6..d7fa79fc815 100644 --- a/transports/noise/src/io/framed.rs +++ b/transports/noise/src/io/framed.rs @@ -81,6 +81,14 @@ impl NoiseFramed { } } + pub(crate) fn is_initiator(&self) -> bool { + self.session.is_initiator() + } + + pub(crate) fn is_responder(&self) -> bool { + !self.session.is_initiator() + } + /// Converts the `NoiseFramed` into a `NoiseOutput` encrypted data stream /// once the handshake is complete, including the static DH [`PublicKey`] /// of the remote, if received. diff --git a/transports/noise/src/io/handshake.rs b/transports/noise/src/io/handshake.rs index 3027fbfbd19..c853af7b189 100644 --- a/transports/noise/src/io/handshake.rs +++ b/transports/noise/src/io/handshake.rs @@ -23,6 +23,7 @@ mod proto { #![allow(unreachable_pub)] include!("../generated/mod.rs"); + pub use self::payload::proto::NoiseExtensions; pub use self::payload::proto::NoiseHandshakePayload; } @@ -32,7 +33,9 @@ use crate::{DecodeError, Error}; use bytes::Bytes; use futures::prelude::*; use libp2p_identity as identity; +use multihash::Multihash; use quick_protobuf::{BytesReader, MessageRead, MessageWrite, Writer}; +use std::collections::HashSet; use std::io; ////////////////////////////////////////////////////////////////////////////// @@ -49,6 +52,15 @@ pub(crate) struct State { dh_remote_pubkey_sig: Option>, /// The known or received public identity key of the remote, if any. id_remote_pubkey: Option, + /// The WebTransport certhashes of the responder, if any. + responder_webtransport_certhashes: Option>>, + /// The received extensions of the remote, if any. + remote_extensions: Option, +} + +/// Extensions +struct Extensions { + webtransport_certhashes: HashSet>, } impl State { @@ -63,12 +75,15 @@ impl State { session: snow::HandshakeState, identity: KeypairIdentity, expected_remote_key: Option, + responder_webtransport_certhashes: Option>>, ) -> Self { Self { identity, io: NoiseFramed::new(io, session), dh_remote_pubkey_sig: None, id_remote_pubkey: expected_remote_key, + responder_webtransport_certhashes, + remote_extensions: None, } } } @@ -77,6 +92,7 @@ impl State { /// Finish a handshake, yielding the established remote identity and the /// [`Output`] for communicating on the encrypted channel. pub(crate) fn finish(self) -> Result<(identity::PublicKey, Output), Error> { + let is_initiator = self.io.is_initiator(); let (pubkey, io) = self.io.into_transport()?; let id_pk = self @@ -91,10 +107,46 @@ impl State { return Err(Error::BadSignature); } + // Check WebTransport certhashes that responder reported back to us. + if is_initiator { + // We check only if we care (i.e. Config::with_webtransport_certhashes was used). + if let Some(expected_certhashes) = self.responder_webtransport_certhashes { + let ext = self.remote_extensions.ok_or_else(|| { + Error::UnknownWebTransportCerthashes( + expected_certhashes.to_owned(), + HashSet::new(), + ) + })?; + + let received_certhashes = ext.webtransport_certhashes; + + // Expected WebTransport certhashes must be a strict subset + // of the reported ones. + if !expected_certhashes.is_subset(&received_certhashes) { + return Err(Error::UnknownWebTransportCerthashes( + expected_certhashes, + received_certhashes, + )); + } + } + } + Ok((id_pk, io)) } } +impl From for Extensions { + fn from(value: proto::NoiseExtensions) -> Self { + Extensions { + webtransport_certhashes: value + .webtransport_certhashes + .into_iter() + .filter_map(|bytes| Multihash::read(&bytes[..]).ok()) + .collect(), + } + } +} + ////////////////////////////////////////////////////////////////////////////// // Handshake Message Futures @@ -149,6 +201,10 @@ where state.dh_remote_pubkey_sig = Some(pb.identity_sig); } + if let Some(extensions) = pb.extensions { + state.remote_extensions = Some(extensions.into()); + } + Ok(()) } @@ -164,6 +220,17 @@ where pb.identity_sig = state.identity.signature.clone(); + // If this is the responder then send WebTransport certhashes to initiator, if any. + if state.io.is_responder() { + if let Some(ref certhashes) = state.responder_webtransport_certhashes { + let ext = pb + .extensions + .get_or_insert_with(proto::NoiseExtensions::default); + + ext.webtransport_certhashes = certhashes.iter().map(|hash| hash.to_bytes()).collect(); + } + } + let mut msg = Vec::with_capacity(pb.get_size()); let mut writer = Writer::new(&mut msg); diff --git a/transports/noise/src/lib.rs b/transports/noise/src/lib.rs index b7360c0c799..be73ea3f7f9 100644 --- a/transports/noise/src/lib.rs +++ b/transports/noise/src/lib.rs @@ -68,7 +68,11 @@ use futures::prelude::*; use libp2p_core::{InboundUpgrade, OutboundUpgrade, UpgradeInfo}; use libp2p_identity as identity; use libp2p_identity::PeerId; +use multiaddr::Protocol; +use multihash::Multihash; use snow::params::NoiseParams; +use std::collections::HashSet; +use std::fmt::Write; use std::pin::Pin; /// The configuration for the noise handshake. @@ -76,6 +80,7 @@ use std::pin::Pin; pub struct Config { dh_keys: AuthenticKeypair, params: NoiseParams, + webtransport_certhashes: Option>>, /// Prologue to use in the noise handshake. /// @@ -94,6 +99,7 @@ impl Config { Ok(Self { dh_keys: noise_keys, params: PARAMS_XX.clone(), + webtransport_certhashes: None, prologue: vec![], }) } @@ -101,7 +107,17 @@ impl Config { /// Set the noise prologue. pub fn with_prologue(mut self, prologue: Vec) -> Self { self.prologue = prologue; + self + } + /// Set WebTransport certhashes extension. + /// + /// In case of initiator, these certhashes will be used to validate the ones reported by + /// responder. + /// + /// In case of responder, these certhashes will be reported to initiator. + pub fn with_webtransport_certhashes(mut self, certhashes: HashSet>) -> Self { + self.webtransport_certhashes = Some(certhashes).filter(|h| !h.is_empty()); self } @@ -114,7 +130,13 @@ impl Config { ) .build_responder()?; - let state = State::new(socket, session, self.dh_keys.identity, None); + let state = State::new( + socket, + session, + self.dh_keys.identity, + None, + self.webtransport_certhashes, + ); Ok(state) } @@ -128,7 +150,13 @@ impl Config { ) .build_initiator()?; - let state = State::new(socket, session, self.dh_keys.identity, None); + let state = State::new( + socket, + session, + self.dh_keys.identity, + None, + self.webtransport_certhashes, + ); Ok(state) } @@ -213,8 +241,20 @@ pub enum Error { InvalidPayload(#[from] DecodeError), #[error(transparent)] SigningError(#[from] libp2p_identity::SigningError), + #[error("Expected WebTransport certhashes ({}) are not a subset of received ones ({})", certhashes_to_string(.0), certhashes_to_string(.1))] + UnknownWebTransportCerthashes(HashSet>, HashSet>), } #[derive(Debug, thiserror::Error)] #[error(transparent)] pub struct DecodeError(quick_protobuf::Error); + +fn certhashes_to_string(certhashes: &HashSet>) -> String { + let mut s = String::new(); + + for hash in certhashes { + write!(&mut s, "{}", Protocol::Certhash(*hash)).unwrap(); + } + + s +} diff --git a/transports/noise/tests/smoke.rs b/transports/noise/tests/smoke.rs index b862a944dfd..6d1723ec7d6 100644 --- a/transports/noise/tests/smoke.rs +++ b/transports/noise/tests/smoke.rs @@ -50,8 +50,8 @@ fn xx() { futures::executor::block_on(async move { let ( - (reported_client_id, mut client_session), - (reported_server_id, mut server_session), + (reported_client_id, mut server_session), + (reported_server_id, mut client_session), ) = futures::future::try_join( noise::Config::new(&server_id) .unwrap() diff --git a/transports/noise/tests/webtransport_certhashes.rs b/transports/noise/tests/webtransport_certhashes.rs new file mode 100644 index 00000000000..41341fe8655 --- /dev/null +++ b/transports/noise/tests/webtransport_certhashes.rs @@ -0,0 +1,152 @@ +use libp2p_core::{InboundUpgrade, OutboundUpgrade}; +use libp2p_identity as identity; +use libp2p_noise as noise; +use multihash::Multihash; +use std::collections::HashSet; + +const SHA_256_MH: u64 = 0x12; + +#[test] +fn webtransport_same_set_of_certhashes() { + let (certhash1, certhash2, _) = certhashes(); + + handshake_with_certhashes(vec![certhash1, certhash2], vec![certhash1, certhash2]).unwrap(); +} + +#[test] +fn webtransport_subset_of_certhashes() { + let (certhash1, certhash2, _) = certhashes(); + + handshake_with_certhashes(vec![certhash1], vec![certhash1, certhash2]).unwrap(); +} + +#[test] +fn webtransport_client_without_certhashes() { + let (certhash1, certhash2, _) = certhashes(); + + // Valid when server uses CA-signed TLS certificate. + handshake_with_certhashes(vec![], vec![certhash1, certhash2]).unwrap(); +} + +#[test] +fn webtransport_client_and_server_without_certhashes() { + // Valid when server uses CA-signed TLS certificate. + handshake_with_certhashes(vec![], vec![]).unwrap(); +} + +#[test] +fn webtransport_server_empty_certhashes() { + let (certhash1, certhash2, _) = certhashes(); + + // Invalid case, because a MITM attacker may strip certificates of the server. + let Err(noise::Error::UnknownWebTransportCerthashes(expected, received)) = + handshake_with_certhashes(vec![certhash1, certhash2], vec![]) else { + panic!("unexpected result"); + }; + + assert_eq!(expected, HashSet::from([certhash1, certhash2])); + assert_eq!(received, HashSet::new()); +} + +#[test] +fn webtransport_client_uninit_certhashes() { + let (certhash1, certhash2, _) = certhashes(); + + // Valid when server uses CA-signed TLS certificate. + handshake_with_certhashes(None, vec![certhash1, certhash2]).unwrap(); +} + +#[test] +fn webtransport_client_and_server_uninit_certhashes() { + // Valid when server uses CA-signed TLS certificate. + handshake_with_certhashes(None, None).unwrap(); +} + +#[test] +fn webtransport_server_uninit_certhashes() { + let (certhash1, certhash2, _) = certhashes(); + + // Invalid case, because a MITM attacker may strip certificates of the server. + let Err(noise::Error::UnknownWebTransportCerthashes(expected, received)) = + handshake_with_certhashes(vec![certhash1, certhash2], None) else { + panic!("unexpected result"); + }; + + assert_eq!(expected, HashSet::from([certhash1, certhash2])); + assert_eq!(received, HashSet::new()); +} + +#[test] +fn webtransport_different_server_certhashes() { + let (certhash1, certhash2, certhash3) = certhashes(); + + let Err(noise::Error::UnknownWebTransportCerthashes(expected, received)) = + handshake_with_certhashes(vec![certhash1, certhash3], vec![certhash1, certhash2]) else { + panic!("unexpected result"); + }; + + assert_eq!(expected, HashSet::from([certhash1, certhash3])); + assert_eq!(received, HashSet::from([certhash1, certhash2])); +} + +#[test] +fn webtransport_superset_of_certhashes() { + let (certhash1, certhash2, _) = certhashes(); + + let Err(noise::Error::UnknownWebTransportCerthashes(expected, received)) = + handshake_with_certhashes(vec![certhash1, certhash2], vec![certhash1]) else { + panic!("unexpected result"); + }; + + assert_eq!(expected, HashSet::from([certhash1, certhash2])); + assert_eq!(received, HashSet::from([certhash1])); +} + +fn certhashes() -> (Multihash<64>, Multihash<64>, Multihash<64>) { + ( + Multihash::wrap(SHA_256_MH, b"1").unwrap(), + Multihash::wrap(SHA_256_MH, b"2").unwrap(), + Multihash::wrap(SHA_256_MH, b"3").unwrap(), + ) +} + +// `valid_certhases` must be a strict subset of `server_certhashes`. +fn handshake_with_certhashes( + valid_certhases: impl Into>>>, + server_certhashes: impl Into>>>, +) -> Result<(), noise::Error> { + let valid_certhases = valid_certhases.into(); + let server_certhashes = server_certhashes.into(); + + let client_id = identity::Keypair::generate_ed25519(); + let server_id = identity::Keypair::generate_ed25519(); + + let (client, server) = futures_ringbuf::Endpoint::pair(100, 100); + + futures::executor::block_on(async move { + let mut client_config = noise::Config::new(&client_id)?; + let mut server_config = noise::Config::new(&server_id)?; + + if let Some(valid_certhases) = valid_certhases { + client_config = + client_config.with_webtransport_certhashes(valid_certhases.into_iter().collect()); + } + + if let Some(server_certhashes) = server_certhashes { + server_config = + server_config.with_webtransport_certhashes(server_certhashes.into_iter().collect()); + } + + let ((reported_client_id, mut _server_session), (reported_server_id, mut _client_session)) = + futures::future::try_join( + server_config.upgrade_inbound(server, ""), + client_config.upgrade_outbound(client, ""), + ) + .await?; + + assert_eq!(reported_client_id, client_id.public().to_peer_id()); + assert_eq!(reported_server_id, server_id.public().to_peer_id()); + + Ok(()) + }) +}