From 2b5feeeb783d2d08497a4c6f77bb42536ee4ef15 Mon Sep 17 00:00:00 2001 From: ljedrz Date: Tue, 28 Jul 2026 15:11:01 +0200 Subject: [PATCH 1/3] fix: harden the EventCodec's encoder Signed-off-by: ljedrz --- node/bft/events/src/helpers/codec.rs | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/node/bft/events/src/helpers/codec.rs b/node/bft/events/src/helpers/codec.rs index 341e9215df..ceba11c320 100644 --- a/node/bft/events/src/helpers/codec.rs +++ b/node/bft/events/src/helpers/codec.rs @@ -53,13 +53,17 @@ impl Encoder> for EventCodec { type Error = std::io::Error; fn encode(&mut self, event: Event, dst: &mut BytesMut) -> Result<(), Self::Error> { - // Serialize the payload directly into dst. + // Serialize the payload directly into dst, but only claim back what was appended here: dst + // may already hold frames that have not been written out yet - which is what happens when a + // sink is fed several events before being flushed - and folding those into this event's frame + // would produce a single frame that no decoder can make sense of. + let buffered = dst.len(); event .write_le(&mut dst.writer()) // This error should never happen, the conversion is for greater compatibility. .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "serialization error"))?; - let serialized_event = dst.split_to(dst.len()).freeze(); + let serialized_event = dst.split_off(buffered).freeze(); self.codec.encode(serialized_event, dst) } @@ -109,4 +113,24 @@ mod tests { fn event_roundtrip(#[strategy(any_event())] event: Event) { assert_roundtrip(event) } + + #[proptest] + fn events_encoded_into_a_shared_buffer_stay_separate( + #[strategy(any_event())] first: Event, + #[strategy(any_event())] second: Event, + ) { + // Encoding into a buffer that already holds a frame is what a sink does when it is fed + // several events before being flushed; each one must remain a frame of its own. + let mut codec: EventCodec = Default::default(); + let mut buffer = BytesMut::new(); + + codec.encode(first.clone(), &mut buffer).unwrap(); + codec.encode(second.clone(), &mut buffer).unwrap(); + + for expected in [first, second] { + let decoded = codec.decode(&mut buffer).unwrap().unwrap(); + assert_eq!(decoded.to_bytes_le().unwrap(), expected.to_bytes_le().unwrap()); + } + assert!(buffer.is_empty()); + } } From 7bb48bb9fb6fc77f8560e8751fc8d389549d1069 Mon Sep 17 00:00:00 2001 From: ljedrz Date: Tue, 28 Jul 2026 15:11:10 +0200 Subject: [PATCH 2/3] feat: introduce asymmetrical Noise-XX-based handshake Signed-off-by: ljedrz --- Cargo.toml | 3 + node/bft/events/src/helpers/codec.rs | 11 + node/bft/events/src/helpers/handshake.rs | 333 ++++++++++++ node/bft/events/src/helpers/mod.rs | 3 + node/bft/src/gateway.rs | 507 ++++++++++++++++-- node/bft/tests/gateway_noise.rs | 403 ++++++++++++++ node/network/Cargo.toml | 18 + node/network/src/lib.rs | 2 + node/network/src/noise.rs | 642 +++++++++++++++++++++++ 9 files changed, 1890 insertions(+), 32 deletions(-) create mode 100644 node/bft/events/src/helpers/handshake.rs create mode 100644 node/bft/tests/gateway_noise.rs create mode 100644 node/network/src/noise.rs diff --git a/Cargo.toml b/Cargo.toml index e14815a1d2..56f81e750e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -168,6 +168,9 @@ version = "0.13" [workspace.dependencies.smol_str] version = "=0.3.2" # Update after Rust >1.88 +[workspace.dependencies.snow] +version = "0.10" + [workspace.dependencies.tracing] version = "0.1" default-features = false diff --git a/node/bft/events/src/helpers/codec.rs b/node/bft/events/src/helpers/codec.rs index ceba11c320..e928795e09 100644 --- a/node/bft/events/src/helpers/codec.rs +++ b/node/bft/events/src/helpers/codec.rs @@ -22,6 +22,17 @@ use tokio_util::codec::{Decoder, Encoder, LengthDelimitedCodec}; use tracing::*; /// The maximum size of an event that can be transmitted during the handshake. +/// +/// This is sixteen times the 65535-byte ceiling the Noise specification puts on a single message, and +/// it is the largest buffer an unauthenticated peer can make a node commit to: `LengthDelimitedCodec` +/// reserves whatever length a frame's header declares as soon as it reads the header, so a peer that +/// declares a megabyte and then sends a single byte holds a megabyte for the whole handshake timeout. +/// +/// It is only still reachable because the gateway continues to accept the legacy handshake. The Noise +/// handshake frames its messages at `MAX_NOISE_MSG_LEN` - the specification's 65535 - and reads the +/// first of them before deriving any keys, so on that path the figure is already 64 KiB. Retiring the +/// legacy handshake leaves `EventCodec::handshake` without callers, at which point it and this +/// constant should go with it: that is what turns the 16x reduction into the only limit there is. const MAX_HANDSHAKE_SIZE: usize = 1024 * 1024; // 1 MiB /// The maximum size of an event that can be transmitted in the network. const MAX_EVENT_SIZE: usize = 256 * 1024 * 1024; // 256 MiB diff --git a/node/bft/events/src/helpers/handshake.rs b/node/bft/events/src/helpers/handshake.rs new file mode 100644 index 0000000000..d8675e16a7 --- /dev/null +++ b/node/bft/events/src/helpers/handshake.rs @@ -0,0 +1,333 @@ +// Copyright (c) 2019-2026 Provable Inc. +// This file is part of the snarkOS library. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at: + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! The payloads carried by the messages of the gateway's Noise handshake. +//! +//! Unlike the legacy [`ChallengeRequest`](crate::ChallengeRequest) and +//! [`ChallengeResponse`](crate::ChallengeResponse) events, these are not part of the [`Event`] +//! enum: they are only ever exchanged inside a Noise message, never over an established connection. +//! +//! The four messages are: +//! +//! 1. `->` [`HandshakeHint`] - cleartext, so the responder can bail out before doing any work. +//! 2. `<-` [`PeerInfo`] - the responder's metadata, deliberately without a signature. +//! 3. `->` [`InitiatorInfo`] - the initiator's metadata *and* the proof of its identity. +//! 4. `<-` [`ResponderProof`] - the responder's verdict, and its own proof if the initiator checked +//! out. This is the first transport message, the pattern having ended with the third. +//! +//! # Important: how the initiator's metadata is bound to its signature +//! +//! The initiator signs the handshake hash as it stands after message 2. Its metadata is tied to that +//! signature by one of two mechanisms, depending on which message carries it: +//! +//! - Messages 1 and 2 are folded into the hash - Noise mixes a message's payload into `h` whether or +//! not a key has been established yet, so even the cleartext [`HandshakeHint`] is covered by the +//! signature. Tampering with it in flight makes the two sides derive different hashes, and the +//! handshake fails. +//! - Message 3 is *not* covered by the hash the initiator signed, and is bound instead by its AEAD +//! tag: only the two endpoints can derive the key, so the party that produced the signature is +//! necessarily the party that sent [`InitiatorInfo`] alongside it. +//! +//! Either placement is sound, so metadata may be moved into the hint if there is a reason to act on +//! it earlier. What must not change is that anything the responder acts on ends up covered by one of +//! the two: metadata reaching it unencrypted *after* message 2 would be bound by neither. +//! +//! The separate reason the responder re-checks the hint against [`InitiatorInfo`] is a +//! self-inconsistent initiator, not tampering. Nothing stops a peer from claiming one value in the +//! hint, having the responder run its early checks against it, and then authenticating a different +//! one - so every field duplicated between the two must be compared, and the connection rejected +//! when they disagree. + +use crate::{Disconnect, DisconnectReason, Event}; +use snarkvm::{ + console::prelude::{FromBytes, Network, Read, ToBytes, Write, io_error}, + ledger::narwhal::Data, + prelude::{Address, Field, Signature}, +}; + +use std::io::Result as IoResult; + +/// The domain separator for the signatures binding an Aleo address to a gateway Noise session. +pub const HANDSHAKE_DOMAIN: &[u8] = b"snarkos-bft-handshake-v1"; + +/// Constant for an unknown commit hash. +const UNKNOWN_COMMIT_HASH: [u8; 40] = [b'?'; 40]; + +/// The cleartext payload of the first handshake message. +/// +/// It is neither encrypted nor authenticated, so it must be treated as a claim rather than a fact. +/// It exists so that the responder can turn away peers it would never accept - self-connects, +/// outdated versions, untrusted or non-committee addresses, peers it is already connected to - +/// before performing a single Diffie-Hellman operation. Every field reappears in [`InitiatorInfo`], +/// where it is authenticated, and the responder must reject the connection if the two disagree; +/// otherwise this message would be a way to be checked as one peer and admitted as another. +/// +/// Disclosing the Aleo address here costs little: committee membership is public on-chain, the +/// responder discloses its own address to an unauthenticated peer in the second message anyway, and +/// the legacy handshake sends it in the clear. What it buys is the committee check, which is the one +/// cheap test that a peer cannot satisfy by guessing. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct HandshakeHint { + pub version: u32, + pub listener_port: u16, + pub address: Address, +} + +impl ToBytes for HandshakeHint { + fn write_le(&self, mut writer: W) -> IoResult<()> { + self.version.write_le(&mut writer)?; + self.listener_port.write_le(&mut writer)?; + self.address.write_le(&mut writer) + } +} + +impl FromBytes for HandshakeHint { + fn read_le(mut reader: R) -> IoResult { + let version = u32::read_le(&mut reader)?; + let listener_port = u16::read_le(&mut reader)?; + let address = Address::::read_le(&mut reader)?; + + Ok(Self { version, listener_port, address }) + } +} + +/// What a party discloses about itself during the handshake. +/// +/// This is the payload of the second message on its own, and part of the payload of the third; it +/// is also what a completed handshake hands back to the gateway. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PeerInfo { + pub version: u32, + pub listener_port: u16, + pub address: Address, + pub restrictions_id: Field, + pub snarkos_sha: Option<[u8; 40]>, +} + +impl PeerInfo { + pub fn new( + listener_port: u16, + address: Address, + restrictions_id: Field, + snarkos_sha: Option<[u8; 40]>, + ) -> Self { + Self { version: Event::::VERSION, listener_port, address, restrictions_id, snarkos_sha } + } +} + +impl ToBytes for PeerInfo { + fn write_le(&self, mut writer: W) -> IoResult<()> { + self.version.write_le(&mut writer)?; + self.listener_port.write_le(&mut writer)?; + self.address.write_le(&mut writer)?; + self.restrictions_id.write_le(&mut writer)?; + // Serialize `None` as a constant. + self.snarkos_sha.unwrap_or(UNKNOWN_COMMIT_HASH).write_le(&mut writer) + } +} + +impl FromBytes for PeerInfo { + fn read_le(mut reader: R) -> IoResult { + let version = u32::read_le(&mut reader)?; + let listener_port = u16::read_le(&mut reader)?; + let address = Address::::read_le(&mut reader)?; + let restrictions_id = Field::read_le(&mut reader)?; + // Unlike the legacy `ChallengeRequest`, a missing SHA is an error rather than a `None`: the + // payload is delimited by the Noise message, so a short read is a protocol violation. + let snarkos_sha = <[u8; 40]>::read_le(&mut reader)?; + let snarkos_sha = if snarkos_sha == UNKNOWN_COMMIT_HASH { None } else { Some(snarkos_sha) }; + + Ok(Self { version, listener_port, address, restrictions_id, snarkos_sha }) + } +} + +/// The payload of the third handshake message: the initiator's metadata, plus the signature that +/// binds its Aleo address to this session. +/// +/// The initiator signs first by design. Verifying the signature is the most expensive thing the +/// responder does, and it only reaches that point once every cheap check against `info` has passed. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InitiatorInfo { + pub info: PeerInfo, + pub signature: Data>, +} + +impl ToBytes for InitiatorInfo { + fn write_le(&self, mut writer: W) -> IoResult<()> { + self.info.write_le(&mut writer)?; + self.signature.write_le(&mut writer) + } +} + +impl FromBytes for InitiatorInfo { + fn read_le(mut reader: R) -> IoResult { + let info = PeerInfo::read_le(&mut reader)?; + let signature = Data::read_le(&mut reader)?; + + Ok(Self { info, signature }) + } +} + +/// The payload of the fourth handshake message: the responder's verdict. +/// +/// The responder's proof is sent separately from its [`PeerInfo`], and last, so that it never signs +/// anything for a peer that has not already authenticated itself. If the initiator was turned away +/// instead, this message carries the reason: the initiator dialed in and has no other way to find +/// out, whereas a responder that gets rejected is owed nothing and is simply dropped. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ResponderProof { + /// The initiator was accepted; the signature binds the responder's Aleo address to the session. + Accepted { signature: Data> }, + /// The initiator was rejected. + Rejected { reason: DisconnectReason }, +} + +impl ToBytes for ResponderProof { + fn write_le(&self, mut writer: W) -> IoResult<()> { + match self { + Self::Accepted { signature } => { + 0u8.write_le(&mut writer)?; + signature.write_le(&mut writer) + } + Self::Rejected { reason } => { + 1u8.write_le(&mut writer)?; + // Reuse the event's encoding of the reason, which rejects the unknown variant. + Disconnect::from(*reason).write_le(&mut writer) + } + } + } +} + +impl FromBytes for ResponderProof { + fn read_le(mut reader: R) -> IoResult { + match u8::read_le(&mut reader)? { + 0 => Ok(Self::Accepted { signature: Data::read_le(&mut reader)? }), + 1 => Ok(Self::Rejected { reason: Disconnect::read_le(&mut reader)?.reason }), + variant => Err(io_error(format!("Invalid responder proof variant ({variant})"))), + } + } +} + +#[cfg(test)] +pub mod prop_tests { + use super::*; + use crate::{challenge_request::prop_tests::any_valid_address, challenge_response::prop_tests::any_signature}; + + use snarkvm::{ + console::prelude::{FromBytes, ToBytes}, + prelude::{Address, Field, TestRng, Uniform}, + }; + + use bytes::{Buf, BufMut, BytesMut}; + use proptest::{ + collection, + prelude::{BoxedStrategy, Strategy, any}, + }; + use test_strategy::proptest; + + type CurrentNetwork = snarkvm::prelude::MainnetV0; + + pub fn any_peer_info() -> BoxedStrategy> { + (any_valid_address(), any::(), any::(), any::(), collection::vec(0u8..=127, 40)) + .prop_map(|(address, version, listener_port, seed, sha)| { + let sha: [u8; 40] = sha.try_into().unwrap(); + let snarkos_sha = if sha == UNKNOWN_COMMIT_HASH { None } else { Some(sha) }; + PeerInfo { + version, + listener_port, + address, + restrictions_id: Field::rand(&mut TestRng::fixed(seed)), + snarkos_sha, + } + }) + .boxed() + } + + #[proptest] + fn handshake_hint_serialize_deserialize( + #[strategy(any::<(u32, u16)>())] fields: (u32, u16), + #[strategy(any_valid_address())] address: Address, + ) { + let original = HandshakeHint { version: fields.0, listener_port: fields.1, address }; + + let mut buf = BytesMut::default().writer(); + original.write_le(&mut buf).unwrap(); + + let deserialized = HandshakeHint::::read_le(buf.into_inner().reader()).unwrap(); + assert_eq!(original, deserialized); + } + + #[proptest] + fn peer_info_serialize_deserialize(#[strategy(any_peer_info())] original: PeerInfo) { + let mut buf = BytesMut::default().writer(); + original.write_le(&mut buf).unwrap(); + + let deserialized = PeerInfo::read_le(buf.into_inner().reader()).unwrap(); + assert_eq!(original, deserialized); + } + + #[proptest] + fn initiator_info_serialize_deserialize( + #[strategy(any_peer_info())] info: PeerInfo, + #[strategy(any_signature())] signature: snarkvm::prelude::Signature, + ) { + let original = InitiatorInfo { info, signature: Data::Object(signature) }; + + let mut buf = BytesMut::default().writer(); + original.write_le(&mut buf).unwrap(); + + let deserialized = InitiatorInfo::::read_le(buf.into_inner().reader()).unwrap(); + assert_eq!(original.info, deserialized.info); + assert_eq!( + original.signature.deserialize_blocking().unwrap(), + deserialized.signature.deserialize_blocking().unwrap() + ); + } + + #[proptest] + fn accepted_responder_proof_serialize_deserialize( + #[strategy(any_signature())] signature: snarkvm::prelude::Signature, + ) { + let original = ResponderProof::::Accepted { signature: Data::Object(signature) }; + + let mut buf = BytesMut::default().writer(); + original.write_le(&mut buf).unwrap(); + + let deserialized = ResponderProof::::read_le(buf.into_inner().reader()).unwrap(); + let (ResponderProof::Accepted { signature: original }, ResponderProof::Accepted { signature: deserialized }) = + (original, deserialized) + else { + panic!("expected an accepted proof"); + }; + assert_eq!(original.deserialize_blocking().unwrap(), deserialized.deserialize_blocking().unwrap()); + } + + #[test] + fn rejected_responder_proof_serialize_deserialize() { + for reason in [ + DisconnectReason::OutdatedClientVersion, + DisconnectReason::UnauthorizedValidator, + DisconnectReason::InvalidChallengeResponse, + ] { + let original = ResponderProof::::Rejected { reason }; + + let mut buf = BytesMut::default().writer(); + original.write_le(&mut buf).unwrap(); + + let deserialized = ResponderProof::::read_le(buf.into_inner().reader()).unwrap(); + assert_eq!(original, deserialized); + } + } +} diff --git a/node/bft/events/src/helpers/mod.rs b/node/bft/events/src/helpers/mod.rs index b006f1511d..9b38ec61d9 100644 --- a/node/bft/events/src/helpers/mod.rs +++ b/node/bft/events/src/helpers/mod.rs @@ -15,3 +15,6 @@ mod codec; pub use codec::*; + +mod handshake; +pub use handshake::*; diff --git a/node/bft/src/gateway.rs b/node/bft/src/gateway.rs index b9aa6cfc61..d58a600161 100644 --- a/node/bft/src/gateway.rs +++ b/node/bft/src/gateway.rs @@ -36,6 +36,11 @@ use snarkos_node_bft_events::{ DataBlocks, Event, EventTrait, + HANDSHAKE_DOMAIN, + HandshakeHint, + InitiatorInfo, + PeerInfo, + ResponderProof, TransmissionRequest, TransmissionResponse, ValidatorsRequest, @@ -51,6 +56,16 @@ use snarkos_node_network::{ bootstrap_peers, get_repo_commit_hash, log_repo_sha_comparison, + noise::{ + HandshakeProtocol, + NoiseSession, + PendingSession, + Role, + binding_message, + detect_handshake_protocol, + prepare_framed, + write_noise_magic, + }, shorten_snarkos_sha, }; use snarkos_node_sync::{MAX_BLOCKS_BEHIND, communication_service::CommunicationService}; @@ -71,7 +86,7 @@ use snarkvm::{ committee::Committee, narwhal::{BatchHeader, Data}, }, - prelude::{Address, Field}, + prelude::{Address, Field, Signature}, utilities::flatten_error, }; @@ -118,6 +133,23 @@ const CONNECTION_ATTEMPTS_SINCE_SECS: i64 = 10; /// The amount of time an IP address is prohibited from connecting. const IP_BAN_TIME_IN_SECS: u64 = 300; +/// The block height at which this node starts *initiating* Noise handshakes, if one is scheduled. +/// +/// Only the initiator's choice is gated: a responder accepts either protocol as soon as this code +/// ships, which is what allows validators to be upgraded one at a time. `None` means no switchover +/// has been scheduled yet - except in development, where the Noise path is always taken so that +/// devnets exercise it, and in tests, which pin the choice explicitly. +/// +/// Setting this height is not the end of the migration, only the middle of it. For as long as the +/// responder still accepts the legacy handshake, two things remain reachable through it: the relay +/// that the handshake binding exists to prevent, and the legacy handshake codec's 1 MiB frame limit - +/// sixteen times what a Noise message may be, on a buffer an unauthenticated peer gets to size. Both +/// are collected by the same step, which is dropping the legacy path rather than merely preferring +/// the new one. Note also that the same relay is reachable through the router's handshake, which +/// signs a byte-identical message with the same account key, so the gateway cannot be the last part +/// of this to be converted. +const NOISE_HANDSHAKE_ACTIVATION_HEIGHT: Option = None; + /// Part of the Gateway API that deals with networking. /// This is a separate trait to allow for easier testing/mocking. #[async_trait] @@ -170,6 +202,14 @@ pub struct InnerGateway { trusted_peers_only: bool, /// The development mode. dev: Option, + /// Pins which handshake this node offers when it dials, bypassing + /// [`NOISE_HANDSHAKE_ACTIVATION_HEIGHT`]. + /// + /// Tests default to the Noise handshake, since that is the one under test, and set this to + /// `false` to cover the other half of the transition: a converted node has to keep talking to + /// unconverted ones, which means the legacy path must stay exercised for as long as it exists. + #[cfg(any(test, feature = "test"))] + initiates_noise_handshake: std::sync::atomic::AtomicBool, } impl PeerPoolHandling for Gateway { @@ -259,6 +299,9 @@ impl Gateway { node_data_dir, trusted_peers_only, dev, + // See the field's documentation for why the tests start out on the Noise handshake. + #[cfg(any(test, feature = "test"))] + initiates_noise_handshake: std::sync::atomic::AtomicBool::new(true), }))) } @@ -1485,16 +1528,32 @@ impl Handshake for Gateway { let restrictions_id = self.ledger.latest_restrictions_id(); // Perform the handshake; we pass on a mutable reference to peer_ip in case the process is broken at any point in time. + // + // The initiator picks the handshake protocol, gated on the block height so that validators + // can be upgraded one at a time; the responder goes along with whichever one it is offered. let handshake_result = if peer_side == ConnectionSide::Responder { - self.handshake_inner_initiator(peer_addr, restrictions_id, stream).await + if self.initiates_noise_handshake(peer_addr) { + write_noise_magic(stream).await?; + self.handshake_inner_initiator_noise(peer_addr, restrictions_id, stream).await + } else { + self.handshake_inner_initiator(peer_addr, restrictions_id, stream).await + } } else { - self.handshake_inner_responder(peer_addr, &mut listener_addr, restrictions_id, stream).await + match detect_handshake_protocol(stream).await? { + (HandshakeProtocol::Noise, _) => { + self.handshake_inner_responder_noise(peer_addr, &mut listener_addr, restrictions_id, stream).await + } + (HandshakeProtocol::Legacy, prefix) => { + self.handshake_inner_responder(peer_addr, &mut listener_addr, restrictions_id, stream, &prefix) + .await + } + } }; // Register the peer, or roll it back, if the handshake got far enough to learn its listening // address. match (&handshake_result, listener_addr) { - (Ok(cr), Some(addr)) => { + (Ok(peer_info), Some(addr)) => { let node_type = if bootstrap_peers::(self.is_dev()).contains(&addr) { NodeType::BootstrapClient } else { @@ -1512,21 +1571,21 @@ impl Handshake for Gateway { if let Peer::Candidate(peer) = peer && let Some(old_aleo_addr) = peer.last_known_aleo_addr { - old_aleo_addr != cr.address || peer.listener_addr == addr + old_aleo_addr != peer_info.address || peer.listener_addr == addr } else { true } }); if let Some(peer) = peer_pool.get_mut(&addr) { - self.resolver.write().insert_peer(addr, peer_addr, Some(cr.address)); + self.resolver.write().insert_peer(addr, peer_addr, Some(peer_info.address)); peer.upgrade_to_connected( peer_addr, - cr.listener_port, - cr.address, + peer_info.listener_port, + peer_info.address, node_type, - cr.version, - cr.snarkos_sha, + peer_info.version, + peer_info.snarkos_sha, ConnectionMode::Gateway, ); } @@ -1540,9 +1599,18 @@ impl Handshake for Gateway { } } } + // Neither handshake can succeed before it has learned the peer's listening address, so + // this is unreachable; if it ever happened, the connection would go live with neither a + // peer pool nor a resolver entry, and every event on it would be discarded as coming + // from an unknown peer. Refuse it instead of leaving it in that state. + (Ok(_), None) => { + return Err(ConnectError::other(format!( + "the handshake with '{peer_addr}' succeeded without a listening address" + ))); + } // The handshake failed before the peer's listening address was known, so there is nothing // in the pool to roll back. - (_, None) => {} + (Err(_), None) => {} } // Abort the connection on failure whether or not the peer reached the pool. @@ -1590,14 +1658,335 @@ async fn send_event( framed.send(event).await } +/// Serializes a handshake payload for transmission inside a Noise message. +fn encode_payload(payload: &T) -> Result, ConnectError> { + payload.to_bytes_le().map_err(ConnectError::other) +} + +/// Deserializes a handshake payload received inside a Noise message. +fn decode_payload(peer_addr: SocketAddr, bytes: &[u8]) -> Result { + T::from_bytes_le(bytes) + .map_err(|err| ConnectError::other(format!("'{peer_addr}' sent a malformed handshake payload: {err}"))) +} + +/// Verifies a peer's proof that it owns the Aleo address it claims: its signature over the binding +/// message for this Noise session. +/// +/// This is by far the most expensive step of the handshake, which is why both sides only reach it +/// once every cheap check has already passed. +#[must_use] +async fn verify_binding_signature( + peer_addr: SocketAddr, + signature: Data>, + address: Address, + binding: &[u8], +) -> Option { + // Perform the deferred non-blocking deserialization of the signature. + let Ok(signature) = spawn_blocking!(signature.deserialize_blocking()) else { + warn!("{CONTEXT} Handshake with '{peer_addr}' failed (cannot deserialize the signature)"); + return Some(DisconnectReason::InvalidChallengeResponse); + }; + // Verify the signature. + if !signature.verify_bytes(&address, binding) { + warn!("{CONTEXT} Handshake with '{peer_addr}' failed (invalid signature)"); + return Some(DisconnectReason::InvalidChallengeResponse); + } + + None +} + +/// Concludes a Noise handshake, handing the stream back to the connection. +/// +/// The stream deliberately goes back unframed. [`Reading`] builds a codec of its own, capped at the +/// 256 MiB an event may be rather than the 64 KiB a Noise message may be, and takes full +/// responsibility for the stream from here on. Handing it a bare stream is only sound because the +/// session reads its messages exactly: anything the peer pipelined behind the last handshake message +/// is still on the socket rather than in a buffer about to be dropped. +fn finish_noise_handshake(noise: NoiseSession<&mut TcpStream>) { + // Note: the transport keys are discarded here, leaving the resulting connection unencrypted. + let _stream = noise.into_inner(); +} + +/// Concludes a legacy handshake, dropping its codec along with anything the peer had pipelined behind +/// the last handshake message. +/// +/// Unlike the Noise handshake, this one reads through a buffering codec, so it can pull bytes off the +/// socket that belong to the events which follow - and those are lost here, leaving the event codec to +/// start in the middle of a frame. That has always been the case, and carrying them across would mean +/// wrapping the stream for a protocol that is being retired, so it is logged rather than fixed. +fn note_legacy_handshake_end(framed: Framed<&mut TcpStream, EventCodec>, peer_addr: SocketAddr) { + let leftover = framed.into_parts().read_buf; + + if !leftover.is_empty() { + debug!("{CONTEXT} Discarding {} bytes '{peer_addr}' sent before the handshake was over", leftover.len()); + } +} + +/// Distills a legacy challenge request into the protocol-agnostic peer information. +/// +/// The peer's restrictions ID is not part of its challenge request, but by the time this is called +/// `verify_challenge_response` has established that it matches the one passed in. +fn peer_info_from_challenge_request( + request: ChallengeRequest, + restrictions_id: Field, +) -> PeerInfo { + let ChallengeRequest { version, listener_port, address, nonce: _, snarkos_sha } = request; + PeerInfo { version, listener_port, address, restrictions_id, snarkos_sha } +} + impl Gateway { - /// The connection initiator side of the handshake. + /// Returns `true` if this node should offer the Noise handshake when it dials the given peer. + fn initiates_noise_handshake(&self, peer_addr: SocketAddr) -> bool { + // The bootstrap clients have not been converted yet and only understand the legacy + // handshake, so they keep getting it until they have been. + if bootstrap_peers::(self.is_dev()).contains(&peer_addr) { + return false; + } + + // Tests pin the choice, so that both sides of the transition can be covered. + if let Some(initiates) = self.pinned_handshake_protocol() { + return initiates; + } + + // Development nodes always take the new path, so that devnets exercise it. + self.is_dev() + || NOISE_HANDSHAKE_ACTIVATION_HEIGHT.is_some_and(|height| self.ledger.latest_block_height() >= height) + } + + /// The pinned choice of handshake protocol; always `None` outside tests. + #[cfg(not(any(test, feature = "test")))] + fn pinned_handshake_protocol(&self) -> Option { + None + } + + /// The pinned choice of handshake protocol; see `InnerGateway::initiates_noise_handshake`. + #[cfg(any(test, feature = "test"))] + fn pinned_handshake_protocol(&self) -> Option { + Some(self.initiates_noise_handshake.load(std::sync::atomic::Ordering::Relaxed)) + } + + /// Pins whether this node offers the Noise handshake when it dials, regardless of the activation + /// height. + /// + /// This exists so that tests can cover the transition, during which a converted node still has + /// to be able to shake hands with unconverted ones. + #[cfg(any(test, feature = "test"))] + pub fn set_initiates_noise_handshake(&self, initiates: bool) { + self.initiates_noise_handshake.store(initiates, std::sync::atomic::Ordering::Relaxed); + } + + /// Returns the snarkOS commit hash to disclose to a peer, if any. + fn snarkos_sha(&self) -> Option<[u8; 40]> { + let current_block_height = self.ledger.latest_block_height(); + let consensus_version = N::CONSENSUS_VERSION(current_block_height).unwrap(); + match (self.is_dev(), consensus_version >= ConsensusVersion::V12, get_repo_commit_hash()) { + (true, _, Some(sha)) => Some(sha), + (_, true, Some(sha)) => Some(sha), + _ => None, + } + } + + /// The connection initiator side of the Noise handshake. + /// + /// The initiator does the expensive work first: it signs before the responder has committed to + /// anything, and only learns whether it was accepted when the fourth message arrives. See + /// [`Gateway::handshake_inner_responder_noise`] for the other half of that bargain. + async fn handshake_inner_initiator_noise<'a>( + &'a self, + peer_addr: SocketAddr, + restrictions_id: Field, + stream: &'a mut TcpStream, + ) -> Result, ConnectError> { + // Introduce the peer into the peer pool. + self.add_connecting_peer(peer_addr)?; + + let mut noise = NoiseSession::new(stream, Role::Initiator)?; + let our_info = + PeerInfo::new(self.local_ip().port(), self.account.address(), restrictions_id, self.snarkos_sha()); + + /* Message 1: announce ourselves in the clear, so the responder can turn us away cheaply. */ + + let hint = HandshakeHint { + version: our_info.version, + listener_port: our_info.listener_port, + address: our_info.address, + }; + noise.send(&encode_payload(&hint)?).await?; + + /* Message 2: receive the responder's metadata, which deliberately carries no signature. */ + + let peer_info: PeerInfo = decode_payload(peer_addr, &noise.recv().await?)?; + + // The handshake hash at this point already commits to both ephemeral keys, the responder's + // static key and every payload exchanged so far, so a signature over it is only valid for + // this session with this responder, and cannot be relayed into another one. + let binding = binding_message(HANDSHAKE_DOMAIN, Role::Initiator, &noise.handshake_hash()?); + + // Check the peer over before signing anything for it. + if let Some(reason) = self.verify_peer_info(peer_addr, &peer_info, restrictions_id) { + return Err(reason.into_connect_error(peer_addr)); + } + + /* Message 3: disclose ourselves and prove that we own the Aleo address we claim. */ + + let Ok(our_signature) = self.account.sign_bytes(&binding, &mut rand::rng()) else { + return Err(ConnectError::other(anyhow!("Failed to sign the handshake binding"))); + }; + let our_message = InitiatorInfo { info: our_info, signature: Data::Object(our_signature) }; + noise.send(&encode_payload(&our_message)?).await?; + + // Capture the binding for the responder's proof before the hash becomes unavailable; it + // additionally commits to our static key and to the message we have just sent. + let peer_binding = binding_message(HANDSHAKE_DOMAIN, Role::Responder, &noise.handshake_hash()?); + + /* Message 4: receive the responder's verdict. */ + + let mut noise = noise.into_transport_mode()?; + let verdict = decode_payload::>(peer_addr, &noise.recv().await?)?; + + // The pattern is over, so the stream goes back to the connection. The responder considers the + // handshake done the moment it sent this message and is free to start sending events while we + // are still verifying below; those bytes wait on the socket for the event codec. + finish_noise_handshake(noise); + + let peer_signature = match verdict { + ResponderProof::Accepted { signature } => signature, + ResponderProof::Rejected { reason } => { + warn!("{CONTEXT} '{peer_addr}' rejected the handshake with reason \"{reason}\""); + return Err(reason.into_connect_error(peer_addr)); + } + }; + + if let Some(reason) = + verify_binding_signature(peer_addr, peer_signature, peer_info.address, &peer_binding).await + { + return Err(reason.into_connect_error(peer_addr)); + } + + Ok(peer_info) + } + + /// The connection responder side of the Noise handshake. + /// + /// Every expensive operation is deferred for as long as the protocol allows: the responder + /// verifies a signature only once the initiator's authenticated metadata has passed all of the + /// cheap checks, and produces one only once that verification has succeeded. + /// + /// The legacy handshake also runs its cheap checks first, so a peer that fails one of those was + /// never expensive under either protocol. What changes is the price of *claiming* an identity + /// that passes them - committee membership is public, so anyone can claim it. Under the legacy + /// handshake that claim alone bought a signature from this node, for the cost of one packet. + /// Here it buys a handful of Diffie-Hellman operations and a signature verification; extracting + /// a signature requires actually holding the committee key, and even reaching the verification + /// requires completing the pattern, which a peer that cannot receive our reply - a spoofed + /// source address - cannot do. + async fn handshake_inner_responder_noise<'a>( + &'a self, + peer_addr: SocketAddr, + peer_ip: &mut Option, + restrictions_id: Field, + stream: &'a mut TcpStream, + ) -> Result, ConnectError> { + /* Message 1: the peer's cleartext hint. Everything it claims is re-checked in message 3. */ + + // The first message is read without deriving any keys, so that everything below costs the + // responder nothing but lookups until it has decided the peer is worth talking to. + let pending = PendingSession::accept(stream).await?; + let hint: HandshakeHint = decode_payload(peer_addr, pending.first_payload()?)?; + let listener_addr = SocketAddr::new(peer_addr.ip(), hint.listener_port); + + // Turn the peer away before performing any Diffie-Hellman if we already know we do not want + // it. Nothing here is trustworthy yet - message 3 runs all of it again against the + // authenticated copy - but every one of these is a peer that would be refused either way, + // and the committee check in particular is one an attacker cannot talk its way past. + if let Err(reason) = self.ensure_peer_is_allowed(listener_addr) { + return Err(reason.into_connect_error(peer_addr)); + } + if let Some(reason) = self.verify_peer_identity(peer_addr, hint.version, hint.listener_port, hint.address) { + return Err(reason.into_connect_error(peer_addr)); + } + + // The peer pool is the only thing mutated here, so it goes last: there is no point admitting + // a peer that one of the checks above was about to turn away. Recording the listening address + // only once that has succeeded also stops a peer claiming somebody else's port from getting + // their entry downgraded by failing this handshake. + self.add_connecting_peer(listener_addr)?; + *peer_ip = Some(listener_addr); + + /* Message 2: disclose ourselves, but do not sign anything yet. */ + + // The peer has passed every free check, so it is now worth deriving keys for. + let mut noise = pending.into_session()?; + + let our_info = + PeerInfo::new(self.local_ip().port(), self.account.address(), restrictions_id, self.snarkos_sha()); + noise.send(&encode_payload(&our_info)?).await?; + + // The binding the initiator is expected to have signed. + let peer_binding = binding_message(HANDSHAKE_DOMAIN, Role::Initiator, &noise.handshake_hash()?); + + /* Message 3: the peer's authenticated metadata and its proof of identity. */ + + let InitiatorInfo { info: peer_info, signature: peer_signature } = + decode_payload::>(peer_addr, &noise.recv().await?)?; + + // Our own binding additionally commits to the peer's static key and to the message it has + // just sent, so it can only be captured after that message has been processed. + let binding = binding_message(HANDSHAKE_DOMAIN, Role::Responder, &noise.handshake_hash()?); + let mut noise = noise.into_transport_mode()?; + + // The cleartext hint is a claim, not a fact: reject the peer if it does not match what it + // has now authenticated, as otherwise the hint would be a way to bypass the checks above. + if (hint.version, hint.listener_port, hint.address) + != (peer_info.version, peer_info.listener_port, peer_info.address) + { + warn!("{CONTEXT} Handshake with '{peer_addr}' failed (the handshake hint was contradicted)"); + return self.reject_noise_handshake(peer_addr, noise, DisconnectReason::ProtocolViolation).await; + } + + // Everything below is a lookup or a comparison; only once all of it passes is the peer + // worth the cost of a signature verification. + if let Some(reason) = self.verify_peer_info(peer_addr, &peer_info, restrictions_id) { + return self.reject_noise_handshake(peer_addr, noise, reason).await; + } + + /* Message 4: having checked the peer over, verify its proof and produce our own. */ + + if let Some(reason) = + verify_binding_signature(peer_addr, peer_signature, peer_info.address, &peer_binding).await + { + return self.reject_noise_handshake(peer_addr, noise, reason).await; + } + + let Ok(our_signature) = self.account.sign_bytes(&binding, &mut rand::rng()) else { + return Err(ConnectError::other(anyhow!("Failed to sign the handshake binding"))); + }; + noise.send(&encode_payload(&ResponderProof::Accepted { signature: Data::Object(our_signature) })?).await?; + + finish_noise_handshake(noise); + + Ok(peer_info) + } + + /// Tells the initiator why it was turned away, and fails the handshake with that reason. + async fn reject_noise_handshake( + &self, + peer_addr: SocketAddr, + mut noise: NoiseSession<&mut TcpStream>, + reason: DisconnectReason, + ) -> Result, ConnectError> { + noise.send(&encode_payload(&ResponderProof::::Rejected { reason })?).await?; + + Err(reason.into_connect_error(peer_addr)) + } + + /// The connection initiator side of the legacy handshake. async fn handshake_inner_initiator<'a>( &'a self, peer_addr: SocketAddr, restrictions_id: Field, stream: &'a mut TcpStream, - ) -> Result, ConnectError> { + ) -> Result, ConnectError> { // Introduce the peer into the peer pool. self.add_connecting_peer(peer_addr)?; @@ -1609,13 +1998,7 @@ impl Gateway { // Sample a random nonce. let our_nonce: u64 = rand::random(); // Determine the snarkOS SHA to send to the peer. - let current_block_height = self.ledger.latest_block_height(); - let consensus_version = N::CONSENSUS_VERSION(current_block_height).unwrap(); - let snarkos_sha = match (self.is_dev(), consensus_version >= ConsensusVersion::V12, get_repo_commit_hash()) { - (true, _, Some(sha)) => Some(sha), - (_, true, Some(sha)) => Some(sha), - _ => None, - }; + let snarkos_sha = self.snarkos_sha(); // Send a challenge request to the peer. let our_request = ChallengeRequest::new(self.local_ip().port(), self.account.address(), our_nonce, snarkos_sha); send_event(&mut framed, peer_addr, Event::ChallengeRequest(our_request)).await?; @@ -1655,19 +2038,25 @@ impl Gateway { ChallengeResponse { restrictions_id, signature: Data::Object(our_signature), nonce: response_nonce }; send_event(&mut framed, peer_addr, Event::ChallengeResponse(our_response)).await?; - Ok(peer_request) + note_legacy_handshake_end(framed, peer_addr); + + Ok(peer_info_from_challenge_request(peer_request, restrictions_id)) } - /// The connection responder side of the handshake. + /// The connection responder side of the legacy handshake. + /// + /// `prefix` holds the bytes that were consumed from the stream while determining which + /// handshake protocol the peer speaks; they are the beginning of its first frame. async fn handshake_inner_responder<'a>( &'a self, peer_addr: SocketAddr, peer_ip: &mut Option, restrictions_id: Field, stream: &'a mut TcpStream, - ) -> Result, ConnectError> { + prefix: &[u8], + ) -> Result, ConnectError> { // Construct the stream. - let mut framed = Framed::new(stream, EventCodec::::handshake()); + let mut framed = prepare_framed(stream, EventCodec::::handshake(), prefix); /* Step 1: Receive the challenge request. */ @@ -1714,13 +2103,7 @@ impl Gateway { // Sample a random nonce. let our_nonce: u64 = rand::random(); // Determine the snarkOS SHA to send to the peer. - let current_block_height = self.ledger.latest_block_height(); - let consensus_version = N::CONSENSUS_VERSION(current_block_height).unwrap(); - let snarkos_sha = match (self.is_dev(), consensus_version >= ConsensusVersion::V12, get_repo_commit_hash()) { - (true, _, Some(sha)) => Some(sha), - (_, true, Some(sha)) => Some(sha), - _ => None, - }; + let snarkos_sha = self.snarkos_sha(); // Send the challenge request. let our_request = ChallengeRequest::new(self.local_ip().port(), self.account.address(), our_nonce, snarkos_sha); send_event(&mut framed, peer_addr, Event::ChallengeRequest(our_request)).await?; @@ -1737,7 +2120,9 @@ impl Gateway { send_event(&mut framed, peer_addr, reason.into()).await?; Err(reason.into_connect_error(peer_addr)) } else { - Ok(peer_request) + note_legacy_handshake_end(framed, peer_addr); + + Ok(peer_info_from_challenge_request(peer_request, restrictions_id)) } } @@ -1748,6 +2133,64 @@ impl Gateway { let &ChallengeRequest { version, listener_port, address, nonce: _, ref snarkos_sha } = event; log_repo_sha_comparison(peer_addr, snarkos_sha, CONTEXT); + self.verify_peer_claims(peer_addr, version, listener_port, address) + } + + /// Verifies the metadata a peer disclosed during the Noise handshake. Returns a disconnect + /// reason if the peer is not acceptable. + /// + /// Every check reachable from here is a lookup or a comparison, which is precisely what makes + /// it safe to run before verifying the peer's signature. + #[must_use] + fn verify_peer_info( + &self, + peer_addr: SocketAddr, + info: &PeerInfo, + expected_restrictions_id: Field, + ) -> Option { + log_repo_sha_comparison(peer_addr, &info.snarkos_sha, CONTEXT); + + // Verify the restrictions ID. This is the only check here that the cleartext hint cannot + // carry, as the peer would simply state the expected value; it is left to this point. + if info.restrictions_id != expected_restrictions_id { + warn!("{CONTEXT} Handshake with '{peer_addr}' failed (incorrect restrictions ID)"); + return Some(DisconnectReason::InvalidChallengeResponse); + } + + self.verify_peer_identity(peer_addr, info.version, info.listener_port, info.address) + } + + /// Everything a peer's claimed identity can be held to without any cryptography. + /// + /// These are all lookups and comparisons, which is what makes them safe to run against the + /// unauthenticated hint in the Noise handshake's first message, and worth running there: a peer + /// that fails one of them would fail it again against the authenticated copy, so there is no + /// reason to spend a Diffie-Hellman on it first. + #[must_use] + fn verify_peer_identity( + &self, + peer_addr: SocketAddr, + version: u32, + listener_port: u16, + address: Address, + ) -> Option { + // Ensure the address is not the same as this node's. + if self.account.address() == address { + return Some(DisconnectReason::SelfConnect); + } + + self.verify_peer_claims(peer_addr, version, listener_port, address) + } + + /// The peer checks shared by both handshakes. + #[must_use] + fn verify_peer_claims( + &self, + peer_addr: SocketAddr, + version: u32, + listener_port: u16, + address: Address, + ) -> Option { let listener_addr = SocketAddr::new(peer_addr.ip(), listener_port); // Ensure the event protocol version is not outdated. diff --git a/node/bft/tests/gateway_noise.rs b/node/bft/tests/gateway_noise.rs new file mode 100644 index 0000000000..a9cbdf30c4 --- /dev/null +++ b/node/bft/tests/gateway_noise.rs @@ -0,0 +1,403 @@ +// Copyright (c) 2019-2026 Provable Inc. +// This file is part of the snarkOS library. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at: + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! End-to-end tests for the gateway's Noise handshake. + +#[allow(dead_code)] +mod common; + +use crate::common::{ + CurrentNetwork, + primary::new_test_committee, + utils::{sample_gateway, sample_ledger, sample_storage}, +}; +use snarkos_account::Account; +use snarkos_node_bft::{Gateway, helpers::init_primary_channels}; +use snarkos_node_bft_events::{ + DisconnectReason, + Event, + EventCodec, + HANDSHAKE_DOMAIN, + HandshakeHint, + InitiatorInfo, + PeerInfo, + ResponderProof, + ValidatorsRequest, +}; +use snarkos_node_network::{ + PeerPoolHandling, + noise::{HandshakeProtocol, NoiseSession, Role, binding_message, detect_handshake_protocol, write_noise_magic}, +}; +use snarkos_node_tcp::P2P; +use snarkvm::{ + ledger::narwhal::Data, + prelude::{FromBytes, TestRng, ToBytes}, +}; + +use std::{io, net::SocketAddr, time::Duration}; + +use deadline::deadline; +use futures::{SinkExt, TryStreamExt}; +use rand::RngExt; +use tokio::{ + net::{TcpListener, TcpStream}, + task, + time::timeout, +}; +use tokio_util::codec::Framed; + +/// The address to dial a test gateway on. +/// +/// The gateways listen on `0.0.0.0` here, which `Tcp::connect` treats as a self-connect when the +/// target's IP matches its own, so the loopback address has to be spelled out. +fn dial_addr(gateway: &Gateway) -> SocketAddr { + SocketAddr::from(([127, 0, 0, 1], gateway.local_ip().port())) +} + +/// The size of the committee the test gateways belong to; a committee needs at least three members. +const COMMITTEE_SIZE: u16 = 4; + +/// Builds `n` running gateways drawn from a single committee, along with all of its accounts. +async fn new_test_gateways( + n: usize, + rng: &mut TestRng, +) -> (Vec>, Vec>) { + let (accounts, committee) = new_test_committee(COMMITTEE_SIZE, rng); + let accounts_ = accounts.clone(); + let mut rng_ = TestRng::fixed(rng.random()); + let ledger = task::spawn_blocking(move || sample_ledger(&accounts_, &committee, &mut rng_)).await.unwrap(); + + let mut gateways = Vec::with_capacity(n); + for account in accounts.iter().take(n) { + let storage = sample_storage(ledger.clone()); + let gateway = sample_gateway(account.clone(), storage, ledger.clone()); + + // Set up primary channels; the rx is discarded as these tests exercise the gateway alone. + let (primary_tx, _primary_rx) = init_primary_channels(); + gateway.run(primary_tx, [].into(), None).await; + + gateways.push(gateway); + } + + (accounts, gateways) +} + +#[tokio::test(flavor = "multi_thread")] +async fn two_gateways_complete_a_noise_handshake() { + let mut rng = TestRng::default(); + let (accounts, gateways) = new_test_gateways(2, &mut rng).await; + let (gateway_a, gateway_b) = (gateways[0].clone(), gateways[1].clone()); + + gateway_a.tcp().connect(dial_addr(&gateway_b)).await.unwrap(); + + // Both sides must have authenticated the other's Aleo address, not merely completed a TCP + // connection. + let (a, b) = (gateway_a.clone(), gateway_b.clone()); + let (addr_a, addr_b) = (accounts[0].address(), accounts[1].address()); + deadline!(Duration::from_secs(5), move || { + a.connected_addresses().contains(&addr_b) && b.connected_addresses().contains(&addr_a) + }); +} + +/// During the transition, a node that speaks Noise still has to be able to shake hands with one that +/// only knows the legacy handshake - which is every node until the activation height passes. The +/// responder goes along with whichever protocol it is offered, and the legacy path now reaches it +/// through the protocol detection, which consumes the first four bytes of the peer's opening frame +/// and has to feed them back into the event codec. +#[tokio::test(flavor = "multi_thread")] +async fn a_legacy_initiator_is_accepted_by_a_noise_capable_responder() { + let mut rng = TestRng::default(); + let (accounts, gateways) = new_test_gateways(2, &mut rng).await; + let (gateway_a, gateway_b) = (gateways[0].clone(), gateways[1].clone()); + + // Stand in for an unconverted validator: dial with the legacy handshake. + gateway_a.set_initiates_noise_handshake(false); + gateway_a.tcp().connect(dial_addr(&gateway_b)).await.unwrap(); + + let (a, b) = (gateway_a.clone(), gateway_b.clone()); + let (addr_a, addr_b) = (accounts[0].address(), accounts[1].address()); + deadline!(Duration::from_secs(5), move || { + a.connected_addresses().contains(&addr_b) && b.connected_addresses().contains(&addr_a) + }); +} + +/// The handshake hands a bare stream back to the connection, where [`Reading`] builds an event codec +/// of its own. A `ValidatorsRequest` is always answered, so a response proves the handover left the +/// stream exactly where that codec expects to start. +#[tokio::test(flavor = "multi_thread")] +async fn a_noise_handshake_leaves_the_connection_usable() { + let mut rng = TestRng::default(); + // One gateway, so that the committee member we authenticate as has no node of its own running. + let (accounts, gateways) = new_test_gateways(1, &mut rng).await; + let gateway = gateways[0].clone(); + let peer = accounts[1].clone(); + + let signer = peer.clone(); + let sign = move |binding: &[u8]| signer.sign_bytes(binding, &mut rand::rng()).unwrap().to_bytes_le().unwrap(); + let (verdict, mut stream) = handshake_with_gateway(dial_addr(&gateway), &peer, 4140, sign).await.unwrap(); + assert!(matches!(verdict, ResponderProof::Accepted { .. }), "the handshake should have been accepted"); + + // Now speak events over the same stream, through a codec built from scratch as `Reading` does. + let mut framed = Framed::new(&mut stream, EventCodec::::default()); + framed.send(Event::ValidatorsRequest(ValidatorsRequest)).await.unwrap(); + + // The gateway solicits validators of its own accord too, so anything arriving ahead of the answer + // is skipped. + let answered = timeout(Duration::from_secs(5), async { + while let Some(event) = framed.try_next().await.unwrap() { + if matches!(event, Event::ValidatorsResponse(_)) { + return true; + } + } + false + }) + .await + .expect("the gateway did not answer in time"); + + assert!(answered, "the gateway closed the connection instead of answering"); + assert!(gateway.connected_addresses().contains(&peer.address())); +} + +/// Relays a Noise handshake between a victim that dials `listener` and a `target` it believes it is +/// talking to. +/// +/// This is the attack the handshake binding exists to defeat: the attacker terminates a Noise +/// session on each side and forwards the decrypted payloads verbatim, so that both ends believe +/// they authenticated each other while it sits in the middle with plaintext access to both. +/// +/// Against the legacy handshake this works, because the challenge signatures cover only a pair of +/// nonces and are therefore valid on any connection they are pasted into. Against this one it must +/// not, because each side signs its own session's handshake hash and the two sessions cannot have +/// the same one. +async fn relay_noise_handshake(listener: TcpListener, target: SocketAddr) -> io::Result<()> { + let (mut victim_stream, _) = listener.accept().await?; + + // The victim announced the Noise handshake; consume the marker and answer as the responder. + let (protocol, _) = detect_handshake_protocol(&mut victim_stream).await?; + assert_eq!(protocol, HandshakeProtocol::Noise); + let mut from_victim = NoiseSession::new(victim_stream, Role::Responder)?; + + // Open a second, entirely independent session to the target. + let mut target_stream = TcpStream::connect(target).await?; + write_noise_magic(&mut target_stream).await?; + let mut to_target = NoiseSession::new(target_stream, Role::Initiator)?; + + // Forward each payload from one session into the other. + let hint = from_victim.recv().await?; + to_target.send(&hint).await?; + + let responder_info = to_target.recv().await?; + from_victim.send(&responder_info).await?; + + let initiator_info = from_victim.recv().await?; + to_target.send(&initiator_info).await?; + + let mut to_target = to_target.into_transport_mode()?; + let mut from_victim = from_victim.into_transport_mode()?; + + let verdict = to_target.recv().await?; + from_victim.send(&verdict).await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_relayed_noise_handshake_is_rejected() { + let mut rng = TestRng::default(); + let (accounts, gateways) = new_test_gateways(2, &mut rng).await; + let (gateway_a, gateway_b) = (gateways[0].clone(), gateways[1].clone()); + + // Put the attacker between the two gateways. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay_addr = listener.local_addr().unwrap(); + let target = dial_addr(&gateway_b); + let relay = task::spawn(async move { relay_noise_handshake(listener, target).await }); + + // The victim dials the attacker, believing it to be a validator. + assert!(gateway_a.tcp().connect(relay_addr).await.is_err(), "the relayed handshake should have been rejected"); + + // Neither side may end up considering the other a peer. + assert!(!gateway_a.connected_addresses().contains(&accounts[1].address())); + assert!(!gateway_b.connected_addresses().contains(&accounts[0].address())); + + relay.abort(); +} + +/// Drives a Noise handshake against a gateway by hand, up to and including the verdict, and hands the +/// stream back so that a test can carry on speaking events over it. +/// +/// The signature over the binding is produced by `sign`, so that a test can decide whether to +/// authenticate honestly or not. +async fn handshake_with_gateway( + gateway_addr: SocketAddr, + account: &Account, + listener_port: u16, + sign: impl Fn(&[u8]) -> Vec, +) -> io::Result<(ResponderProof, TcpStream)> { + let mut stream = TcpStream::connect(gateway_addr).await?; + write_noise_magic(&mut stream).await?; + let mut noise = NoiseSession::new(stream, Role::Initiator)?; + + let version = snarkos_node_bft_events::Event::::VERSION; + + // Message 1: the cleartext hint. + let hint = HandshakeHint { version, listener_port, address: account.address() }; + noise.send(&hint.to_bytes_le().unwrap()).await?; + + // Message 2: the gateway's metadata, which tells us the restrictions ID it expects back. + let peer_info = PeerInfo::::from_bytes_le(&noise.recv().await?).unwrap(); + + // Message 3: our metadata, and whatever `sign` decides to offer as proof. + let binding = binding_message(HANDSHAKE_DOMAIN, Role::Initiator, &noise.handshake_hash()?); + let our_info = PeerInfo::new(listener_port, account.address(), peer_info.restrictions_id, None); + let our_message = InitiatorInfo { info: our_info, signature: Data::Buffer(sign(&binding).into()) }; + noise.send(&our_message.to_bytes_le().unwrap()).await?; + + // Message 4: the verdict. + let mut noise = noise.into_transport_mode()?; + let verdict = ResponderProof::from_bytes_le(&noise.recv().await?).unwrap(); + + Ok((verdict, noise.into_inner())) +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_unauthorized_validator_is_dropped_before_the_responder_replies() { + let mut rng = TestRng::default(); + let (_accounts, gateways) = new_test_gateways(1, &mut rng).await; + let gateway = gateways[0].clone(); + + // An account that is not a member of the gateway's committee. + let outsider = Account::::new(&mut rng).unwrap(); + + let mut stream = TcpStream::connect(dial_addr(&gateway)).await.unwrap(); + write_noise_magic(&mut stream).await.unwrap(); + let mut noise = NoiseSession::new(stream, Role::Initiator).unwrap(); + + let version = snarkos_node_bft_events::Event::::VERSION; + let hint = HandshakeHint { version, listener_port: 4130, address: outsider.address() }; + noise.send(&hint.to_bytes_le().unwrap()).await.unwrap(); + + // The committee check runs against the hint, so the gateway hangs up rather than answering. + // Never receiving a second message is what proves it spent no Diffie-Hellman on this peer. + assert!(noise.recv().await.is_err(), "the gateway should not have replied to a non-committee peer"); + assert!(!gateway.connected_addresses().contains(&outsider.address())); + + // And the failure must actually abort the connection. A peer rejected this early never reaches + // the peer pool, so the handshake result is the only thing standing between it and a live + // connection with a reader attached. + let gateway_clone = gateway.clone(); + deadline!(Duration::from_secs(5), move || gateway_clone.tcp().num_connected() == 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_committee_member_that_cannot_prove_its_identity_is_rejected() { + let mut rng = TestRng::default(); + let (accounts, gateways) = new_test_gateways(2, &mut rng).await; + let gateway = gateways[0].clone(); + + // Claim the Aleo address of a genuine committee member, which is public knowledge, but sign + // with a key we actually own. + let impostor = Account::::new(&mut rng).unwrap(); + let sign = move |binding: &[u8]| impostor.sign_bytes(binding, &mut rand::rng()).unwrap().to_bytes_le().unwrap(); + + let (verdict, _stream) = handshake_with_gateway(dial_addr(&gateway), &accounts[1], 4131, sign).await.unwrap(); + + assert_eq!(verdict, ResponderProof::Rejected { reason: DisconnectReason::InvalidChallengeResponse }); + assert!(!gateway.connected_addresses().contains(&accounts[1].address())); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_contradicted_handshake_hint_is_rejected() { + let mut rng = TestRng::default(); + let (accounts, gateways) = new_test_gateways(2, &mut rng).await; + let gateway = gateways[0].clone(); + let peer = accounts[1].clone(); + + let mut stream = TcpStream::connect(dial_addr(&gateway)).await.unwrap(); + write_noise_magic(&mut stream).await.unwrap(); + let mut noise = NoiseSession::new(stream, Role::Initiator).unwrap(); + + let version = snarkos_node_bft_events::Event::::VERSION; + + // Claim one listening port in the cleartext hint... + let hint = HandshakeHint { version, listener_port: 4132, address: peer.address() }; + noise.send(&hint.to_bytes_le().unwrap()).await.unwrap(); + let peer_info = PeerInfo::::from_bytes_le(&noise.recv().await.unwrap()).unwrap(); + + // ...and a different one in the authenticated payload. The hint is what the gateway ran its + // early checks against, so disagreeing with it must not be a way to slip past them. + let binding = binding_message(HANDSHAKE_DOMAIN, Role::Initiator, &noise.handshake_hash().unwrap()); + let signature = peer.sign_bytes(&binding, &mut rand::rng()).unwrap(); + let our_info = PeerInfo::new(4133, peer.address(), peer_info.restrictions_id, None); + let our_message = InitiatorInfo { info: our_info, signature: Data::Object(signature) }; + noise.send(&our_message.to_bytes_le().unwrap()).await.unwrap(); + + let mut noise = noise.into_transport_mode().unwrap(); + let verdict = ResponderProof::::from_bytes_le(&noise.recv().await.unwrap()).unwrap(); + + assert_eq!(verdict, ResponderProof::Rejected { reason: DisconnectReason::ProtocolViolation }); +} + +#[test] +fn the_noise_marker_is_rejected_by_a_legacy_peer() { + use bytes::BytesMut; + use snarkos_node_bft_events::EventCodec; + use snarkos_node_network::noise::NOISE_MAGIC; + use tokio_util::codec::Decoder; + + // A node that only speaks the legacy handshake has to fail fast when it is offered the Noise + // one, rather than stalling on a frame that will never arrive. The marker is chosen so that it + // decodes as a frame length far beyond what the legacy handshake codec accepts. + let mut codec = EventCodec::::handshake(); + let mut bytes = BytesMut::from(&NOISE_MAGIC[..]); + + let error = codec.decode(&mut bytes).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_initiator_cannot_be_checked_as_one_validator_and_admitted_as_another() { + let mut rng = TestRng::default(); + let (accounts, gateways) = new_test_gateways(2, &mut rng).await; + let gateway = gateways[0].clone(); + + // Pass the early checks as one committee member... + let claimed = accounts[1].clone(); + // ...then authenticate, correctly and verifiably, as a different one. + let actual = accounts[2].clone(); + + let mut stream = TcpStream::connect(dial_addr(&gateway)).await.unwrap(); + write_noise_magic(&mut stream).await.unwrap(); + let mut noise = NoiseSession::new(stream, Role::Initiator).unwrap(); + + let version = snarkos_node_bft_events::Event::::VERSION; + let hint = HandshakeHint { version, listener_port: 4134, address: claimed.address() }; + noise.send(&hint.to_bytes_le().unwrap()).await.unwrap(); + let peer_info = PeerInfo::::from_bytes_le(&noise.recv().await.unwrap()).unwrap(); + + let binding = binding_message(HANDSHAKE_DOMAIN, Role::Initiator, &noise.handshake_hash().unwrap()); + let signature = actual.sign_bytes(&binding, &mut rand::rng()).unwrap(); + let our_info = PeerInfo::new(4134, actual.address(), peer_info.restrictions_id, None); + let our_message = InitiatorInfo { info: our_info, signature: Data::Object(signature) }; + noise.send(&our_message.to_bytes_le().unwrap()).await.unwrap(); + + // The signature is perfectly valid for `actual`; what must fail is that the gateway ran its + // early checks against `claimed`. + let mut noise = noise.into_transport_mode().unwrap(); + let verdict = ResponderProof::::from_bytes_le(&noise.recv().await.unwrap()).unwrap(); + + assert_eq!(verdict, ResponderProof::Rejected { reason: DisconnectReason::ProtocolViolation }); + assert!(!gateway.connected_addresses().contains(&actual.address())); +} diff --git a/node/network/Cargo.toml b/node/network/Cargo.toml index 1d74a29777..07083dc420 100644 --- a/node/network/Cargo.toml +++ b/node/network/Cargo.toml @@ -24,6 +24,9 @@ test = [ ] [dependencies.anyhow] workspace = true +[dependencies.bytes] +workspace = true + [dependencies.locktick] workspace = true features = [ "parking_lot" ] @@ -32,6 +35,9 @@ optional = true [dependencies.parking_lot] workspace = true +[dependencies.rand] +workspace = true + [dependencies.tracing] workspace = true @@ -48,15 +54,27 @@ workspace = true workspace = true features = [ "console", "utilities" ] +[dependencies.snow] +workspace = true + [dependencies.socket2] version = "0.6.2" [dependencies.tokio] workspace = true +features = [ "io-util", "net" ] + +[dependencies.tokio-util] +workspace = true +features = [ "codec" ] [dev-dependencies.snarkos-node-network] path = "." features = [ "test" ] +[dev-dependencies.tokio] +workspace = true +features = [ "io-util", "macros", "rt-multi-thread" ] + [build-dependencies.built] workspace = true diff --git a/node/network/src/lib.rs b/node/network/src/lib.rs index 168be5f07e..8ca24e0360 100644 --- a/node/network/src/lib.rs +++ b/node/network/src/lib.rs @@ -18,6 +18,8 @@ pub mod node_type; pub use node_type::*; +pub mod noise; + pub mod peer; pub use peer::*; diff --git a/node/network/src/noise.rs b/node/network/src/noise.rs new file mode 100644 index 0000000000..50dc679f74 --- /dev/null +++ b/node/network/src/noise.rs @@ -0,0 +1,642 @@ +// Copyright (c) 2019-2026 Provable Inc. +// This file is part of the snarkOS library. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at: + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Shared plumbing for handshakes based on the [Noise-XX] pattern. +//! +//! The Noise static keys are generated per connection: they exist solely to give the pattern a +//! channel to bind to, and are never persisted. Node identity remains the Aleo account key, which +//! is bound to the session by signing the running Noise handshake hash - see [`binding_message`]. +//! +//! Note that the transport keys a completed session yields are only used to carry the last +//! handshake message, and are then dropped. +//! +//! [Noise-XX]: https://noiseprotocol.org/noise.html#interactive-handshake-patterns-fundamental + +use bytes::BytesMut; +use snow::{Builder, HandshakeState, TransportState, params::NoiseParams}; +use std::io; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio_util::codec::Framed; + +/// The Noise handshake pattern used by snarkOS. +pub const NOISE_PARAMS: &str = "Noise_XX_25519_ChaChaPoly_BLAKE2s"; + +/// The prefix that marks a stream as speaking the Noise handshake. +/// +/// A peer that only knows the legacy handshake reads these bytes as the little-endian `u32` length +/// prefix of the first frame; the value is `0xFF00_1EAE` (~4.3 GiB), which is far beyond the 1 MiB +/// frame limit its handshake codec allows, so it rejects the connection immediately rather than +/// stalling or misparsing it. +/// +/// The prefix travels ahead of the Noise stream and so is not a Noise message, but it is fed to the +/// pattern as its prologue, which mixes it into the handshake hash on both sides. It is therefore +/// covered by the signatures that bind the two identities to the session, exactly like the payloads +/// are - if it is ever tampered with in flight, the two sides derive different hashes and the +/// handshake fails. Nothing that precedes the pattern should be left out of the prologue: the moment +/// the preamble carries anything negotiable, an unauthenticated one is a downgrade attack. +pub const NOISE_MAGIC: [u8; 4] = [0xAE, 0x1E, 0x00, 0xFF]; + +/// The maximum length of a single Noise message, as mandated by the specification. +pub const MAX_NOISE_MSG_LEN: usize = 65535; + +/// The length of the little-endian `u32` that prefixes each Noise message on the wire. +/// +/// The specification's own convention is a two-byte big-endian prefix; four little-endian bytes are +/// used instead so that the marker preceding the stream can be chosen to look like an impossible +/// frame length to a peer that speaks the legacy handshake. See [`NOISE_MAGIC`]. +const LENGTH_PREFIX_LEN: usize = 4; + +/// An upper bound on the number of bytes a Noise message can add on top of its payload: an +/// ephemeral public key, an encrypted static public key with its AEAD tag, and a tag for the payload +/// itself. +/// +/// No single message of the pattern carries all of them - the largest is the second, at `e, ee, s, +/// es` - so this is deliberately an upper bound rather than an exact figure. It is used both to +/// reject payloads that could not fit in a message and to size the buffer a message is written into, +/// so it must not be an underestimate for *any* message: the second message is the one that makes +/// the ephemeral key term necessary. +const NOISE_OVERHEAD: usize = DH_LEN + (DH_LEN + TAG_LEN) + TAG_LEN; + +/// The length of the AEAD tag of the cipher function in [`NOISE_PARAMS`]. +const TAG_LEN: usize = 16; + +/// The length of the handshake hash, i.e. the digest length of the hash function in [`NOISE_PARAMS`]. +pub const HANDSHAKE_HASH_LEN: usize = 32; + +/// The length of a public or private key of the Diffie-Hellman function in [`NOISE_PARAMS`]. +const DH_LEN: usize = 32; + +/// The offset of the payload within the pattern's first message. +/// +/// That message is `e`: the initiator's ephemeral public key followed by the payload, neither of +/// them encrypted, since no key has been established yet. The payload can therefore be read - though +/// emphatically not trusted - before any elliptic curve operation is performed, which is what lets a +/// responder turn a peer away for free; see [`PendingSession`]. +/// +/// A unit test pins this against a message the pattern actually produced, so that a change to +/// [`NOISE_PARAMS`] cannot invalidate it silently. +const FIRST_MESSAGE_PAYLOAD_OFFSET: usize = DH_LEN; + +/// The side of a Noise session; note that this is the side of the *handshake*, which for all +/// current callers coincides with the side of the underlying TCP connection. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Role { + Initiator, + Responder, +} + +impl Role { + /// The role tag included in the signed binding message. + const fn tag(&self) -> &'static [u8] { + match self { + Self::Initiator => b"|initiator", + Self::Responder => b"|responder", + } + } +} + +/// Returns the message that a party must sign with its Aleo account key in order to bind that +/// identity to the Noise session identified by `handshake_hash`. +/// +/// The handshake hash commits to every key and payload exchanged so far, so a signature over it is +/// only valid for the one session it was produced in. This is what makes relaying impossible: an +/// attacker that terminates two separate Noise sessions and forwards the payloads between them +/// derives a different handshake hash on each side, so neither signature verifies. +/// +/// `domain` separates the handshakes of different subprotocols (e.g. the BFT gateway and the +/// router), which a validator runs concurrently under the same Aleo key. +pub fn binding_message(domain: &[u8], role: Role, handshake_hash: &[u8]) -> Vec { + let mut message = Vec::with_capacity(domain.len() + role.tag().len() + handshake_hash.len()); + message.extend_from_slice(domain); + message.extend_from_slice(role.tag()); + message.extend_from_slice(handshake_hash); + message +} + +/// The handshake protocol a connection is speaking. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HandshakeProtocol { + /// The Noise-based handshake, identified by the [`NOISE_MAGIC`] prefix. + Noise, + /// The legacy challenge-response handshake. + Legacy, +} + +/// Announces to the peer that this side is about to perform a Noise handshake. +/// +/// The marker is also the pattern's prologue, so it does not need to be authenticated separately; +/// see [`NOISE_MAGIC`]. +pub async fn write_noise_magic(stream: &mut S) -> io::Result<()> { + stream.write_all(&NOISE_MAGIC).await +} + +/// Determines which handshake protocol the peer is speaking by consuming the first +/// [`NOISE_MAGIC`]-sized chunk of the stream. +/// +/// Alongside the protocol, this returns the bytes that need to be handed back to the caller's +/// decoder: empty for [`HandshakeProtocol::Noise`], since the magic is not part of the Noise +/// stream, and the consumed prefix for [`HandshakeProtocol::Legacy`], where it is the beginning of +/// the peer's first frame. Use [`prepare_framed`] to feed it back into a codec. +/// +/// Note: this reads rather than peeks, as a peek is free to return fewer bytes than requested. +pub async fn detect_handshake_protocol( + stream: &mut S, +) -> io::Result<(HandshakeProtocol, BytesMut)> { + let mut prefix = [0u8; NOISE_MAGIC.len()]; + stream.read_exact(&mut prefix).await?; + + if prefix == NOISE_MAGIC { + Ok((HandshakeProtocol::Noise, BytesMut::new())) + } else { + Ok((HandshakeProtocol::Legacy, BytesMut::from(&prefix[..]))) + } +} + +/// Frames the given stream with the given codec, pre-populating the read buffer with bytes that +/// were consumed from the stream before it was framed. +/// +/// This exists only so that the legacy handshake can be handed back the prefix that +/// [`detect_handshake_protocol`] took from it; the Noise handshake reads its messages exactly and has +/// no codec to seed. It goes away with the legacy path. +pub fn prepare_framed(stream: S, codec: C, read_buf: &[u8]) -> Framed { + let mut framed = Framed::new(stream, codec); + framed.read_buffer_mut().extend_from_slice(read_buf); + framed +} + +/// Reads one length-prefixed Noise message from the given stream. +/// +/// Both reads are exact, so this consumes the message and not a single byte more. That is what lets +/// the stream be handed on to a reader with a codec of its own once the handshake is done: there is +/// never anything buffered here for that reader to miss. Note that a buffering codec could not offer +/// the same guarantee, as it reads whatever the socket has available. +async fn read_message(stream: &mut S) -> io::Result> { + let mut length = [0u8; LENGTH_PREFIX_LEN]; + stream.read_exact(&mut length).await?; + + // Bound the length before it is used to allocate; the specification caps a Noise message at + // `MAX_NOISE_MSG_LEN`, so anything larger is a protocol violation rather than a big message. + let length = u32::from_le_bytes(length) as usize; + if length > MAX_NOISE_MSG_LEN { + return Err(invalid_data(format!("the Noise message is too large ({length} bytes)"))); + } + + let mut message = vec![0u8; length]; + stream.read_exact(&mut message).await?; + + Ok(message) +} + +/// Writes one length-prefixed Noise message to the given stream. +/// +/// The prefix and the body go out in a single write, so that emitting a message costs one syscall +/// rather than two and the peer sees the two halves arrive together. +async fn write_message(stream: &mut S, message: &[u8]) -> io::Result<()> { + // `NoiseSession::send` already bounds its payload so that the message the pattern produces cannot + // reach this; the check is repeated here so that the cast below cannot silently truncate. + if message.len() > MAX_NOISE_MSG_LEN { + return Err(invalid_data(format!("the Noise message is too large ({} bytes)", message.len()))); + } + + let mut framed = Vec::with_capacity(LENGTH_PREFIX_LEN + message.len()); + framed.extend_from_slice(&(message.len() as u32).to_le_bytes()); + framed.extend_from_slice(message); + + stream.write_all(&framed).await?; + stream.flush().await +} + +/// Returns a builder for the pattern in [`NOISE_PARAMS`], with [`NOISE_MAGIC`] as its prologue. +fn builder<'a>() -> io::Result> { + // This is a compile-time constant, so a parsing failure is a bug rather than a runtime condition. + let params: NoiseParams = NOISE_PARAMS.parse().expect("the Noise parameters should be valid"); + // The prologue brings the marker that precedes the pattern under the handshake hash; see + // `NOISE_MAGIC`. + Builder::new(params).prologue(NOISE_MAGIC.as_slice()).map_err(invalid_data) +} + +/// Builds the handshake state for the given role, with a fresh static keypair. +fn build_state(role: Role) -> io::Result { + // Any 32 bytes are a valid X25519 private key, as the scalar is clamped where it is used, so the + // key is taken straight from the RNG. `Builder::generate_keypair` would derive the public key + // here and the builder would derive it again when the state is built, spending a scalar + // multiplication that nothing reads. + let private_key: [u8; DH_LEN] = rand::random(); + let builder = builder()?.local_private_key(&private_key).map_err(invalid_data)?; + + match role { + Role::Initiator => builder.build_initiator(), + Role::Responder => builder.build_responder(), + } + .map_err(invalid_data) +} + +fn invalid_data(err: E) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, err.to_string()) +} + +enum SessionState { + Handshake(Box), + Transport(Box), +} + +/// A stream whose first Noise message has been read but not yet processed. +/// +/// This exists so that a responder can act on the initiator's cleartext payload *before* deriving +/// any keys. Everything up to [`PendingSession::into_session`] is reading and parsing, so a peer that +/// is going to be turned away - one that is not an authorized validator, say - costs no elliptic +/// curve operations at all. Under a distributed flood of connection attempts, that is the difference +/// between paying for five scalar multiplications per rejected peer and paying for none. +pub struct PendingSession { + stream: S, + first_message: Vec, +} + +impl PendingSession { + /// Reads the pattern's first message from the given stream. + /// + /// The caller is responsible for the [`NOISE_MAGIC`] prefix, which must already have been + /// consumed with [`detect_handshake_protocol`]. + pub async fn accept(mut stream: S) -> io::Result { + let first_message = read_message(&mut stream).await?; + + Ok(Self { stream, first_message }) + } + + /// Returns the payload the initiator sent alongside its ephemeral key. + /// + /// It is neither encrypted nor authenticated, so it is a claim and not a fact: anything acted on + /// here has to be re-checked against the authenticated copy later in the handshake. + pub fn first_payload(&self) -> io::Result<&[u8]> { + self.first_message + .get(FIRST_MESSAGE_PAYLOAD_OFFSET..) + .ok_or_else(|| invalid_data("the first Noise message is too short to carry a payload")) + } + + /// Derives the responder's keys and processes the first message. + pub fn into_session(self) -> io::Result> { + let Self { stream, first_message } = self; + let mut session = + NoiseSession { stream, state: SessionState::Handshake(Box::new(build_state(Role::Responder)?)) }; + // The payload was already exposed by `first_payload`, so it is discarded here; processing the + // message is what advances the pattern. + session.decrypt(&first_message)?; + + Ok(session) + } +} + +/// A Noise-XX session over a stream. +/// +/// Payloads are exchanged with [`NoiseSession::send`] and [`NoiseSession::recv`]; the session moves +/// from the handshake phase to the transport phase via [`NoiseSession::into_transport_mode`], which +/// may only be called once the pattern's three messages have been exchanged. +pub struct NoiseSession { + stream: S, + state: SessionState, +} + +impl NoiseSession { + /// Creates a Noise session over the given stream. + /// + /// The caller is responsible for the [`NOISE_MAGIC`] prefix: the initiator must have sent it + /// with [`write_noise_magic`], and the responder must have consumed it with + /// [`detect_handshake_protocol`]. + /// Note that a responder should generally use [`PendingSession::accept`] instead, so that it can + /// inspect the initiator's cleartext payload before committing to any key derivation. + pub fn new(stream: S, role: Role) -> io::Result { + Ok(Self { stream, state: SessionState::Handshake(Box::new(build_state(role)?)) }) + } + + /// Encrypts the given payload and sends it to the peer. + pub async fn send(&mut self, payload: &[u8]) -> io::Result<()> { + if payload.len() + NOISE_OVERHEAD > MAX_NOISE_MSG_LEN { + return Err(invalid_data(format!("the handshake payload is too large ({} bytes)", payload.len()))); + } + + // The message cannot exceed the payload plus what the pattern adds to it, so there is no + // reason to reach for the maximum message length here. + let mut buffer = vec![0u8; payload.len() + NOISE_OVERHEAD]; + let len = match self.state { + SessionState::Handshake(ref mut state) => state.write_message(payload, &mut buffer), + SessionState::Transport(ref mut state) => state.write_message(payload, &mut buffer), + } + .map_err(invalid_data)?; + buffer.truncate(len); + + write_message(&mut self.stream, &buffer).await + } + + /// Receives a message from the peer and returns its decrypted payload. + pub async fn recv(&mut self) -> io::Result> { + let message = read_message(&mut self.stream).await?; + + self.decrypt(&message) + } + + /// Processes an already-received message and returns its decrypted payload. + fn decrypt(&mut self, message: &[u8]) -> io::Result> { + // A payload is never longer than the message that carried it, which `read_message` has + // already capped at `MAX_NOISE_MSG_LEN`. + let mut buffer = vec![0u8; message.len()]; + let len = match self.state { + SessionState::Handshake(ref mut state) => state.read_message(message, &mut buffer), + SessionState::Transport(ref mut state) => state.read_message(message, &mut buffer), + } + .map_err(invalid_data)?; + buffer.truncate(len); + + Ok(buffer) + } + + /// Returns the current handshake hash, which commits to every key and payload exchanged so + /// far; see [`binding_message`]. + /// + /// Both sides derive the same value after processing the same number of messages. It can only + /// be read during the handshake phase, so callers that need it must capture it before calling + /// [`NoiseSession::into_transport_mode`]. + pub fn handshake_hash(&self) -> io::Result<[u8; HANDSHAKE_HASH_LEN]> { + let SessionState::Handshake(ref state) = self.state else { + return Err(invalid_data("the Noise handshake hash is only available during the handshake")); + }; + + // The hash length is determined by the hash function in `NOISE_PARAMS`. + Ok(state.get_handshake_hash().try_into().expect("the Noise handshake hash should be 32 bytes long")) + } + + /// Transitions the session from the handshake phase to the transport phase, which the pattern + /// only permits once its three messages have been exchanged. + pub fn into_transport_mode(self) -> io::Result { + let Self { stream, state } = self; + + let SessionState::Handshake(handshake_state) = state else { + return Err(invalid_data("the Noise session is already in transport mode")); + }; + let transport_state = handshake_state.into_transport_mode().map_err(invalid_data)?; + + Ok(Self { stream, state: SessionState::Transport(Box::new(transport_state)) }) + } + + /// Recovers the underlying stream. + /// + /// Nothing is lost in the process: the session reads its messages exactly, so anything the peer + /// sent past the end of the handshake is still on the stream for whoever takes it next. + pub fn into_inner(self) -> S { + self.stream + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use tokio::io::{DuplexStream, duplex}; + + /// Runs the three messages of the XX pattern, returning both sessions and the handshake hashes + /// each side derived after messages 2 and 3. + async fn perform_xx_handshake( + payloads: [&[u8]; 3], + ) -> (NoiseSession, NoiseSession, [[u8; 32]; 4]) { + let (initiator_stream, responder_stream) = duplex(1024); + let mut initiator = NoiseSession::new(initiator_stream, Role::Initiator).unwrap(); + + // -> e + initiator.send(payloads[0]).await.unwrap(); + let pending = PendingSession::accept(responder_stream).await.unwrap(); + assert_eq!(pending.first_payload().unwrap(), payloads[0]); + let mut responder = pending.into_session().unwrap(); + + // <- e, ee, s, es + responder.send(payloads[1]).await.unwrap(); + assert_eq!(initiator.recv().await.unwrap(), payloads[1]); + let (initiator_h2, responder_h2) = (initiator.handshake_hash().unwrap(), responder.handshake_hash().unwrap()); + + // -> s, se + initiator.send(payloads[2]).await.unwrap(); + assert_eq!(responder.recv().await.unwrap(), payloads[2]); + let (initiator_h3, responder_h3) = (initiator.handshake_hash().unwrap(), responder.handshake_hash().unwrap()); + + (initiator, responder, [initiator_h2, responder_h2, initiator_h3, responder_h3]) + } + + #[tokio::test] + async fn xx_handshake_completes_and_agrees_on_the_handshake_hash() { + let (initiator, responder, [initiator_h2, responder_h2, initiator_h3, responder_h3]) = + perform_xx_handshake([b"hint", b"responder info", b"initiator info"]).await; + + // Both sides derive the same binding value at both points of the handshake. + assert_eq!(initiator_h2, responder_h2); + assert_eq!(initiator_h3, responder_h3); + // The hash keeps evolving, so the two binding values are distinct. + assert_ne!(initiator_h2, initiator_h3); + + // The fourth message, carrying the responder's proof, is a transport message. + let mut initiator = initiator.into_transport_mode().unwrap(); + let mut responder = responder.into_transport_mode().unwrap(); + responder.send(b"responder proof").await.unwrap(); + assert_eq!(initiator.recv().await.unwrap(), b"responder proof"); + } + + #[tokio::test] + async fn handshake_hashes_differ_between_sessions() { + let (_, _, first) = perform_xx_handshake([b"", b"", b""]).await; + let (_, _, second) = perform_xx_handshake([b"", b"", b""]).await; + + // The ephemeral keys make every session's binding value unique, which is what prevents a + // signature over it from being relayed into another session. + assert_ne!(first, second); + } + + #[tokio::test] + async fn tampering_with_a_handshake_message_is_detected() { + let (mut initiator_stream, mut responder_stream) = duplex(1024); + let mut initiator = NoiseSession::new(&mut initiator_stream, Role::Initiator).unwrap(); + + initiator.send(b"hint").await.unwrap(); + let mut responder = PendingSession::accept(&mut responder_stream).await.unwrap().into_session().unwrap(); + + // Flip a bit in the encrypted payload of the second message. + let mut buffer = vec![0u8; MAX_NOISE_MSG_LEN]; + let SessionState::Handshake(ref mut state) = responder.state else { unreachable!() }; + let len = state.write_message(b"responder info", &mut buffer).unwrap(); + buffer.truncate(len); + *buffer.last_mut().unwrap() ^= 1; + write_message(&mut responder.stream, &buffer).await.unwrap(); + + assert!(initiator.recv().await.is_err()); + } + + #[tokio::test] + async fn a_first_message_payload_is_readable_before_any_keys_are_derived() { + let (initiator_stream, responder_stream) = duplex(1024); + let mut initiator = NoiseSession::new(initiator_stream, Role::Initiator).unwrap(); + initiator.send(b"a cleartext hint").await.unwrap(); + + // This is what pins `FIRST_MESSAGE_PAYLOAD_OFFSET` to what the pattern actually produces: if + // `NOISE_PARAMS` ever changed its Diffie-Hellman function, or the pattern gained a + // pre-message, the offset would be wrong and this assertion would catch it. + let pending = PendingSession::accept(responder_stream).await.unwrap(); + assert_eq!(pending.first_payload().unwrap(), b"a cleartext hint"); + + // The same message must still drive the handshake once the keys exist. + let mut responder = pending.into_session().unwrap(); + responder.send(b"responder info").await.unwrap(); + assert_eq!(initiator.recv().await.unwrap(), b"responder info"); + } + + #[tokio::test] + async fn tampering_with_the_cleartext_first_payload_is_detected() { + // The attacker sits on both wires, so that it can rewrite the first message in flight. + let (initiator_stream, mut initiator_wire) = duplex(1024); + let (mut responder_wire, responder_stream) = duplex(1024); + + let mut initiator = NoiseSession::new(initiator_stream, Role::Initiator).unwrap(); + initiator.send(b"the original hint").await.unwrap(); + + // Flip a bit in the payload, which the pattern's first message carries in the clear. + let mut message = read_message(&mut initiator_wire).await.unwrap(); + message[FIRST_MESSAGE_PAYLOAD_OFFSET] ^= 1; + write_message(&mut responder_wire, &message).await.unwrap(); + + // The responder reads the rewritten payload quite happily - it is a claim, not a fact. + let pending = PendingSession::accept(responder_stream).await.unwrap(); + assert_eq!(pending.first_payload().unwrap(), b"uhe original hint"); + let mut responder = pending.into_session().unwrap(); + + // But the payload was mixed into its handshake hash, which is the associated data of every + // encryption that follows, so its reply cannot be decrypted by the initiator. This is what + // lets the responder act on the cleartext hint and have the result stand. + responder.send(b"responder info").await.unwrap(); + let reply = read_message(&mut responder_wire).await.unwrap(); + write_message(&mut initiator_wire, &reply).await.unwrap(); + + assert!(initiator.recv().await.is_err()); + } + + #[tokio::test] + async fn a_mismatched_prologue_fails_the_handshake() { + // A peer that folds a different marker into the pattern - which is what a marker tampered + // with in flight amounts to - cannot complete the handshake. + let params: NoiseParams = NOISE_PARAMS.parse().unwrap(); + let mut odd_one_out = Builder::new(params) + .prologue(b"a different marker") + .unwrap() + .local_private_key(&[0u8; DH_LEN]) + .unwrap() + .build_initiator() + .unwrap(); + + let (mut initiator_stream, responder_stream) = duplex(1024); + + let mut buffer = vec![0u8; MAX_NOISE_MSG_LEN]; + let len = odd_one_out.write_message(b"hint", &mut buffer).unwrap(); + buffer.truncate(len); + write_message(&mut initiator_stream, &buffer).await.unwrap(); + + // The responder accepts the first message, which carries nothing it could verify, and its + // reply is rejected in turn - exactly as with a tampered payload. + let mut responder = PendingSession::accept(responder_stream).await.unwrap().into_session().unwrap(); + responder.send(b"responder info").await.unwrap(); + + let reply = read_message(&mut initiator_stream).await.unwrap(); + assert!(odd_one_out.read_message(&reply, &mut vec![0u8; MAX_NOISE_MSG_LEN]).is_err()); + } + + #[tokio::test] + async fn a_session_leaves_bytes_that_follow_a_message_on_the_stream() { + let (mut initiator_stream, responder_stream) = duplex(1024); + let mut initiator = NoiseSession::new(&mut initiator_stream, Role::Initiator).unwrap(); + initiator.send(b"hint").await.unwrap(); + + // Whatever the peer pipelines behind a handshake message. Dropping the session first is only + // to release the borrow; the bytes are written to the same stream either way. + drop(initiator); + initiator_stream.write_all(b"pipelined").await.unwrap(); + + let pending = PendingSession::accept(responder_stream).await.unwrap(); + assert_eq!(pending.first_payload().unwrap(), b"hint"); + let responder = pending.into_session().unwrap(); + + // The session read its message and not a byte further, so the trailing bytes are still there + // for whoever takes the stream next - which is what lets the handshake hand a bare stream to + // a reader that builds a codec of its own. + let mut stream = responder.into_inner(); + let mut trailing = [0u8; 9]; + stream.read_exact(&mut trailing).await.unwrap(); + assert_eq!(&trailing, b"pipelined"); + } + + #[tokio::test] + async fn a_truncated_first_message_is_rejected() { + let (mut initiator_stream, responder_stream) = duplex(1024); + + // Shorter than the ephemeral key the pattern's first message must begin with. + write_message(&mut initiator_stream, &[0u8; DH_LEN - 1]).await.unwrap(); + + let pending = PendingSession::accept(responder_stream).await.unwrap(); + assert!(pending.first_payload().is_err()); + } + + #[tokio::test] + async fn an_oversized_message_length_is_rejected_before_it_is_allocated() { + let (mut initiator_stream, mut responder_stream) = duplex(1024); + + // A length prefix beyond what the specification permits, and no body behind it. + initiator_stream.write_all(&(MAX_NOISE_MSG_LEN as u32 + 1).to_le_bytes()).await.unwrap(); + + let error = read_message(&mut responder_stream).await.unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + } + + #[tokio::test] + async fn the_binding_message_is_domain_separated() { + let hash = [7u8; HANDSHAKE_HASH_LEN]; + + // A signature is only valid for one role of one subprotocol. + assert_ne!(binding_message(b"bft", Role::Initiator, &hash), binding_message(b"bft", Role::Responder, &hash)); + assert_ne!(binding_message(b"bft", Role::Initiator, &hash), binding_message(b"router", Role::Initiator, &hash)); + } + + #[tokio::test] + async fn the_noise_magic_is_detected() { + let (mut initiator_stream, mut responder_stream) = duplex(1024); + + write_noise_magic(&mut initiator_stream).await.unwrap(); + let (protocol, leftover) = detect_handshake_protocol(&mut responder_stream).await.unwrap(); + assert_eq!(protocol, HandshakeProtocol::Noise); + assert!(leftover.is_empty()); + } + + #[tokio::test] + async fn a_legacy_prefix_is_detected_and_returned() { + let (mut initiator_stream, mut responder_stream) = duplex(1024); + + // The length prefix of a legacy `ChallengeRequest` frame. + let legacy_prefix = 87u32.to_le_bytes(); + initiator_stream.write_all(&legacy_prefix).await.unwrap(); + + let (protocol, leftover) = detect_handshake_protocol(&mut responder_stream).await.unwrap(); + assert_eq!(protocol, HandshakeProtocol::Legacy); + assert_eq!(&leftover[..], &legacy_prefix[..]); + } + + #[test] + fn the_noise_magic_is_an_invalid_legacy_frame_length() { + // A legacy peer must reject the magic outright instead of waiting for a frame that will + // never arrive; its handshake codecs cap frames at 1 MiB. + const MAX_LEGACY_HANDSHAKE_FRAME_LEN: u32 = 1024 * 1024; + assert!(u32::from_le_bytes(NOISE_MAGIC) > MAX_LEGACY_HANDSHAKE_FRAME_LEN); + } +} From 14564349093618837f70969b49a84311e8b1e55d Mon Sep 17 00:00:00 2001 From: ljedrz Date: Tue, 28 Jul 2026 15:11:19 +0200 Subject: [PATCH 3/3] chore: update the lockfile Signed-off-by: ljedrz --- Cargo.lock | 160 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 159 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 7912d00069..9d523aa0cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.6", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -630,6 +665,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.1" @@ -641,6 +687,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -662,6 +721,17 @@ dependencies = [ "envmnt", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.6", + "inout", + "zeroize", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -1067,6 +1137,15 @@ dependencies = [ "phf", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -1827,6 +1906,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.32.3" @@ -2422,6 +2511,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "instability" version = "0.3.12" @@ -3134,6 +3232,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.4.0" @@ -3436,6 +3540,29 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.14.0" @@ -3694,7 +3821,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20", + "chacha20 0.10.1", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -5005,15 +5132,19 @@ version = "4.8.1" dependencies = [ "anyhow", "built", + "bytes", "locktick", "parking_lot", + "rand 0.10.2", "serde", "smol_str 0.3.2", "snarkos-node-network", "snarkos-node-tcp", "snarkvm", + "snow", "socket2 0.6.5", "tokio", + "tokio-util", "tracing", ] @@ -6139,6 +6270,23 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "snow" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "599b506ccc4aff8cf7844bc42cf783009a434c1e26c964432560fb6d6ad02d82" +dependencies = [ + "aes-gcm", + "blake2", + "chacha20poly1305", + "curve25519-dalek", + "getrandom 0.3.4", + "ring", + "rustc_version", + "sha2 0.10.9", + "subtle", +] + [[package]] name = "socket2" version = "0.5.10" @@ -7102,6 +7250,16 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.6", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0"