diff --git a/.github/wordlist.txt b/.github/wordlist.txt index 097487156..2a2be478a 100644 --- a/.github/wordlist.txt +++ b/.github/wordlist.txt @@ -47,4 +47,14 @@ Holesky Mainnet lifecycle Syncer - +JSON +Protobuf +Responder +responder +Prepends +Secp +NodeMetadata +NodeInfo +subnets +holesky +responder's \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 5afc7927f..f7fea0e25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -711,7 +711,7 @@ dependencies = [ name = "anchor" version = "0.1.0" dependencies = [ - "async-channel", + "async-channel 1.9.0", "bls", "clap", "client", @@ -1046,6 +1046,57 @@ dependencies = [ "futures-core", ] +[[package]] +name = "async-channel" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30ca9a001c1e8ba5149f91a74362376cc6bc5b919d92d988668657bd570bdcec" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcd09b382f40fcd159c2d695175b2ae620ffa5f3bd6f664131efff4e8b9e04a" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.3.1", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + [[package]] name = "async-io" version = "2.4.0" @@ -1076,6 +1127,80 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb" +dependencies = [ + "async-channel 2.3.1", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.0", + "futures-lite", + "rustix 0.38.44", + "tracing", +] + +[[package]] +name = "async-signal" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "637e00349800c0bdf8bfc21ebbc0b6524abea702b0da4168ac00d070d0c0b9f3" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 0.38.44", + "signal-hook-registry", + "slab", + "windows-sys 0.59.0", +] + +[[package]] +name = "async-std" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c634475f29802fde2b8f0b505b1bd00dfe4df7d4a000f0b36f7671197d5c3615" +dependencies = [ + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -1098,6 +1223,12 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.86" @@ -1362,6 +1493,19 @@ dependencies = [ "generic-array 0.14.7", ] +[[package]] +name = "blocking" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" +dependencies = [ + "async-channel 2.3.1", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bls" version = "0.2.0" @@ -2148,7 +2292,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1145d32e826a7748b69ee8fc62d3e6355ff7f1051df53141e7048162fc90481b" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.98", ] [[package]] @@ -3026,7 +3170,10 @@ version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532" dependencies = [ + "fastrand", "futures-core", + "futures-io", + "parking", "pin-project-lite", ] @@ -3193,12 +3340,24 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "gossipsub" version = "0.5.0" source = "git+https://github.com/sigp/lighthouse?rev=1a77f7a0#1a77f7a0609fc96d1cc2eb74da7fe90aef046352" dependencies = [ - "async-channel", + "async-channel 1.9.0", "asynchronous-codec", "base64 0.21.7", "byteorder", @@ -3914,6 +4073,7 @@ dependencies = [ "netlink-proto", "netlink-sys", "rtnetlink", + "smol", "system-configuration 0.6.1", "tokio", "windows", @@ -4118,6 +4278,15 @@ dependencies = [ "sha3-asm", ] +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + [[package]] name = "kzg" version = "0.1.0" @@ -4206,6 +4375,7 @@ dependencies = [ "libp2p-ping", "libp2p-plaintext", "libp2p-quic", + "libp2p-request-response", "libp2p-swarm", "libp2p-tcp", "libp2p-upnp", @@ -4502,12 +4672,33 @@ dependencies = [ "tracing", ] +[[package]] +name = "libp2p-request-response" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1356c9e376a94a75ae830c42cdaea3d4fe1290ba409a22c809033d1b7dcab0a6" +dependencies = [ + "async-trait", + "futures", + "futures-bounded", + "futures-timer", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "rand", + "smallvec", + "tracing", + "void", + "web-time", +] + [[package]] name = "libp2p-swarm" version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7dd6741793d2c1fb2088f67f82cf07261f25272ebe3c0b0c311e0c6b50e851a" dependencies = [ + "async-std", "either", "fnv", "futures", @@ -4538,12 +4729,32 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "libp2p-swarm-test" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea4e1d1d92421dc4c90cad42e3cd24f50fd210191c9f126d41bd483a09567f67" +dependencies = [ + "async-trait", + "futures", + "futures-timer", + "libp2p-core", + "libp2p-identity", + "libp2p-plaintext", + "libp2p-swarm", + "libp2p-tcp", + "libp2p-yamux", + "rand", + "tracing", +] + [[package]] name = "libp2p-tcp" version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad964f312c59dcfcac840acd8c555de8403e295d39edf96f5240048b5fcaa314" dependencies = [ + "async-io", "futures", "futures-timer", "if-watch", @@ -4772,6 +4983,9 @@ name = "log" version = "0.4.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" +dependencies = [ + "value-bag", +] [[package]] name = "logging" @@ -5131,6 +5345,7 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16c903aa70590cb93691bf97a767c8d1d6122d2cc9070433deb3bbf36ce8bd23" dependencies = [ + "async-io", "bytes", "futures", "libc", @@ -5142,7 +5357,8 @@ dependencies = [ name = "network" version = "0.1.0" dependencies = [ - "async-channel", + "async-channel 1.9.0", + "async-trait", "dirs 6.0.0", "discv5", "ethereum_ssz 0.8.2", @@ -5150,14 +5366,20 @@ dependencies = [ "futures", "hex", "libp2p", + "libp2p-swarm", + "libp2p-swarm-test", "lighthouse_network", + "parking_lot", + "quick-protobuf", "serde", + "serde_json", + "ssv_types", "ssz_types 0.10.0", "subnet_tracker", "task_executor", + "thiserror 1.0.69", "tokio", "tracing", - "types", "version", ] @@ -5645,6 +5867,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -5793,7 +6026,7 @@ dependencies = [ name = "processor" version = "0.1.0" dependencies = [ - "async-channel", + "async-channel 1.9.0", "futures", "metrics", "num_cpus", @@ -5945,7 +6178,7 @@ dependencies = [ name = "qbft_manager" version = "0.1.0" dependencies = [ - "async-channel", + "async-channel 1.9.0", "dashmap", "ethereum_ssz 0.7.1", "ethereum_ssz_derive 0.7.1", @@ -6414,6 +6647,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a552eb82d19f38c3beed3f786bd23aa434ceb9ac43ab44419ca6d67a7e186c0" dependencies = [ + "async-global-executor", "futures", "log", "netlink-packet-core", @@ -7143,6 +7377,23 @@ dependencies = [ "serde", ] +[[package]] +name = "smol" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33bd3e260892199c3ccfc487c88b2da2265080acb316cd920da72fdfd7c599f" +dependencies = [ + "async-channel 2.3.1", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-net", + "async-process", + "blocking", + "futures-lite", +] + [[package]] name = "snap" version = "1.1.1" @@ -7209,6 +7460,7 @@ dependencies = [ "enr", "eth2_network_config", "serde_yaml", + "ssv_types", ] [[package]] @@ -7511,7 +7763,7 @@ name = "task_executor" version = "0.1.0" source = "git+https://github.com/sigp/lighthouse?rev=1a77f7a0#1a77f7a0609fc96d1cc2eb74da7fe90aef046352" dependencies = [ - "async-channel", + "async-channel 1.9.0", "futures", "logging", "metrics", @@ -8293,6 +8545,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "value-bag" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ef4c4aa54d5d05a279399bfa921ec387b7aba77caf7a682ae8d86785b8fdad2" + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/anchor/client/src/config.rs b/anchor/client/src/config.rs index 29c811634..f972a11d4 100644 --- a/anchor/client/src/config.rs +++ b/anchor/client/src/config.rs @@ -16,7 +16,7 @@ pub const DEFAULT_EXECUTION_NODE_WS: &str = "ws://localhost:8545/"; /// The default Data directory, relative to the users home directory pub const DEFAULT_ROOT_DIR: &str = ".anchor"; /// Default network, used to partition the data storage -pub const DEFAULT_HARDCODED_NETWORK: &str = "mainnet"; +pub const DEFAULT_HARDCODED_NETWORK: &str = "holesky"; /// Base directory name for unnamed testnets passed through the --testnet-dir flag pub const CUSTOM_TESTNET_DIR: &str = "custom"; diff --git a/anchor/common/ssv_network_config/Cargo.toml b/anchor/common/ssv_network_config/Cargo.toml index e3d9a6c32..858eb9159 100644 --- a/anchor/common/ssv_network_config/Cargo.toml +++ b/anchor/common/ssv_network_config/Cargo.toml @@ -9,3 +9,4 @@ alloy = { workspace = true } enr = { workspace = true } eth2_network_config = { workspace = true } serde_yaml = { workspace = true } +ssv_types = { workspace = true } diff --git a/anchor/common/ssv_network_config/built_in_network_configs/holesky/ssv_domain_type.txt b/anchor/common/ssv_network_config/built_in_network_configs/holesky/ssv_domain_type.txt new file mode 100644 index 000000000..1e9292108 --- /dev/null +++ b/anchor/common/ssv_network_config/built_in_network_configs/holesky/ssv_domain_type.txt @@ -0,0 +1 @@ +00000502 \ No newline at end of file diff --git a/anchor/common/ssv_network_config/built_in_network_configs/mainnet/ssv_domain_type.txt b/anchor/common/ssv_network_config/built_in_network_configs/mainnet/ssv_domain_type.txt new file mode 100644 index 000000000..b86234fa1 --- /dev/null +++ b/anchor/common/ssv_network_config/built_in_network_configs/mainnet/ssv_domain_type.txt @@ -0,0 +1 @@ +00000001 \ No newline at end of file diff --git a/anchor/common/ssv_network_config/src/lib.rs b/anchor/common/ssv_network_config/src/lib.rs index 7ef0463ab..a5b52ad3e 100644 --- a/anchor/common/ssv_network_config/src/lib.rs +++ b/anchor/common/ssv_network_config/src/lib.rs @@ -1,6 +1,7 @@ use alloy::primitives::Address; use enr::{CombinedKey, Enr}; use eth2_network_config::Eth2NetworkConfig; +use ssv_types::domain_type::DomainType; use std::fs::File; use std::path::{Path, PathBuf}; use std::str::FromStr; @@ -22,6 +23,7 @@ macro_rules! get_hardcoded { include_str_for_net!($network, "ssv_boot_enr.yaml"), include_str_for_net!($network, "ssv_contract_address.txt"), include_str_for_net!($network, "ssv_contract_block.txt"), + include_str_for_net!($network, "ssv_domain_type.txt"), ) }; } @@ -32,11 +34,12 @@ pub struct SsvNetworkConfig { pub ssv_boot_nodes: Option>>, pub ssv_contract: Address, pub ssv_contract_block: u64, + pub ssv_domain_type: DomainType, } impl SsvNetworkConfig { pub fn constant(name: &str) -> Result, String> { - let (enr_yaml, address, block) = match name { + let (enr_yaml, address, block, domain_type) = match name { "mainnet" => get_hardcoded!(mainnet), "holesky" => get_hardcoded!(holesky), _ => return Ok(None), @@ -55,6 +58,9 @@ impl SsvNetworkConfig { ssv_contract_block: block .parse() .map_err(|_| "Unable to parse built-in block!")?, + ssv_domain_type: domain_type + .parse() + .map_err(|e| format!("Unable to parse built-in domain type: {}", e))?, })) } @@ -76,6 +82,7 @@ impl SsvNetworkConfig { ssv_boot_nodes, ssv_contract: read(&base_dir.join("ssv_contract_address.txt"))?, ssv_contract_block: read(&base_dir.join("ssv_contract_block.txt"))?, + ssv_domain_type: read(&base_dir.join("ssv_domain_type.txt"))?, eth2_network: Eth2NetworkConfig::load(base_dir)?, }) } diff --git a/anchor/common/ssv_types/src/domain_type.rs b/anchor/common/ssv_types/src/domain_type.rs new file mode 100644 index 000000000..b4a276782 --- /dev/null +++ b/anchor/common/ssv_types/src/domain_type.rs @@ -0,0 +1,24 @@ +use std::str::FromStr; + +#[derive(Clone, Debug, Default)] +pub struct DomainType(pub [u8; 4]); + +impl FromStr for DomainType { + type Err = String; + + fn from_str(hex_str: &str) -> Result { + let bytes = hex::decode(hex_str).map_err(|e| format!("Invalid domain type hex: {}", e))?; + if bytes.len() != 4 { + return Err("Domain type must be 4 bytes".into()); + } + let mut domain_type = [0; 4]; + domain_type.copy_from_slice(&bytes); + Ok(Self(domain_type)) + } +} + +impl From for String { + fn from(domain_type: DomainType) -> Self { + hex::encode(domain_type.0) + } +} diff --git a/anchor/common/ssv_types/src/lib.rs b/anchor/common/ssv_types/src/lib.rs index 31b8a9900..f3acc55c1 100644 --- a/anchor/common/ssv_types/src/lib.rs +++ b/anchor/common/ssv_types/src/lib.rs @@ -3,6 +3,7 @@ pub use operator::{Operator, OperatorId}; pub use share::Share; mod cluster; pub mod consensus; +pub mod domain_type; pub mod message; pub mod msgid; mod operator; diff --git a/anchor/network/Cargo.toml b/anchor/network/Cargo.toml index eac709ebf..0450fdf96 100644 --- a/anchor/network/Cargo.toml +++ b/anchor/network/Cargo.toml @@ -5,6 +5,7 @@ edition = { workspace = true } authors = ["Sigma Prime "] [dependencies] +async-trait = "0.1.85" dirs = { workspace = true } discv5 = { workspace = true } ethereum_ssz = "0.8.1" @@ -22,16 +23,24 @@ libp2p = { version = "0.54", default-features = false, features = [ "gossipsub", "quic", "ping", + "request-response", ] } lighthouse_network = { workspace = true } +parking_lot = { workspace = true } +quick-protobuf = "0.8.1" serde = { workspace = true } +serde_json = "1.0.137" +ssv_types = { workspace = true } ssz_types = "0.10" subnet_tracker = { workspace = true } task_executor = { workspace = true } +thiserror = "1.0.69" tokio = { workspace = true } tracing = { workspace = true } -types = { workspace = true } version = { workspace = true } [dev-dependencies] async-channel = { workspace = true } +libp2p-swarm = { version = "0.45.1", features = ["macros"] } +libp2p-swarm-test = { version = "0.4.0" } +tokio = { workspace = true, features = ["rt", "macros", "time"] } diff --git a/anchor/network/src/behaviour.rs b/anchor/network/src/behaviour.rs index baa2dd177..7f5364b00 100644 --- a/anchor/network/src/behaviour.rs +++ b/anchor/network/src/behaviour.rs @@ -1,4 +1,5 @@ use crate::discovery::Discovery; +use crate::handshake; use libp2p::swarm::NetworkBehaviour; use libp2p::{gossipsub, identify, ping}; @@ -12,4 +13,6 @@ pub struct AnchorBehaviour { pub gossipsub: gossipsub::Behaviour, /// Discv5 Discovery protocol. pub discovery: Discovery, + + pub handshake: handshake::Behaviour, } diff --git a/anchor/network/src/config.rs b/anchor/network/src/config.rs index 3587da760..99a87d2e8 100644 --- a/anchor/network/src/config.rs +++ b/anchor/network/src/config.rs @@ -2,7 +2,7 @@ use discv5::Enr; use libp2p::Multiaddr; use lighthouse_network::types::GossipKind; use lighthouse_network::{ListenAddr, ListenAddress}; -use serde::{Deserialize, Serialize}; +use ssv_types::domain_type::DomainType; use std::net::{Ipv4Addr, Ipv6Addr}; use std::num::NonZeroU16; use std::path::PathBuf; @@ -16,7 +16,7 @@ pub const DEFAULT_DISC_PORT: u16 = 9100u16; pub const DEFAULT_QUIC_PORT: u16 = 9101u16; /// Configuration for setting up the p2p network. -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone)] pub struct Config { /// Data directory where node's keyfile is stored pub network_dir: PathBuf, @@ -66,6 +66,8 @@ pub struct Config { /// Target number of connected peers. pub target_peers: usize, + + pub domain_type: DomainType, } impl Default for Config { @@ -100,6 +102,7 @@ impl Default for Config { disable_discovery: false, disable_quic_support: false, topics: vec![], + domain_type: DomainType::default(), } } } diff --git a/anchor/network/src/handshake/envelope/codec.rs b/anchor/network/src/handshake/envelope/codec.rs new file mode 100644 index 000000000..7c7e7bf49 --- /dev/null +++ b/anchor/network/src/handshake/envelope/codec.rs @@ -0,0 +1,102 @@ +use crate::handshake::envelope; +use crate::handshake::envelope::{parse_envelope, Envelope}; +use async_trait::async_trait; +use futures::{AsyncReadExt, AsyncWriteExt}; +use libp2p::futures::{AsyncRead, AsyncWrite}; +use libp2p::{request_response, StreamProtocol}; +use std::io; +use tracing::debug; + +const MAXIMUM_SIZE: u64 = 1024; + +impl From for io::Error { + fn from(err: envelope::Error) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, err) + } +} + +/// A `Codec` that reads/writes an **`Envelope`**. +#[derive(Clone, Debug, Default)] +pub struct Codec; + +#[async_trait] +impl request_response::Codec for Codec { + type Protocol = StreamProtocol; + type Request = Envelope; + type Response = Envelope; + + async fn read_request( + &mut self, + _protocol: &Self::Protocol, + io: &mut T, + ) -> io::Result + where + T: AsyncRead + Unpin + Send, + { + debug!("reading handsake request"); + let mut msg_buf = Vec::new(); + let num_bytes_read = io.take(MAXIMUM_SIZE).read_to_end(&mut msg_buf).await?; + // TODO potentially try to read one more byte here and create a "message too large error" + debug!(?num_bytes_read, "read handshake request"); + let env = Envelope::decode_from_slice(&msg_buf)?; + debug!(?env, "decoded handshake request"); + Ok(env) + } + + async fn read_response( + &mut self, + _protocol: &Self::Protocol, + io: &mut T, + ) -> io::Result + where + T: AsyncRead + Unpin + Send, + { + debug!("reading handshake response"); + let mut msg_buf = Vec::new(); + // We don't need a varint here because we always read only one message in the protocol. + // In this way we can just read until the end of the stream. + let num_bytes_read = io.take(MAXIMUM_SIZE).read_to_end(&mut msg_buf).await?; + debug!(?num_bytes_read, "read handshake response"); + + let env = parse_envelope(&msg_buf)?; + + debug!(?env, "decoded handshake response"); + Ok(env) + } + + async fn write_request( + &mut self, + _protocol: &Self::Protocol, + io: &mut T, + req: Self::Request, + ) -> io::Result<()> + where + T: AsyncWrite + Unpin + Send, + { + debug!(req = ?req, "writing handshake request"); + let raw = req.encode_to_vec()?; + io.write_all(&raw).await?; + io.close().await?; + debug!("wrote handshake request"); + Ok(()) + } + + async fn write_response( + &mut self, + _protocol: &Self::Protocol, + io: &mut T, + res: Self::Response, + ) -> io::Result<()> + where + T: AsyncWrite + Unpin + Send, + { + debug!("writing handshake response"); + let raw = res + .encode_to_vec() + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + io.write_all(&raw).await?; + io.close().await?; + debug!("wrote handshake response"); + Ok(()) + } +} diff --git a/anchor/network/src/handshake/envelope/generated/message.proto b/anchor/network/src/handshake/envelope/generated/message.proto new file mode 100644 index 000000000..2e1f668f2 --- /dev/null +++ b/anchor/network/src/handshake/envelope/generated/message.proto @@ -0,0 +1,8 @@ +syntax = "proto3"; + +message Envelope { + bytes public_key = 1; + bytes payload_type = 2; + bytes payload = 3; + bytes signature = 5; +} diff --git a/anchor/network/src/handshake/envelope/generated/message/mod.rs b/anchor/network/src/handshake/envelope/generated/message/mod.rs new file mode 100644 index 000000000..ffaf2083e --- /dev/null +++ b/anchor/network/src/handshake/envelope/generated/message/mod.rs @@ -0,0 +1 @@ +pub mod pb; diff --git a/anchor/network/src/handshake/envelope/generated/message/pb.rs b/anchor/network/src/handshake/envelope/generated/message/pb.rs new file mode 100644 index 000000000..9f7746dee --- /dev/null +++ b/anchor/network/src/handshake/envelope/generated/message/pb.rs @@ -0,0 +1,57 @@ +// Automatically generated rust module for 'envelope.proto' file + +#![allow(non_snake_case)] +#![allow(non_upper_case_globals)] +#![allow(non_camel_case_types)] +#![allow(unused_imports)] +#![allow(unknown_lints)] +#![allow(clippy::all)] +#![cfg_attr(rustfmt, rustfmt_skip)] + +use quick_protobuf::{MessageInfo, MessageRead, MessageWrite, BytesReader, Writer, WriterBackend, Result}; +use quick_protobuf::sizeofs::*; +use super::*; + +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Debug, Default, PartialEq, Clone)] +pub struct Envelope { + pub public_key: Vec, + pub payload_type: Vec, + pub payload: Vec, + pub signature: Vec, +} + +impl<'a> MessageRead<'a> for Envelope { + fn from_reader(r: &mut BytesReader, bytes: &'a [u8]) -> Result { + let mut msg = Self::default(); + while !r.is_eof() { + match r.next_tag(bytes) { + Ok(10) => msg.public_key = r.read_bytes(bytes)?.to_owned(), + Ok(18) => msg.payload_type = r.read_bytes(bytes)?.to_owned(), + Ok(26) => msg.payload = r.read_bytes(bytes)?.to_owned(), + Ok(42) => msg.signature = r.read_bytes(bytes)?.to_owned(), + Ok(t) => { r.read_unknown(bytes, t)?; } + Err(e) => return Err(e), + } + } + Ok(msg) + } +} + +impl MessageWrite for Envelope { + fn write_message(&self, w: &mut Writer) -> Result<()> { + if !self.public_key.is_empty() { w.write_with_tag(10, |w| w.write_bytes(&**&self.public_key))?; } + if !self.payload_type.is_empty() { w.write_with_tag(18, |w| w.write_bytes(&**&self.payload_type))?; } + if !self.payload.is_empty() { w.write_with_tag(26, |w| w.write_bytes(&**&self.payload))?; } + if !self.signature.is_empty() { w.write_with_tag(42, |w| w.write_bytes(&**&self.signature))?; } + Ok(()) + } + + fn get_size(&self) -> usize { + 0 + + if self.public_key.is_empty() { 0 } else { 1 + sizeof_len((&self.public_key).len()) } + + if self.payload_type.is_empty() { 0 } else { 1 + sizeof_len((&self.payload_type).len()) } + + if self.payload.is_empty() { 0 } else { 1 + sizeof_len((&self.payload).len()) } + + if self.signature.is_empty() { 0 } else { 1 + sizeof_len((&self.signature).len()) } + } +} diff --git a/anchor/network/src/handshake/envelope/generated/mod.rs b/anchor/network/src/handshake/envelope/generated/mod.rs new file mode 100644 index 000000000..e216a5018 --- /dev/null +++ b/anchor/network/src/handshake/envelope/generated/mod.rs @@ -0,0 +1 @@ +pub mod message; diff --git a/anchor/network/src/handshake/envelope/mod.rs b/anchor/network/src/handshake/envelope/mod.rs new file mode 100644 index 000000000..96d8a1d51 --- /dev/null +++ b/anchor/network/src/handshake/envelope/mod.rs @@ -0,0 +1,76 @@ +mod codec; +mod generated; + +use crate::handshake::node_info::NodeInfo; +use discv5::libp2p_identity::PublicKey; +use libp2p::identity::DecodingError; +use quick_protobuf::{BytesReader, Error as ProtoError, MessageRead, MessageWrite, Writer}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("Coding error: {0}")] + Coding(#[from] ProtoError), // Automatically implements `From for Error` + + #[error("Public Key Decoding error: {0}")] + PublicKeyDecoding(#[from] DecodingError), + + #[error("Signature Verification error: {0}")] + SignatureVerification(String), +} + +impl Envelope { + /// Encode the Envelope to a Protobuf byte array (like `proto.Marshal` in Go). + pub fn encode_to_vec(&self) -> Result, Error> { + let mut buf = Vec::new(); + let mut writer = Writer::new(&mut buf); + self.write_message(&mut writer)?; + Ok(buf) + } + + /// Decode an Envelope from a Protobuf byte array (like `proto.Unmarshal` in Go). + pub fn decode_from_slice(data: &[u8]) -> Result { + let mut reader = BytesReader::from_bytes(data); + let env = Envelope::from_reader(&mut reader, data).map_err(Error::Coding)?; + Ok(env) + } +} + +/// Decodes an Envelope and verify signature. +pub fn parse_envelope(bytes: &[u8]) -> Result { + let env = Envelope::decode_from_slice(bytes)?; + + let domain = NodeInfo::DOMAIN; + let payload_type = NodeInfo::CODEC; + + let unsigned = make_unsigned(domain.as_bytes(), payload_type, &env.payload); + + let pk = PublicKey::try_decode_protobuf(&env.public_key.to_vec())?; + + if !pk.verify(&unsigned?, &env.signature) { + return Err(SignatureVerification( + "signature verification failed".into(), + )); + } + + Ok(env) +} + +pub fn make_unsigned( + domain: &[u8], + payload_type: &[u8], + payload: &[u8], +) -> Result, ProtoError> { + let mut buf = Vec::new(); + { + let mut writer = Writer::new(&mut buf); + writer.write_bytes(domain)?; + writer.write_bytes(payload_type)?; + writer.write_bytes(payload)?; + } + Ok(buf) +} + +use crate::handshake::envelope::Error::SignatureVerification; +pub use codec::Codec; +pub use generated::message::pb::Envelope; diff --git a/anchor/network/src/handshake/mod.rs b/anchor/network/src/handshake/mod.rs new file mode 100644 index 000000000..e4029fa60 --- /dev/null +++ b/anchor/network/src/handshake/mod.rs @@ -0,0 +1,334 @@ +mod envelope; +pub mod node_info; + +use crate::handshake::envelope::Codec; +use crate::handshake::envelope::Envelope; +use crate::handshake::node_info::NodeInfo; +use crate::network::NodeInfoManager; +use discv5::libp2p_identity::Keypair; +use discv5::multiaddr::Multiaddr; +use libp2p::core::transport::PortUse; +use libp2p::core::{ConnectedPoint, Endpoint}; +use libp2p::request_response::{ + self, Behaviour as RequestResponseBehaviour, Config, Event as RequestResponseEvent, + InboundFailure, OutboundFailure, ProtocolSupport, ResponseChannel, +}; +use libp2p::swarm::{ + ConnectionDenied, ConnectionId, FromSwarm, NetworkBehaviour, THandler, THandlerInEvent, + THandlerOutEvent, ToSwarm, +}; +use libp2p::{PeerId, StreamProtocol}; +use std::task::{Context, Poll}; +use tracing::debug; + +#[derive(Debug)] +pub enum Error { + NetworkMismatch { ours: String, theirs: String }, + NodeInfo(node_info::Error), + Inbound(InboundFailure), + Outbound(OutboundFailure), +} + +/// Event emitted on handshake completion or failure. +#[derive(Debug)] +pub enum Event { + Completed { + peer_id: PeerId, + their_info: NodeInfo, + }, + Failed { + peer_id: PeerId, + error: Error, + }, +} + +/// Network behaviour handling the handshake protocol. +pub struct Behaviour { + /// Request-response behaviour for the handshake protocol. + pub(crate) behaviour: RequestResponseBehaviour, + /// Keypair for signing envelopes. + keypair: Keypair, + /// Local node's information provider. + node_info_manager: NodeInfoManager, + /// Events to emit. + events: Vec, +} + +impl Behaviour { + pub fn new(keypair: Keypair, local_node_info: NodeInfoManager) -> Self { + // NodeInfoProtocol is the protocol.ID used for handshake + const NODE_INFO_PROTOCOL: &str = "/ssv/info/0.0.1"; + + let protocol = StreamProtocol::new(NODE_INFO_PROTOCOL); + let behaviour = + RequestResponseBehaviour::new([(protocol, ProtocolSupport::Full)], Config::default()); + + Self { + behaviour, + keypair, + node_info_manager: local_node_info, + events: Vec::new(), + } + } + + /// Create a signed envelope containing local node info. + fn sealed_node_record(&self) -> Envelope { + let node_info = self.node_info_manager.get_node_info(); + node_info.seal(&self.keypair).unwrap() + } + + fn verify_node_info(&mut self, node_info: &NodeInfo) -> Result<(), Error> { + let ours = self.node_info_manager.get_node_info().network_id; + if node_info.network_id != *ours { + return Err(Error::NetworkMismatch { + ours, + theirs: node_info.network_id.clone(), + }); + } + Ok(()) + } + + fn handle_handshake_request( + &mut self, + peer_id: PeerId, + request: Envelope, + channel: ResponseChannel, + ) { + // Handle incoming request: send response then verify + let response = self.sealed_node_record(); + let _ = self.behaviour.send_response(channel, response.clone()); // Any error here is handled by the InboundFailure handler + + self.unmarshall_and_verify(peer_id, &request); + } + + fn handle_handshake_response(&mut self, peer_id: PeerId, response: &Envelope) { + self.unmarshall_and_verify(peer_id, response); + } + + fn unmarshall_and_verify(&mut self, peer_id: PeerId, envelope: &Envelope) { + let mut their_info = NodeInfo::default(); + + if let Err(e) = their_info.unmarshal(&envelope.payload) { + self.events.push(Event::Failed { + peer_id, + error: Error::NodeInfo(e), + }); + } + + match self.verify_node_info(&their_info) { + Ok(_) => self.events.push(Event::Completed { + peer_id, + their_info, + }), + Err(e) => self.events.push(Event::Failed { peer_id, error: e }), + } + } +} + +impl NetworkBehaviour for Behaviour { + type ConnectionHandler = + as NetworkBehaviour>::ConnectionHandler; + type ToSwarm = Event; + + fn handle_established_inbound_connection( + &mut self, + connection_id: ConnectionId, + peer: PeerId, + local_addr: &Multiaddr, + remote_addr: &Multiaddr, + ) -> Result, ConnectionDenied> { + self.behaviour.handle_established_inbound_connection( + connection_id, + peer, + local_addr, + remote_addr, + ) + } + + fn handle_established_outbound_connection( + &mut self, + connection_id: ConnectionId, + peer: PeerId, + addr: &Multiaddr, + role_override: Endpoint, + port_use: PortUse, + ) -> Result, ConnectionDenied> { + self.behaviour.handle_established_outbound_connection( + connection_id, + peer, + addr, + role_override, + port_use, + ) + } + + fn on_swarm_event(&mut self, event: FromSwarm) { + // Initiate handshake on new connection + if let FromSwarm::ConnectionEstablished(conn_est) = &event { + // Only send handshake request if we initiated the connection (outbound) + if let ConnectedPoint::Dialer { .. } = conn_est.endpoint { + let peer = conn_est.peer_id; + let request = self.sealed_node_record(); + self.behaviour.send_request(&peer, request); + } + } + + // Delegate other events to inner behaviour + self.behaviour.on_swarm_event(event); + } + + fn on_connection_handler_event( + &mut self, + peer_id: PeerId, + connection_id: ConnectionId, + event: THandlerOutEvent, + ) { + self.behaviour + .on_connection_handler_event(peer_id, connection_id, event); + } + + fn poll( + &mut self, + cx: &mut Context<'_>, + ) -> Poll>> { + // Process events from inner request-response behaviour + while let Poll::Ready(event) = self.behaviour.poll(cx) { + match event { + ToSwarm::GenerateEvent(event) => match event { + RequestResponseEvent::Message { + peer, + message: + request_response::Message::Request { + request, channel, .. + }, + } => { + debug!("Received handshake request"); + self.handle_handshake_request(peer, request, channel); + } + RequestResponseEvent::Message { + peer, + message: request_response::Message::Response { response, .. }, + } => { + debug!(?response, "Received handshake response"); + self.handle_handshake_response(peer, &response); + } + RequestResponseEvent::OutboundFailure { peer, error, .. } => { + self.events.push(Event::Failed { + peer_id: peer, + error: Error::Outbound(error), + }); + } + RequestResponseEvent::InboundFailure { peer, error, .. } => { + self.events.push(Event::Failed { + peer_id: peer, + error: Error::Inbound(error), + }); + } + _ => {} + }, + other => { + // Bubble up all other ToSwarm events. The closure is unreachable because we already handled GenerateEvent + return Poll::Ready( + other.map_out(|_| unreachable!("We already handled GenerateEvent")), + ); + } + } + } + + // Emit queued events + if !self.events.is_empty() { + return Poll::Ready(ToSwarm::GenerateEvent(self.events.remove(0))); + } + + Poll::Pending + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::handshake::node_info::NodeMetadata; + use discv5::libp2p_identity::Keypair; + use libp2p_swarm::Swarm; + use libp2p_swarm_test::{drive, SwarmExt}; + + fn node_info(network: &str, version: &str) -> NodeInfo { + NodeInfo { + network_id: network.to_string(), + metadata: Some(NodeMetadata { + node_version: version.to_string(), + execution_node: "".to_string(), + consensus_node: "".to_string(), + subnets: "".to_string(), + }), + } + } + + fn test_behaviour(network: &str, version: &str, keypair: Keypair) -> Behaviour { + let node_info_manager = NodeInfoManager::new(node_info(network, version)); + Behaviour::new(keypair, node_info_manager) + } + + #[tokio::test] + async fn handshake_success() { + let local_key = Keypair::generate_ed25519(); + let remote_key = Keypair::generate_ed25519(); + + let mut local_swarm = Swarm::new_ephemeral(|_| test_behaviour("test", "local", local_key)); + let mut remote_swarm = + Swarm::new_ephemeral(|_| test_behaviour("test", "remote", remote_key.clone())); + + tokio::spawn(async move { + local_swarm.listen().with_memory_addr_external().await; + + remote_swarm.connect(&mut local_swarm).await; + + // Drive the swarm until the handshake completes + let ([local_event], [remote_event]): ([Event; 1], [Event; 1]) = + drive(&mut local_swarm, &mut remote_swarm).await; + + assert!(matches!(local_event, + Event::Completed { peer_id, ref their_info } if peer_id == *remote_swarm.local_peer_id() && their_info.metadata.as_ref().unwrap().node_version == "remote") + ); + + assert!(matches!(remote_event, + Event::Completed { peer_id, ref their_info } if peer_id == *local_swarm.local_peer_id() && their_info.metadata.as_ref().unwrap().node_version == "local") + ); + }) + .await + .expect("tokio runtime failed"); + } + + #[tokio::test] + async fn mismatched_networks_handshake_failed() { + let local_key = Keypair::generate_ed25519(); + let remote_key = Keypair::generate_ed25519(); + + let mut local_swarm = Swarm::new_ephemeral(|_| test_behaviour("test1", "local", local_key)); + let mut remote_swarm = + Swarm::new_ephemeral(|_| test_behaviour("test2", "remote", remote_key.clone())); + + tokio::spawn(async move { + local_swarm.listen().with_memory_addr_external().await; + + remote_swarm.connect(&mut local_swarm).await; + + // Drive the swarm until the handshake completes + let ([local_event], [remote_event]): ([Event; 1], [Event; 1]) = + drive(&mut local_swarm, &mut remote_swarm).await; + + assert!(matches!(remote_event, + Event::Failed { peer_id, error } if peer_id == *local_swarm.local_peer_id() + && matches!( + error, Error::NetworkMismatch { ref ours , ref theirs } if ours.as_str() == "test2" && theirs.as_str() == "test1")) + ); + + assert!(matches!(local_event, + Event::Failed { peer_id, error } if peer_id == *remote_swarm.local_peer_id() + && matches!(error, Error::NetworkMismatch { ref ours, ref theirs } if ours.as_str() == "test1" && theirs.as_str() == "test2")) + ); + }) + .await + .expect("tokio runtime failed"); + } +} diff --git a/anchor/network/src/handshake/node_info.rs b/anchor/network/src/handshake/node_info.rs new file mode 100644 index 000000000..78a8ba0b8 --- /dev/null +++ b/anchor/network/src/handshake/node_info.rs @@ -0,0 +1,201 @@ +use crate::handshake::envelope::{make_unsigned, Envelope}; +use discv5::libp2p_identity::{Keypair, SigningError}; +use serde::{Deserialize, Serialize}; +use serde_json; + +use crate::handshake::node_info::Error::Validation; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("Serialization error: {0}")] + Serialization(#[from] serde_json::Error), + + #[error("Seal error: {0}")] + Seal(#[from] SigningError), + + #[error("Validation error: {0}")] + Validation(String), +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] +pub struct NodeMetadata { + #[serde(rename = "NodeVersion")] + pub node_version: String, + #[serde(rename = "ExecutionNode")] + pub execution_node: String, + #[serde(rename = "ConsensusNode")] + pub consensus_node: String, + #[serde(rename = "Subnets")] + pub subnets: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] +pub struct NodeInfo { + pub network_id: String, + pub metadata: Option, +} + +// This is the direct Rust equivalent to Go 'serializable' struct +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +struct Serializable { + #[serde(rename = "Entries")] + entries: Vec, +} + +impl NodeInfo { + pub fn new(network_id: String, metadata: Option) -> Self { + NodeInfo { + network_id, + metadata, + } + } + + pub(crate) const DOMAIN: &'static str = "ssv"; + + pub(crate) const CODEC: &'static [u8] = b"ssv/nodeinfo"; + + /// Serialize `NodeInfo` to JSON bytes. + fn marshal(&self) -> Result, Error> { + let mut entries = vec![ + "".to_string(), // formerly forkVersion, now deprecated + self.network_id.clone(), // network id + ]; + + if let Some(meta) = &self.metadata { + entries.push(serde_json::to_string(meta)?); + } + + // Serialize as JSON + let ser = Serializable { entries }; + let data = serde_json::to_vec(&ser)?; + Ok(data) + } + + /// Deserialize `NodeInfo` from JSON bytes, replacing `self`. + pub fn unmarshal(&mut self, data: &[u8]) -> Result<(), Error> { + let ser: Serializable = serde_json::from_slice(data)?; + if ser.entries.len() < 2 { + return Err(Validation("node info must have at least 2 entries".into())); + } + // skip ser.entries[0]: old forkVersion + self.network_id = ser.entries[1].clone(); + if ser.entries.len() >= 3 { + let meta = serde_json::from_slice(ser.entries[2].as_bytes())?; + self.metadata = Some(meta); + } + Ok(()) + } + + /// Seals a `Record` into an Envelope by: + /// 1) marshalling record to bytes, + /// 2) building "unsigned" data (domain + codec + payload), + /// 3) signing, + /// 4) storing into `Envelope`. + pub fn seal(&self, keypair: &Keypair) -> Result { + let domain = Self::DOMAIN; + let payload_type = Self::CODEC; + + let raw_payload = self.marshal()?; + + let unsigned = make_unsigned(domain.as_bytes(), payload_type, &raw_payload).unwrap(); + + let sig = keypair.sign(&unsigned)?; + + let env = Envelope { + public_key: keypair.public().encode_protobuf(), + payload_type: payload_type.to_vec(), + payload: raw_payload, + signature: sig, + }; + Ok(env) + } +} + +#[cfg(test)] +mod tests { + use crate::handshake::envelope::parse_envelope; + use crate::handshake::node_info::{NodeInfo, NodeMetadata}; + use libp2p::identity::Keypair; + + #[test] + fn test_node_info_seal_consume() { + // Create a sample NodeInfo instance + let node_info = NodeInfo::new( + "holesky".to_string(), + Some(NodeMetadata { + node_version: "geth/x".to_string(), + execution_node: "geth/x".to_string(), + consensus_node: "prysm/x".to_string(), + subnets: "00000000000000000000000000000000".to_string(), + }), + ); + + // Marshal the NodeInfo into bytes and wrap it into an Envelope + let envelope = node_info + .seal(&Keypair::generate_secp256k1()) + .expect("Seal failed"); + + let data = envelope.encode_to_vec().unwrap(); + + let parsed_env = parse_envelope(&data).expect("Consume failed"); + let mut parsed_node_info = NodeInfo::default(); + parsed_node_info + .unmarshal(&parsed_env.payload) + .expect("TODO: panic message"); + + assert_eq!(node_info, parsed_node_info); + + let encoded= + hex::decode("0a250802122102ba6a707dcec6c60ba2793d52123d34b22556964fc798d4aa88ffc41\ + a00e42407120c7373762f6e6f6465696e666f1aa5017b22456e7472696573223a5b22222c22686f6c65736b7\ + 9222c227b5c224e6f646556657273696f6e5c223a5c22676574682f785c222c5c22457865637574696f6e4e6f64655c223a5c22676574682f785c222c5c22436f6e73656e7375734e6f64655c223a5c22707279736d2f785c222c5c225375626e6574735c223a5c2230303030303030303030303030303030303030303030303030303030303030305c227d225d7d2a473045022100b8a2a668113330369e74b86ec818a87009e2a351f7ee4c0e431e1f659dd1bc3f02202b1ebf418efa7fb0541f77703bea8563234a1b70b8391d43daa40b6e7c3fcc84").unwrap(); + + let parsed_env = parse_envelope(&encoded).expect("Consume failed"); + let mut parsed_node_info = NodeInfo::default(); + parsed_node_info + .unmarshal(&parsed_env.payload) + .expect("TODO: panic message"); + + assert_eq!(node_info, parsed_node_info); + } + + #[test] + fn test_node_info_marshal_unmarshal() { + // The old serialized data from the Go code + // (note the "Subnets":"ffffffffffffffffffffffffffffffff") + let old_serialized_data = br#"{"Entries":["", "testnet", "{\"NodeVersion\":\"v0.1.12\",\"ExecutionNode\":\"geth/x\",\"ConsensusNode\":\"prysm/x\",\"Subnets\":\"ffffffffffffffffffffffffffffffff\"}"]}"#; + + // The "current" NodeInfo data + let current_data = NodeInfo { + network_id: "testnet".to_string(), + metadata: Some(NodeMetadata { + node_version: "v0.1.12".into(), + execution_node: "geth/x".into(), + consensus_node: "prysm/x".into(), + subnets: "ffffffffffffffffffffffffffffffff".into(), + }), + }; + + // 1) Marshal current_data + let data = current_data + .marshal() + .expect("marshal_record should succeed"); + + // 2) Unmarshal into parsed_rec + let mut parsed_rec = NodeInfo::default(); + parsed_rec + .unmarshal(&data) + .expect("unmarshal_record should succeed"); + + // 3) Now unmarshal the old format data into the same struct + parsed_rec + .unmarshal(old_serialized_data) + .expect("unmarshal old data should succeed"); + + // 4) Compare + // The Go test checks reflect.DeepEqual(currentSerializedData, parsedRec) + // We can do the same in Rust using assert_eq. + assert_eq!(current_data, parsed_rec); + } +} diff --git a/anchor/network/src/lib.rs b/anchor/network/src/lib.rs index 49ad72200..44b096aa1 100644 --- a/anchor/network/src/lib.rs +++ b/anchor/network/src/lib.rs @@ -3,11 +3,11 @@ mod behaviour; mod config; mod discovery; +mod handshake; mod keypair_utils; mod network; mod transport; pub mod types; - pub use config::Config; pub use lighthouse_network::{ListenAddr, ListenAddress}; pub use network::Network; diff --git a/anchor/network/src/network.rs b/anchor/network/src/network.rs index 06e7ca6b7..1be3adbd6 100644 --- a/anchor/network/src/network.rs +++ b/anchor/network/src/network.rs @@ -1,5 +1,6 @@ use std::num::{NonZeroU8, NonZeroUsize}; use std::pin::Pin; +use std::sync::Arc; use std::time::Duration; use futures::StreamExt; @@ -22,8 +23,11 @@ use crate::keypair_utils::load_private_key; use crate::transport::build_transport; use crate::Config; +use crate::handshake::node_info::{NodeInfo, NodeMetadata}; +use crate::handshake::{Behaviour, Event}; use crate::types::ssv_message::SignedSSVMessage; use lighthouse_network::EnrExt; +use parking_lot::RwLock; use ssz::Decode; use subnet_tracker::{SubnetEvent, SubnetId}; use tokio::sync::mpsc; @@ -153,6 +157,9 @@ impl Network { } } } + AnchorBehaviourEvent::Handshake(ev) => { + handle_handshake_event(ev); + } // TODO handle other behaviour events _ => { debug!(event = ?behaviour_event, "Unhandled behaviour event"); @@ -212,6 +219,21 @@ fn subnet_to_topic(subnet: SubnetId) -> IdentTopic { IdentTopic::new(format!("ssv.{}", *subnet)) } +fn handle_handshake_event(ev: Event) { + match ev { + Event::Completed { + peer_id, + their_info, + } => { + debug!(%peer_id, ?their_info, "Handshake completed"); + // Update peer store with their_info + } + Event::Failed { peer_id, error } => { + debug!(%peer_id, ?error, "Handshake failed"); + } + } +} + async fn build_anchor_behaviour( local_keypair: Keypair, network_config: &Config, @@ -266,11 +288,24 @@ async fn build_anchor_behaviour( discovery }; + let domain_type: String = network_config.clone().domain_type.into(); + let node_info = NodeInfo::new( + domain_type, + Some(NodeMetadata { + node_version: "1.0.0".to_string(), + execution_node: "geth/v1.10.8".to_string(), + consensus_node: "lighthouse/v1.5.0".to_string(), + subnets: "ffffffffffffffffffffffffffffffff".to_string(), + }), + ); + let handshake = Behaviour::new(local_keypair.clone(), NodeInfoManager::new(node_info)); + AnchorBehaviour { identify, ping: ping::Behaviour::default(), gossipsub, discovery, + handshake, } } @@ -327,6 +362,26 @@ fn build_swarm( .build() } +pub struct NodeInfoManager { + node_info: Arc>, +} + +impl NodeInfoManager { + pub fn new(node_info: NodeInfo) -> Self { + Self { + node_info: Arc::new(RwLock::new(node_info)), + } + } + + pub fn get_node_info(&self) -> NodeInfo { + self.node_info.read().clone() + } + + pub fn set_node_info(&self, node_info: NodeInfo) { + *self.node_info.write() = node_info; + } +} + #[cfg(test)] mod test { use crate::network::Network; diff --git a/anchor/src/main.rs b/anchor/src/main.rs index dd4cb8266..d38b648b3 100644 --- a/anchor/src/main.rs +++ b/anchor/src/main.rs @@ -22,13 +22,14 @@ fn main() { // Currently the only binary is the client. We build the client config, but later this will // generalise to other sub commands // Build the client config - let config = match config::from_cli(&anchor_config) { + let mut config = match config::from_cli(&anchor_config) { Ok(config) => config, Err(e) => { error!(e, "Unable to initialize configuration"); return; } }; + config.network.domain_type = config.ssv_network.ssv_domain_type.clone(); // Build the core task executor let core_executor = environment.executor(); diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 4843bd51d..ce6d933c7 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -7,4 +7,5 @@ - [Development Environment](./setup.md) - [Contributing](./contributing.md) - [Protocol Developers](./developers.md) + - [SSV Handshake Protocol](./handshake.md) - [Architecture](./architecture.md) diff --git a/book/src/developers.md b/book/src/developers.md index 819edcaa7..a8a562830 100644 --- a/book/src/developers.md +++ b/book/src/developers.md @@ -4,3 +4,7 @@ _Documentation for protocol developers._ This section lists Anchor-specific decisions that are not strictly spec'd and may be useful for other protocol developers wishing to interact with Anchor. + +## SSV NodeInfo Handshake Protocol + +The protocol is used by SSV-based nodes to exchange basic node metadata and validate each other's identity when establishing a connection over Libp2p under a dedicated protocol ID. The spec is define here in the [SSV NodeInfo Handshake Protocol](./handshake.md). diff --git a/book/src/handshake.md b/book/src/handshake.md new file mode 100644 index 000000000..4adeda71f --- /dev/null +++ b/book/src/handshake.md @@ -0,0 +1,276 @@ +# SSV NodeInfo Handshake Protocol Specification + +This document specifies the **SSV NodeInfo Handshake Protocol**. The protocol is used by SSV-based nodes to exchange basic node metadata and validate each other's identity when establishing a connection over Libp2p under a dedicated protocol ID. + +--- + +## Table of Contents + +- [1. Introduction](#1-introduction) +- [2. Definitions](#2-definitions) + - [2.1 Terminology](#21-terminology) + - [2.2 Domain Separation](#22-domain-separation) +- [3. Protocol Constants](#3-protocol-constants) +- [4. Data Structures](#4-data-structures) + - [4.1 Envelope](#41-envelope) + - [4.2 NodeInfo](#42-nodeinfo) + - [4.3 NodeMetadata](#43-nodemetadata) +- [5. Serialization and Signing](#5-serialization-and-signing) + - [5.1 Envelope Fields](#51-envelope-fields) + - [5.2 NodeInfo JSON Layout](#52-nodeinfo-json-layout) + - [5.3 Signature Preparation](#53-signature-preparation) +- [6. Handshake Protocol Flows](#6-handshake-protocol-flows) + - [6.1 Protocol ID](#61-protocol-id) + - [6.2 Request Phase](#62-request-phase) + - [6.3 Response Phase](#63-response-phase) + - [6.4 Network Mismatch Checks](#64-network-mismatch-checks) +- [7. Security Considerations](#7-security-considerations) +- [8. Rationale and Notes](#8-rationale-and-notes) +- [9. Examples](#9-examples) + +--- + +## 1. Introduction + +The SSV NodeInfo Handshake Protocol defines how two SSV nodes exchange, sign, and verify each other's **NodeInfo**, which includes a `network_id` (such as "holesky", "prater", etc.) and optional metadata about node software versions or subnets. The protocol uses a request-response style handshake over Libp2p under a dedicated protocol ID. + +The high-level handshake steps are: + +1. **Requester** sends an Envelope (containing its NodeInfo) to the peer. +2. **Responder** verifies this Envelope, checks the `network_id`, and replies with its own Envelope. +3. **Requester** verifies the responder's Envelope. +4. Both sides proceed if verification succeeds; otherwise, the handshake is considered failed. + +--- + +## 2. Definitions + +### 2.1 Terminology + +| **Term** | **Definition** | +|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Envelope** | A Protobuf-encoded message containing a `public_key`, `payload_type`, `payload`, and `signature` (covering a domain-separated concatenation of fields). | +| **NodeInfo** | A JSON-based structure holding key node attributes like `network_id` plus optional metadata. | +| **Handshake**| The request-response exchange of Envelopes between two nodes at connection time. | + +### 2.2 Domain Separation + +- **Domain**: `"ssv"`. + Used to separate signatures for different contexts or protocols. + +--- + +## 3. Protocol Constants + +| **Name** | **Value** | **Description** | +|----------------|--------------------|------------------------------------------------------| +| `DOMAIN` | `ssv` | Fixed ASCII text used during signature generation. | +| `PAYLOAD_TYPE` | `ssv/nodeinfo` | Identifies the payload as an SSV NodeInfo structure. | +| `PROTOCOL_ID` | `/ssv/info/0.0.1` | Libp2p protocol ID used for the handshake. | + +--- + +## 4. Data Structures + +### 4.1 Envelope + +The Envelope is a Protobuf message: + +```protobuf +message Envelope { + bytes public_key = 1; + bytes payload_type = 2; + bytes payload = 3; + bytes signature = 5; +} +``` + +### 4.2 NodeInfo + +```text +NodeInfo: +- network_id: String +- metadata: NodeMetadata (optional) +``` + +### 4.3 NodeMetadata + +```text +NodeMetadata: +- node_version: String +- execution_node: String +- consensus_node: String +- subnets: String +``` + +--- + +## 5. Serialization and Signing + +### 5.1 Envelope Fields + +1. **public_key** + - Sender’s public key in serialized form (e.g., compressed Secp256k1 or raw Ed25519 bytes). + - The public key is encoded and decoded using Protobuf. + - For reference, Libp2p has a [Peer Ids and Keys](https://github.com/libp2p/specs/blob/master/peer-ids/peer-ids.md), which may be consulted for consistent handling across implementations. + +2. **payload_type** + - MUST be `"ssv/nodeinfo"` in this protocol. + - Used to identify how to interpret `payload`. + +3. **payload** + - Contains `NodeInfo` data in JSON (described below). + +4. **signature** + - A cryptographic signature covering `DOMAIN || payload_type || payload`. + +### 5.2 NodeInfo JSON Layout + +Internally, the protocol uses a “legacy” layout for `NodeInfo` serialization, with a top-level JSON structure: + +```json +{ + "Entries": [ + "", // (Index 0) Old forkVersion, not used + "", // (Index 1) The NodeInfo.network_id + "" // (Index 2) if NodeMetadata is present + ] +} +``` + +- If the array has fewer than 2 entries, the payload is invalid. +- If the array has 3 entries, the 3rd entry is a JSON object for metadata, for example: + +```json +{ + "NodeVersion": "...", + "ExecutionNode": "...", + "ConsensusNode": "...", + "Subnets": "..." +} +``` + +### 5.3 Signature Preparation + +To **sign** an Envelope, implementations: + +1. Construct the unsigned message: + + ```text + unsigned_message = DOMAIN || payload_type || payload + ``` + +2. Sign `unsigned_message` using the node’s private key. +3. Write the resulting signature to `signature`. + +To **verify** an Envelope: + +1. Recompute the `unsigned_message`. +2. Verify using `public_key` against `signature`. + +If verification fails, the handshake **MUST** abort. + +--- + +## 6. Handshake Protocol Flows + +### 6.1 Protocol ID + +Both peers must speak the protocol identified by: + +```text +/ssv/info/0.0.1 +``` + +### 6.2 Request Phase + +1. **Build Envelope** + - The initiating node (Requester) serializes its `NodeInfo` into JSON (the `payload`). + - Sets `payload_type = "ssv/nodeinfo"`. + - Prepends `DOMAIN = "ssv"` when computing the signature. + - Places the resulting `public_key` and `signature` into the Envelope. + +2. **Send Request** + - The requester sends this Envelope as the request. + +3. **Wait for Response** + - The requester awaits the single response from the Responder. + +### 6.3 Response Phase + +1. **Receive & Verify** + - The responder verifies the incoming Envelope: + - Check signature correctness. + - Extract `NodeInfo`. + - Validate `network_id` if necessary (see [6.4](#64-network-mismatch-checks)). + +2. **Build Response** + - If valid, the responder builds and signs its own Envelope containing its `NodeInfo`. + +3. **Send Response** + - The responder sends the Envelope back to the requester. + +4. **Requester Verifies** + - The requester verifies the signature, parses `NodeInfo`, and checks `network_id`. + +### 6.4 Network Mismatch Checks + +- Implementations **MUST** check whether the received `NodeInfo`’s `network_id` matches their local `network_id`. +- If they mismatch, the implementation **SHOULD** reject the connection. + +--- + +## 7. Security Considerations + +- **Signature Validation** is mandatory. Any failure to verify the Envelope’s signature indicates an invalid handshake. +- **Public Key Authenticity**: The Envelope’s `public_key` is not implicitly trusted. It must match the verified signature. +- **Network Mismatch**: Avoid bridging distinct SSV or Ethereum networks. Peers claiming the wrong `network_id` should be rejected. +- **Payload Size**: Although `NodeInfo` is generally small, implementations **SHOULD** impose a maximum bound for payload. Any request or response exceeding this size limit **SHOULD** be rejected. + +--- + +## 8. Rationale and Notes + +- Using a Protobuf-based Envelope simplifies cross-language interoperability. +- The domain separation string (`"ssv"`) prevents signature reuse in other contexts. +- The “legacy” `Entries` layout ensures backward-compatibility with older SSV implementations. + +--- + +## 9. Examples + +### 9.1 Example Envelope in Hex + +An example Envelope could be hex-encoded as: + +```text +0a250802122102ba6a707dcec6c60ba2793d52123d34b22556964fc798d4aa88ffc41a00e42407120c7373762f6e6f6465696e666f1aa5017b22456e7472696573223a5b22222c22686f6c65736b79222c227b5c224e6f646556657273696f6e5c223a5c22676574682f785c222c5c22457865637574696f6e4e6f64655c223a5c22676574682f785c222c5c22436f6e73656e7375734e6f64655c223a5c22707279736d2f785c222c5c225375626e6574735c223a5c2230303030303030303030303030303030303030303030303030303030303030303030305c227d225d7d2a473045022100b8a2a668113330369e74b86ec818a87009e2a351f7ee4c0e431e1f659dd1bc3f02202b1ebf418efa7fb0541f77703bea8563234a1b70b8391d43daa40b6e7c3fcc84 +``` + +Decoding reveals (high-level view): + +```text +Envelope { + public_key = , + payload_type = "ssv/nodeinfo", + payload = { + "Entries": [ + "", + "holesky", + "{\"NodeVersion\":\"geth/x\",\"ExecutionNode\":\"geth/x\",\"ConsensusNode\":\"prysm/x\",\"Subnets\":\"00000000000000000000000000000000\"}" + ] + }, + signature = +} +``` + +### 9.2 Verifying the Envelope + +1. Recompute: `domain = "ssv"` + + ```text + unsigned_message = "ssv" || "ssv/nodeinfo" || payload_bytes + ``` + +2. Verify signature with `public_key`. +3. Parse payload JSON => parse `NodeInfo` => check `network_id`.