From dfe1ce1edc74955e3e6750f96e9ff86af93a0ed9 Mon Sep 17 00:00:00 2001 From: Nina Date: Thu, 2 Apr 2026 09:35:17 +0100 Subject: [PATCH 1/5] discovery draft squashed --- .github/workflows/lint.yml | 43 + .github/workflows/test.yml | 31 + Cargo.lock | 230 +++- Cargo.toml | 31 +- crates/common/Cargo.toml | 11 + crates/common/src/enr/LICENSE | 21 + crates/common/src/enr/builder.rs | 154 +++ crates/common/src/enr/keys/k256_key.rs | 93 ++ crates/common/src/enr/keys/mod.rs | 48 + crates/common/src/enr/mod.rs | 929 +++++++++++++++ crates/common/src/enr/node_id.rs | 183 +++ crates/common/src/lib.rs | 4 + crates/discovery/Cargo.toml | 34 + crates/discovery/src/config.rs | 21 + crates/discovery/src/crypto.rs | 262 +++++ crates/discovery/src/discovery.rs | 38 + crates/discovery/src/discv5.rs | 1476 ++++++++++++++++++++++++ crates/discovery/src/kbucket.rs | 494 ++++++++ crates/discovery/src/lib.rs | 11 + crates/discovery/src/message.rs | 827 +++++++++++++ crates/discovery/src/query_pool.rs | 409 +++++++ crates/discovery/tests/e2e.rs | 220 ++++ crates/network/benches/quic_basic.rs | 199 ++-- crates/network/src/lib.rs | 2 +- crates/network/src/p2p/quic/peer.rs | 19 +- justfile | 2 +- 26 files changed, 5675 insertions(+), 117 deletions(-) create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/test.yml create mode 100644 crates/common/src/enr/LICENSE create mode 100644 crates/common/src/enr/builder.rs create mode 100644 crates/common/src/enr/keys/k256_key.rs create mode 100644 crates/common/src/enr/keys/mod.rs create mode 100644 crates/common/src/enr/mod.rs create mode 100644 crates/common/src/enr/node_id.rs create mode 100644 crates/discovery/Cargo.toml create mode 100644 crates/discovery/src/config.rs create mode 100644 crates/discovery/src/crypto.rs create mode 100644 crates/discovery/src/discovery.rs create mode 100644 crates/discovery/src/discv5.rs create mode 100644 crates/discovery/src/kbucket.rs create mode 100644 crates/discovery/src/lib.rs create mode 100644 crates/discovery/src/message.rs create mode 100644 crates/discovery/src/query_pool.rs create mode 100644 crates/discovery/tests/e2e.rs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..93cba7e --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,43 @@ +name: lint + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + lint: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: nightly-2025-10-01 + components: clippy, rustfmt + + - name: Setup just + uses: extractions/setup-just@v2 + with: + just-version: 1.5.0 + + - name: Check compilation + run: cargo check + + - name: Check formatting + run: just fmt-check + + - name: Check clippy + run: just clippy diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..91074f0 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,31 @@ +name: test + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: nightly-2025-10-01 + + - name: Run tests + run: cargo test diff --git a/Cargo.lock b/Cargo.lock index 97ddc4f..794cdbb 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", + "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", +] + +[[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" @@ -39,6 +74,28 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloy-rlp" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93e50f64a77ad9c5470bf2ad0ca02f228da70c792a8f06634801e202579f35e" +dependencies = [ + "alloy-rlp-derive", + "arrayvec", + "bytes", +] + +[[package]] +name = "alloy-rlp-derive" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce8849c74c9ca0f5a03da1c865e3eb6f768df816e67dd3721a398a8a7e398011" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -394,6 +451,12 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -465,6 +528,16 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.6.0" @@ -638,9 +711,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -735,6 +818,30 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "discovery" +version = "0.0.1" +dependencies = [ + "aes", + "aes-gcm", + "alloy-rlp", + "bytes", + "ctr", + "flux", + "flux-utils", + "hex", + "hkdf", + "k256", + "rand 0.8.5", + "rustc-hash", + "serde", + "sha2", + "silver-common", + "thiserror 1.0.69", + "tracing", + "uint", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -883,7 +990,7 @@ dependencies = [ [[package]] name = "flux" version = "0.0.38" -source = "git+https://github.com/gattaca-com/flux?rev=899e5fa#899e5fad57599771a8f4f17b137d35efb7a28b46" +source = "git+https://github.com/gattaca-com/flux?rev=41d1c369fa14392285b46d2bc4093ac3b04e21b6#41d1c369fa14392285b46d2bc4093ac3b04e21b6" dependencies = [ "bitcode", "core_affinity", @@ -905,7 +1012,7 @@ dependencies = [ [[package]] name = "flux-communication" version = "0.0.27" -source = "git+https://github.com/gattaca-com/flux?rev=899e5fa#899e5fad57599771a8f4f17b137d35efb7a28b46" +source = "git+https://github.com/gattaca-com/flux?rev=41d1c369fa14392285b46d2bc4093ac3b04e21b6#41d1c369fa14392285b46d2bc4093ac3b04e21b6" dependencies = [ "directories", "flux-timing", @@ -920,7 +1027,7 @@ dependencies = [ [[package]] name = "flux-timing" version = "0.0.16" -source = "git+https://github.com/gattaca-com/flux?rev=899e5fa#899e5fad57599771a8f4f17b137d35efb7a28b46" +source = "git+https://github.com/gattaca-com/flux?rev=41d1c369fa14392285b46d2bc4093ac3b04e21b6#41d1c369fa14392285b46d2bc4093ac3b04e21b6" dependencies = [ "bitcode", "chrono", @@ -938,8 +1045,9 @@ dependencies = [ [[package]] name = "flux-utils" version = "0.0.23" -source = "git+https://github.com/gattaca-com/flux?rev=899e5fa#899e5fad57599771a8f4f17b137d35efb7a28b46" +source = "git+https://github.com/gattaca-com/flux?rev=41d1c369fa14392285b46d2bc4093ac3b04e21b6#41d1c369fa14392285b46d2bc4093ac3b04e21b6" dependencies = [ + "bytes", "core_affinity", "directories", "flux-timing", @@ -1109,6 +1217,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" @@ -1202,6 +1320,21 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1269,6 +1402,15 @@ dependencies = [ "str_stack", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "is-terminal" version = "0.4.17" @@ -1329,6 +1471,15 @@ dependencies = [ "signature", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + [[package]] name = "kv-log-macro" version = "1.0.7" @@ -1585,6 +1736,12 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "option-ext" version = "0.2.0" @@ -1711,6 +1868,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -2188,6 +2357,16 @@ dependencies = [ "digest", ] +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2250,10 +2429,19 @@ dependencies = [ name = "silver-common" version = "0.0.1" dependencies = [ + "alloy-rlp", + "base64", + "bytes", "flux", + "hex", "k256", + "rand 0.8.5", "rcgen", "rustls", + "serde", + "serde_json", + "sha3", + "thiserror 1.0.69", "tracing", ] @@ -2313,7 +2501,7 @@ dependencies = [ [[package]] name = "spine-derive" version = "0.0.10" -source = "git+https://github.com/gattaca-com/flux?rev=899e5fa#899e5fad57599771a8f4f17b137d35efb7a28b46" +source = "git+https://github.com/gattaca-com/flux?rev=41d1c369fa14392285b46d2bc4093ac3b04e21b6#41d1c369fa14392285b46d2bc4093ac3b04e21b6" dependencies = [ "proc-macro2", "quote", @@ -2345,6 +2533,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "str_stack" version = "0.1.0" @@ -2620,12 +2814,12 @@ dependencies = [ [[package]] name = "type-hash" version = "0.0.1" -source = "git+https://github.com/gattaca-com/flux?rev=899e5fa#899e5fad57599771a8f4f17b137d35efb7a28b46" +source = "git+https://github.com/gattaca-com/flux?rev=41d1c369fa14392285b46d2bc4093ac3b04e21b6#41d1c369fa14392285b46d2bc4093ac3b04e21b6" [[package]] name = "type-hash-derive" version = "0.0.4" -source = "git+https://github.com/gattaca-com/flux?rev=899e5fa#899e5fad57599771a8f4f17b137d35efb7a28b46" +source = "git+https://github.com/gattaca-com/flux?rev=41d1c369fa14392285b46d2bc4093ac3b04e21b6#41d1c369fa14392285b46d2bc4093ac3b04e21b6" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -2639,12 +2833,34 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uint" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index bb7995e..a73a926 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/common", + "crates/discovery", "crates/network" ] resolver = "2" @@ -45,24 +46,40 @@ opt-level = 3 [workspace.dependencies] silver-common = { path = "crates/common" } -flux = { git = "https://github.com/gattaca-com/flux", rev = "899e5fa" } +silver-discovery = {path = "crates/discovery" } +flux = { git = "https://github.com/gattaca-com/flux", rev = "41d1c369fa14392285b46d2bc4093ac3b04e21b6"} +flux-utils = { git = "https://github.com/gattaca-com/flux", rev = "41d1c369fa14392285b46d2bc4093ac3b04e21b6", features = ["bytes"]} # External +aes = "0.8" +aes-gcm = "0.10" +alloy-rlp = { version = "0.3.12", default-features = false, features = ["derive", "std"] } +base64 = "0.22" buffa = "0.2.0" bytes = "1.11.1" -criterion = { features = ["async_std", "async_tokio"], version = "0.5.1" } +criterion = { version = "0.5.1", features = ["async_std", "async_tokio"] } +ctr = "0.9" +hex = "0.4" +hkdf = "0.12" k256 = { version = "0.13", features = ["ecdsa"] } -mio = { features = ["net", "os-poll"], version = "1.0.4" } -pprof = { features = ["criterion", "flamegraph"], version = "0.13" } +libc = "0.2" +mio = { version = "1.0.4", features = ["net", "os-poll"] } +pprof = { version = "0.13", features = ["criterion", "flamegraph"] } quinn-proto = "0.11.14" -rand = "=0.8.5" -rand_core = "=0.6.4" +rand = "0.8.5" +rand_core = "0.6.4" rcgen = "0.14.7" ring = "0.17" +rustc-hash = "2" rustls = "0.23.37" -libc = "0.2" +serde = { version = "1.0.228", features = ["derive"] } +sha2 = "0.10" +sha3 = "0.10" +slab = "0.4.12" +thiserror = "1.0.58" tracing = "0.1.40" tracing-subscriber = "0.3.23" +uint = "0.9" x509-parser = { version = "0.17", features = ["verify"] } yasna = "0.5" diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 75dc458..1d71458 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -6,11 +6,22 @@ rust-version.workspace = true version.workspace = true [dependencies] +alloy-rlp.workspace = true +base64.workspace = true +bytes.workspace = true flux.workspace = true +hex.workspace = true k256.workspace = true +rand.workspace = true rcgen.workspace = true rustls.workspace = true +serde.workspace = true +sha3.workspace = true +thiserror.workspace = true tracing.workspace = true +[dev-dependencies] +serde_json = "1.0.135" + [lints] workspace = true diff --git a/crates/common/src/enr/LICENSE b/crates/common/src/enr/LICENSE new file mode 100644 index 0000000..bd1c2c2 --- /dev/null +++ b/crates/common/src/enr/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Age Manning + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/common/src/enr/builder.rs b/crates/common/src/enr/builder.rs new file mode 100644 index 0000000..f7d77e9 --- /dev/null +++ b/crates/common/src/enr/builder.rs @@ -0,0 +1,154 @@ +// Adapted from https://github.com/sigp/enr (MIT License) + +use std::{ + marker::PhantomData, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, +}; + +use alloy_rlp::{Encodable, Header}; +use bytes::BytesMut; + +use super::{ + ENR_VERSION, Enr, EnrKey, EnrPublicKey, ID_ENR_KEY, IP_ENR_KEY, IP6_ENR_KEY, MAX_ENR_SIZE, + NodeId, UDP_ENR_KEY, UDP6_ENR_KEY, +}; + +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum Error { + #[error("enr exceeds max size")] + ExceedsMaxSize, + #[error("sequence number too large")] + SequenceNumberTooHigh, + #[error("signing error")] + SigningError, + #[error("unsupported identity scheme")] + UnsupportedIdentityScheme, + #[error("invalid rlp data {0}")] + InvalidRlpData(#[from] alloy_rlp::Error), +} + +pub struct Builder { + seq: u64, + ip4: Option, + ip6: Option, + udp4: Option, + udp6: Option, + phantom: PhantomData, +} + +impl Default for Builder { + fn default() -> Self { + Self { seq: 1, ip4: None, ip6: None, udp4: None, udp6: None, phantom: PhantomData } + } +} + +impl Builder { + pub fn seq(&mut self, seq: u64) -> &mut Self { + self.seq = seq; + self + } + + pub fn ip(&mut self, ip: IpAddr) -> &mut Self { + match ip { + IpAddr::V4(addr) => self.ip4(addr), + IpAddr::V6(addr) => self.ip6(addr), + } + } + + pub fn ip4(&mut self, ip: Ipv4Addr) -> &mut Self { + self.ip4 = Some(ip); + self + } + + pub fn ip6(&mut self, ip: Ipv6Addr) -> &mut Self { + self.ip6 = Some(ip); + self + } + + pub fn udp4(&mut self, udp: u16) -> &mut Self { + self.udp4 = Some(udp); + self + } + + pub fn udp6(&mut self, udp: u16) -> &mut Self { + self.udp6 = Some(udp); + self + } + + // Produces the RLP list content used for signing (no signature prefix). + // Key order matches the sorted order required by the ENR spec. + fn rlp_content(&self, public_key: &K::PublicKey) -> BytesMut { + let mut list = BytesMut::with_capacity(MAX_ENR_SIZE); + self.seq.encode(&mut list); + + let pk_key = public_key.enr_key(); + let pk_encoded = public_key.encode(); + + if pk_key == b"ed25519" { + pk_key.encode(&mut list); + pk_encoded.as_ref().encode(&mut list); + } + ID_ENR_KEY.encode(&mut list); + ENR_VERSION.encode(&mut list); + if let Some(ip4) = self.ip4 { + IP_ENR_KEY.encode(&mut list); + ip4.octets().as_ref().encode(&mut list); + } + if let Some(ip6) = self.ip6 { + IP6_ENR_KEY.encode(&mut list); + ip6.octets().as_ref().encode(&mut list); + } + if pk_key == b"secp256k1" { + pk_key.encode(&mut list); + pk_encoded.as_ref().encode(&mut list); + } + if let Some(udp4) = self.udp4 { + UDP_ENR_KEY.encode(&mut list); + udp4.encode(&mut list); + } + if let Some(udp6) = self.udp6 { + UDP6_ENR_KEY.encode(&mut list); + udp6.encode(&mut list); + } + + let header = Header { list: true, payload_length: list.len() }; + let mut out = BytesMut::with_capacity(header.length() + list.len()); + header.encode(&mut out); + out.extend_from_slice(&list); + out + } + + pub fn build(&mut self, key: &K) -> Result, Error> { + let public_key = key.public(); + let rlp_content = self.rlp_content(&public_key); + + let sig_bytes = key.sign_v4(&rlp_content).map_err(|_| Error::SigningError)?; + if sig_bytes.len() != 64 { + return Err(Error::SigningError); + } + let mut signature = [0u8; 64]; + signature.copy_from_slice(&sig_bytes); + + let node_id = NodeId::from(public_key.clone()); + + let enr = Enr { + seq: self.seq, + node_id, + ip4: self.ip4, + ip6: self.ip6, + udp4: self.udp4, + udp6: self.udp6, + eth2: None, + attnets: None, + syncnets: None, + public_key, + signature, + }; + + if enr.size() > MAX_ENR_SIZE { + return Err(Error::ExceedsMaxSize); + } + + Ok(enr) + } +} diff --git a/crates/common/src/enr/keys/k256_key.rs b/crates/common/src/enr/keys/k256_key.rs new file mode 100644 index 0000000..c4cd2d8 --- /dev/null +++ b/crates/common/src/enr/keys/k256_key.rs @@ -0,0 +1,93 @@ +// Adapted from https://github.com/sigp/enr (MIT License) + +use alloy_rlp::Error as DecoderError; +use k256::{ + AffinePoint, CompressedPoint, EncodedPoint, + ecdsa::{ + Signature, SigningKey, VerifyingKey, + signature::{DigestVerifier, RandomizedDigestSigner}, + }, + elliptic_curve::{ + point::DecompressPoint, + sec1::{Coordinates, ToEncodedPoint}, + subtle::Choice, + }, +}; +use rand::rngs::OsRng; +use sha3::{Digest, Keccak256}; + +use super::{EnrKey, EnrKeyUnambiguous, EnrPublicKey, SigningError}; + +pub const ENR_KEY: &str = "secp256k1"; + +impl EnrKey for SigningKey { + type PublicKey = VerifyingKey; + + fn sign_v4(&self, msg: &[u8]) -> Result, SigningError> { + let digest = Keccak256::new().chain_update(msg); + let signature: Signature = + self.try_sign_digest_with_rng(&mut OsRng, digest).map_err(|_| SigningError {})?; + + Ok(signature.to_vec()) + } + + fn public(&self) -> Self::PublicKey { + *self.verifying_key() + } + + fn enr_to_public(scheme: &[u8], pubkey_bytes: &[u8]) -> Result { + if scheme != ENR_KEY.as_bytes() { + return Err(DecoderError::Custom("Unknown signature")); + } + Self::decode_public(pubkey_bytes) + } +} + +impl EnrKeyUnambiguous for SigningKey { + fn decode_public(bytes: &[u8]) -> Result { + VerifyingKey::from_sec1_bytes(bytes) + .map_err(|_| DecoderError::Custom("Invalid Secp256k1 Signature")) + } +} + +impl EnrPublicKey for VerifyingKey { + type Raw = CompressedPoint; + type RawUncompressed = [u8; 64]; + + fn verify_v4(&self, msg: &[u8], sig: &[u8]) -> bool { + if let Ok(sig) = k256::ecdsa::Signature::try_from(sig) { + return self.verify_digest(Keccak256::new().chain_update(msg), &sig).is_ok(); + } + false + } + + fn encode(&self) -> Self::Raw { + self.into() + } + + fn encode_uncompressed(&self) -> Self::RawUncompressed { + let p = EncodedPoint::from(self); + let (x, y) = match p.coordinates() { + Coordinates::Compact { .. } | Coordinates::Identity => unreachable!(), + Coordinates::Compressed { x, y_is_odd } => ( + x, + *AffinePoint::decompress(x, Choice::from(u8::from(y_is_odd))) + .unwrap() + .to_encoded_point(false) + .y() + .unwrap(), + ), + Coordinates::Uncompressed { x, y } => (x, *y), + }; + + let mut coords = [0; 64]; + coords[..32].copy_from_slice(x); + coords[32..].copy_from_slice(&y); + + coords + } + + fn enr_key(&self) -> &'static [u8] { + ENR_KEY.as_bytes() + } +} diff --git a/crates/common/src/enr/keys/mod.rs b/crates/common/src/enr/keys/mod.rs new file mode 100644 index 0000000..7bcc09b --- /dev/null +++ b/crates/common/src/enr/keys/mod.rs @@ -0,0 +1,48 @@ +// Adapted from https://github.com/sigp/enr (MIT License) + +mod k256_key; + +use std::{ + error::Error, + fmt::{self, Debug, Display}, +}; + +use alloy_rlp::Error as DecoderError; + +pub trait EnrKey: Send + Sync + Unpin + 'static { + type PublicKey: EnrPublicKey + Clone; + + fn sign_v4(&self, msg: &[u8]) -> Result, SigningError>; + + fn public(&self) -> Self::PublicKey; + + fn enr_to_public(scheme: &[u8], pubkey_bytes: &[u8]) -> Result; +} + +pub trait EnrKeyUnambiguous: EnrKey { + fn decode_public(bytes: &[u8]) -> Result; +} + +pub trait EnrPublicKey: Clone + Debug + Send + Sync + Unpin + 'static { + type Raw: AsRef<[u8]>; + type RawUncompressed: AsRef<[u8]>; + + fn verify_v4(&self, msg: &[u8], sig: &[u8]) -> bool; + + fn encode(&self) -> Self::Raw; + + fn encode_uncompressed(&self) -> Self::RawUncompressed; + + fn enr_key(&self) -> &'static [u8]; +} + +#[derive(Debug)] +pub struct SigningError {} + +impl Display for SigningError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "Signing error") + } +} + +impl Error for SigningError {} diff --git a/crates/common/src/enr/mod.rs b/crates/common/src/enr/mod.rs new file mode 100644 index 0000000..7d5d7a7 --- /dev/null +++ b/crates/common/src/enr/mod.rs @@ -0,0 +1,929 @@ +// Adapted from https://github.com/sigp/enr (MIT License) + +mod builder; +mod keys; +mod node_id; + +use std::{ + hash::{Hash, Hasher}, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6}, + str::FromStr, +}; + +use alloy_rlp::{Decodable, Encodable, Error as DecoderError, Header}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +pub use builder::Error; +use bytes::{Buf, BytesMut}; +pub use keys::{EnrKey, EnrPublicKey}; +pub use node_id::NodeId; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; +use sha3::{Digest, Keccak256}; + +pub const MAX_ENR_SIZE: usize = 300; + +pub const ID_ENR_KEY: &[u8] = b"id"; +pub const ENR_VERSION: &[u8] = b"v4"; +pub const IP_ENR_KEY: &[u8] = b"ip"; +pub const IP6_ENR_KEY: &[u8] = b"ip6"; +pub const UDP_ENR_KEY: &[u8] = b"udp"; +pub const UDP6_ENR_KEY: &[u8] = b"udp6"; +pub const ETH2_ENR_KEY: &[u8] = b"eth2"; +pub const ATTNETS_ENR_KEY: &[u8] = b"attnets"; +pub const SYNCNETS_ENR_KEY: &[u8] = b"syncnets"; + +/// An ENR record with a verified signature. +/// +/// Fields are the standard ENR fields relevant to discovery. TCP fields are +/// omitted; the p2p layer uses QUIC. Unknown fields from decoded records are +/// verified against the signature and then dropped. +pub struct Enr { + seq: u64, + node_id: NodeId, + ip4: Option, + ip6: Option, + udp4: Option, + udp6: Option, + /// SSZ-encoded ENRForkID: fork_digest[4] + next_fork_version[4] + + /// next_fork_epoch[8]. + eth2: Option<[u8; 16]>, + /// Attestation subnet bitfield (SSZ Bitvector[64]). + attnets: Option<[u8; 8]>, + /// Sync committee subnet bitfield (lower 4 bits, SSZ Bitvector[4]). + syncnets: Option, + public_key: K::PublicKey, + signature: [u8; 64], +} + +impl Copy for Enr where K::PublicKey: Copy {} + +impl Enr { + pub fn builder() -> builder::Builder { + builder::Builder::default() + } + + pub fn empty(signing_key: &K) -> Result { + Self::builder().build(signing_key) + } + + #[inline] + pub const fn node_id(&self) -> NodeId { + self.node_id + } + + #[inline] + pub const fn seq(&self) -> u64 { + self.seq + } + + #[inline] + pub fn ip4(&self) -> Option { + self.ip4 + } + + #[inline] + pub fn ip6(&self) -> Option { + self.ip6 + } + + #[inline] + pub fn udp4(&self) -> Option { + self.udp4 + } + + #[inline] + pub fn udp6(&self) -> Option { + self.udp6 + } + + #[inline] + pub fn udp4_socket(&self) -> Option { + Some(SocketAddrV4::new(self.ip4?, self.udp4?)) + } + + #[inline] + pub fn udp6_socket(&self) -> Option { + Some(SocketAddrV6::new(self.ip6?, self.udp6?, 0, 0)) + } + + #[inline] + pub fn eth2(&self) -> Option<[u8; 16]> { + self.eth2 + } + + #[inline] + pub fn attnets(&self) -> Option<[u8; 8]> { + self.attnets + } + + #[inline] + pub fn syncnets(&self) -> Option { + self.syncnets + } + + pub fn set_eth2(&mut self, eth2: [u8; 16], key: &K) -> Result<(), Error> { + self.apply(key, |enr| enr.eth2 = Some(eth2)) + } + + pub fn set_attnets(&mut self, attnets: [u8; 8], key: &K) -> Result<(), Error> { + self.apply(key, |enr| enr.attnets = Some(attnets)) + } + + pub fn set_syncnets(&mut self, syncnets: u8, key: &K) -> Result<(), Error> { + self.apply(key, |enr| enr.syncnets = Some(syncnets)) + } + + /// Always returns `Some("v4")` — this implementation only supports the v4 + /// identity scheme. + #[inline] + pub fn id(&self) -> Option { + Some(String::from_utf8_lossy(ENR_VERSION).into_owned()) + } + + #[inline] + pub fn signature(&self) -> &[u8] { + &self.signature + } + + #[inline] + pub fn public_key(&self) -> K::PublicKey { + self.public_key.clone() + } + + /// Returns true if the ENR's signature is valid against the stored public + /// key and the fields in this struct. + /// + /// Note: this recomputes RLP from the known typed fields only. ENRs that + /// were decoded from the wire and contained additional fields (e.g. tcp, + /// custom keys) were verified at decode time; those fields are not present + /// here and this method will return false for them. + #[inline] + pub fn verify(&self) -> bool { + self.public_key.verify_v4(&self.rlp_content(), &self.signature) + } + + #[inline] + pub fn compare_content(&self, other: &Self) -> bool { + self.rlp_content() == other.rlp_content() + } + + #[inline] + pub fn is_udp_reachable(&self) -> bool { + self.udp4_socket().is_some() || self.udp6_socket().is_some() + } + + #[inline] + pub fn to_base64(&self) -> String { + let mut out = BytesMut::new(); + self.encode(&mut out); + format!("enr:{}", URL_SAFE_NO_PAD.encode(out)) + } + + #[inline] + pub fn size(&self) -> usize { + let mut out = BytesMut::new(); + self.encode(&mut out); + out.len() + } + + pub fn set_seq(&mut self, seq: u64, key: &K) -> Result<(), Error> { + let prev_seq = self.seq; + let prev_sig = self.signature; + self.seq = seq; + if let Err(e) = self.sign(key) { + self.seq = prev_seq; + return Err(e); + } + if self.size() > MAX_ENR_SIZE { + self.seq = prev_seq; + self.signature = prev_sig; + return Err(Error::ExceedsMaxSize); + } + self.node_id = NodeId::from(key.public()); + Ok(()) + } + + pub fn set_ip(&mut self, ip: IpAddr, key: &K) -> Result, Error> { + let prev = match ip { + IpAddr::V4(_) => self.ip4.map(IpAddr::V4), + IpAddr::V6(_) => self.ip6.map(IpAddr::V6), + }; + self.apply(key, |enr| match ip { + IpAddr::V4(addr) => enr.ip4 = Some(addr), + IpAddr::V6(addr) => enr.ip6 = Some(addr), + })?; + Ok(prev) + } + + pub fn set_udp4(&mut self, udp: u16, key: &K) -> Result, Error> { + let prev = self.udp4; + self.apply(key, |enr| enr.udp4 = Some(udp))?; + Ok(prev) + } + + pub fn remove_udp4(&mut self, key: &K) -> Result<(), Error> { + self.apply(key, |enr| enr.udp4 = None) + } + + pub fn set_udp6(&mut self, udp: u16, key: &K) -> Result, Error> { + let prev = self.udp6; + self.apply(key, |enr| enr.udp6 = Some(udp))?; + Ok(prev) + } + + pub fn remove_udp6(&mut self, key: &K) -> Result<(), Error> { + self.apply(key, |enr| enr.udp6 = None) + } + + pub fn set_udp_socket(&mut self, socket: SocketAddr, key: &K) -> Result<(), Error> { + self.apply(key, |enr| match socket.ip() { + IpAddr::V4(addr) => { + enr.ip4 = Some(addr); + enr.udp4 = Some(socket.port()); + } + IpAddr::V6(addr) => { + enr.ip6 = Some(addr); + enr.udp6 = Some(socket.port()); + } + }) + } + + pub fn remove_udp_socket(&mut self, key: &K) -> Result<(), Error> { + self.apply(key, |enr| { + enr.ip4 = None; + enr.udp4 = None; + }) + } + + pub fn remove_udp6_socket(&mut self, key: &K) -> Result<(), Error> { + self.apply(key, |enr| { + enr.ip6 = None; + enr.udp6 = None; + }) + } + + // Clone, apply f, re-sign, check size, commit. + fn apply(&mut self, key: &K, f: F) -> Result<(), Error> + where + F: FnOnce(&mut Self), + { + let mut new = self.clone(); + f(&mut new); + new.seq = new.seq.checked_add(1).ok_or(Error::SequenceNumberTooHigh)?; + new.sign(key)?; + new.node_id = NodeId::from(key.public()); + if new.size() > MAX_ENR_SIZE { + return Err(Error::ExceedsMaxSize); + } + *self = new; + Ok(()) + } + + // Encode (seq + k-v pairs) into a flat buffer; signature is prepended only + // when include_signature is true. Keys emitted in lexicographic sorted order: + // attnets < ed25519 < eth2 < id < ip < ip6 < secp256k1 < syncnets < udp < + // udp6 + fn append_rlp_content(&self, stream: &mut BytesMut, include_signature: bool) { + if include_signature { + self.signature.as_ref().encode(stream); + } + self.seq.encode(stream); + + let pk_key = self.public_key.enr_key(); + let pk_encoded = self.public_key.encode(); + + if let Some(attnets) = self.attnets { + ATTNETS_ENR_KEY.encode(stream); + attnets.as_ref().encode(stream); + } + if pk_key == b"ed25519" { + pk_key.encode(stream); + pk_encoded.as_ref().encode(stream); + } + if let Some(eth2) = self.eth2 { + ETH2_ENR_KEY.encode(stream); + eth2.as_ref().encode(stream); + } + ID_ENR_KEY.encode(stream); + ENR_VERSION.encode(stream); + if let Some(ip4) = self.ip4 { + IP_ENR_KEY.encode(stream); + ip4.octets().as_ref().encode(stream); + } + if let Some(ip6) = self.ip6 { + IP6_ENR_KEY.encode(stream); + ip6.octets().as_ref().encode(stream); + } + if pk_key == b"secp256k1" { + pk_key.encode(stream); + pk_encoded.as_ref().encode(stream); + } + if let Some(syncnets) = self.syncnets { + SYNCNETS_ENR_KEY.encode(stream); + [syncnets].as_ref().encode(stream); + } + if let Some(udp4) = self.udp4 { + UDP_ENR_KEY.encode(stream); + udp4.encode(stream); + } + if let Some(udp6) = self.udp6 { + UDP6_ENR_KEY.encode(stream); + udp6.encode(stream); + } + } + + // Returns the RLP list used as the signing payload (no signature prefix). + fn rlp_content(&self) -> BytesMut { + let mut stream = BytesMut::with_capacity(MAX_ENR_SIZE); + self.append_rlp_content(&mut stream, false); + let header = Header { list: true, payload_length: stream.len() }; + let mut out = BytesMut::new(); + header.encode(&mut out); + out.extend_from_slice(&stream); + out + } + + fn sign(&mut self, key: &K) -> Result<[u8; 64], Error> { + let sig_bytes = key.sign_v4(&self.rlp_content()).map_err(|_| Error::SigningError)?; + if sig_bytes.len() != 64 { + return Err(Error::SigningError); + } + let mut new_sig = [0u8; 64]; + new_sig.copy_from_slice(&sig_bytes); + let old = self.signature; + self.signature = new_sig; + Ok(old) + } +} + +impl Clone for Enr { + fn clone(&self) -> Self { + Self { + seq: self.seq, + node_id: self.node_id, + ip4: self.ip4, + ip6: self.ip6, + udp4: self.udp4, + udp6: self.udp6, + eth2: self.eth2, + attnets: self.attnets, + syncnets: self.syncnets, + public_key: self.public_key.clone(), + signature: self.signature, + } + } +} + +impl Eq for Enr {} + +impl PartialEq for Enr { + fn eq(&self, other: &Self) -> bool { + self.seq == other.seq && self.node_id == other.node_id && self.signature == other.signature + } +} + +impl Hash for Enr { + fn hash(&self, state: &mut H) { + self.seq.hash(state); + self.node_id.hash(state); + self.signature.hash(state); + } +} + +impl std::fmt::Display for Enr { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}", self.to_base64()) + } +} + +impl std::fmt::Debug for Enr { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.debug_struct("Enr") + .field("seq", &self.seq) + .field("node_id", &self.node_id()) + .field("ip4", &self.ip4) + .field("ip6", &self.ip6) + .field("udp4", &self.udp4) + .field("udp6", &self.udp6) + .field("signature", &hex::encode(self.signature)) + .finish_non_exhaustive() + } +} + +impl FromStr for Enr { + type Err = String; + + fn from_str(base64_string: &str) -> Result { + if base64_string.len() < 4 { + return Err("Invalid ENR string".to_string()); + } + let decode_string = if base64_string.starts_with("enr:") { + base64_string.get(4..).ok_or_else(|| "Invalid ENR string".to_string())? + } else { + base64_string + }; + let bytes = URL_SAFE_NO_PAD + .decode(decode_string) + .map_err(|e| format!("Invalid base64 encoding: {e:?}"))?; + Self::decode(&mut bytes.as_ref()).map_err(|e| format!("Invalid ENR: {e:?}")) + } +} + +impl Serialize for Enr { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_base64()) + } +} + +impl<'de, K: EnrKey> Deserialize<'de> for Enr { + fn deserialize>(deserializer: D) -> Result { + let s: String = Deserialize::deserialize(deserializer)?; + Self::from_str(&s).map_err(D::Error::custom) + } +} + +impl Encodable for Enr { + fn encode(&self, out: &mut dyn bytes::BufMut) { + let mut stream = BytesMut::with_capacity(MAX_ENR_SIZE); + self.append_rlp_content(&mut stream, true); + let header = Header { list: true, payload_length: stream.len() }; + header.encode(out); + out.put_slice(&stream); + } +} + +impl Decodable for Enr { + fn decode(buf: &mut &[u8]) -> Result { + if buf.len() > MAX_ENR_SIZE { + return Err(DecoderError::Custom("enr exceeds max size")); + } + + let payload = &mut Header::decode_bytes(buf, true)?; + + if payload.is_empty() { + return Err(DecoderError::Custom("Payload is empty")); + } + let sig_bytes = Header::decode_bytes(payload, false)?; + if sig_bytes.len() != 64 { + return Err(DecoderError::Custom("Invalid signature length")); + } + let mut signature = [0u8; 64]; + signature.copy_from_slice(sig_bytes); + + if payload.is_empty() { + return Err(DecoderError::Custom("Seq is missing")); + } + let seq = u64::decode(payload)?; + + // Accumulate all k-v pairs verbatim for signature verification. + // Unknown fields (tcp, custom) are included here but discarded from the + // typed struct after the check. + let mut content_list = BytesMut::with_capacity(MAX_ENR_SIZE); + seq.encode(&mut content_list); + + let mut ip4: Option = None; + let mut ip6: Option = None; + let mut udp4: Option = None; + let mut udp6: Option = None; + let mut eth2: Option<[u8; 16]> = None; + let mut attnets: Option<[u8; 8]> = None; + let mut syncnets: Option = None; + let mut pubkey_info: Option<(&[u8], &[u8])> = None; + + let mut prev: Option<&[u8]> = None; + while !payload.is_empty() { + let key = Header::decode_bytes(payload, false)?; + if let Some(prev) = prev { + if prev >= key { + return Err(DecoderError::Custom("Unsorted keys")); + } + } + prev = Some(key); + key.encode(&mut content_list); + + let val_start = *payload; + match key { + b"id" => { + let id = Header::decode_bytes(payload, false)?; + if id != b"v4" { + return Err(DecoderError::Custom("Unsupported identity scheme")); + } + } + UDP_ENR_KEY => { + udp4 = Some(u16::decode(payload)?); + } + UDP6_ENR_KEY => { + udp6 = Some(u16::decode(payload)?); + } + IP_ENR_KEY => { + ip4 = Some(Ipv4Addr::decode(payload)?); + } + IP6_ENR_KEY => { + ip6 = Some(Ipv6Addr::decode(payload)?); + } + b"secp256k1" | b"ed25519" => { + let pk_bytes = Header::decode_bytes(payload, false)?; + pubkey_info = Some((key, pk_bytes)); + } + ETH2_ENR_KEY => { + let b = Header::decode_bytes(payload, false)?; + if b.len() != 16 { + return Err(DecoderError::Custom("invalid eth2 length")); + } + let mut arr = [0u8; 16]; + arr.copy_from_slice(b); + eth2 = Some(arr); + } + ATTNETS_ENR_KEY => { + let b = Header::decode_bytes(payload, false)?; + if b.len() != 8 { + return Err(DecoderError::Custom("invalid attnets length")); + } + let mut arr = [0u8; 8]; + arr.copy_from_slice(b); + attnets = Some(arr); + } + SYNCNETS_ENR_KEY => { + let b = Header::decode_bytes(payload, false)?; + if b.len() != 1 { + return Err(DecoderError::Custom("invalid syncnets length")); + } + syncnets = Some(b[0]); + } + _ => { + // Skip unknown fields (tcp, custom keys, etc.). + // Still include their raw bytes in content_list for signature verification. + let h = Header::decode(payload)?; + if h.payload_length > payload.len() { + return Err(DecoderError::InputTooShort); + } + payload.advance(h.payload_length); + } + } + // Append exact wire bytes for this value into the content accumulator. + let raw_val = &val_start[..val_start.len() - payload.len()]; + content_list.extend_from_slice(raw_val); + } + + let (scheme, pk_bytes) = pubkey_info.ok_or(DecoderError::Custom("Missing public key"))?; + let public_key = K::enr_to_public(scheme, pk_bytes)?; + let node_id = NodeId::from(public_key.clone()); + + // Verify signature over the full content (including skipped fields). + let content_rlp = { + let header = Header { list: true, payload_length: content_list.len() }; + let mut out = BytesMut::with_capacity(header.length() + content_list.len()); + header.encode(&mut out); + out.extend_from_slice(&content_list); + out + }; + if !public_key.verify_v4(&content_rlp, &signature) { + return Err(DecoderError::Custom("Invalid Signature")); + } + + Ok(Self { + seq, + node_id, + ip4, + ip6, + udp4, + udp6, + eth2, + attnets, + syncnets, + public_key, + signature, + }) + } +} + +pub fn digest(b: &[u8]) -> [u8; 32] { + let mut output = [0_u8; 32]; + output.copy_from_slice(&Keccak256::digest(b)); + output +} + +#[cfg(test)] +mod tests { + use super::*; + + type DefaultEnr = Enr; + + #[test] + fn test_vector_k256() { + let valid_record = hex::decode("f884b8407098ad865b00a582051940cb9cf36836572411a47278783077011599ed5cd16b76f2635f4e234738f30813a89eb9137e3e3df5266e3a1f11df72ecf1145ccb9c01826964827634826970847f00000189736563703235366b31a103ca634cae0d49acb401d8a4c6b6fe8c55b70d115bf400769cc1400f3258cd31388375647082765f").unwrap(); + let signature = hex::decode("7098ad865b00a582051940cb9cf36836572411a47278783077011599ed5cd16b76f2635f4e234738f30813a89eb9137e3e3df5266e3a1f11df72ecf1145ccb9c").unwrap(); + let expected_pubkey = + hex::decode("03ca634cae0d49acb401d8a4c6b6fe8c55b70d115bf400769cc1400f3258cd3138") + .unwrap(); + + let mut buf = valid_record.as_slice(); + let enr = DefaultEnr::decode(&mut buf).unwrap(); + assert!(buf.is_empty()); + + let pubkey = enr.public_key().encode(); + + assert_eq!(enr.ip4(), Some(Ipv4Addr::new(127, 0, 0, 1))); + assert_eq!(enr.id(), Some(String::from("v4"))); + assert_eq!(enr.udp4(), Some(30303)); + assert_eq!(enr.signature(), &signature[..]); + assert_eq!(pubkey.to_vec(), expected_pubkey); + assert!(enr.verify()); + } + + #[test] + fn test_vector_2() { + let text = "enr:-IS4QHCYrYZbAKWCBRlAy5zzaDZXJBGkcnh4MHcBFZntXNFrdvJjX04jRzjzCBOonrkTfj499SZuOh8R33Ls8RRcy5wBgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQPKY0yuDUmstAHYpMa2_oxVtw0RW_QAdpzBQA8yWM0xOIN1ZHCCdl8"; + let signature = hex::decode("7098ad865b00a582051940cb9cf36836572411a47278783077011599ed5cd16b76f2635f4e234738f30813a89eb9137e3e3df5266e3a1f11df72ecf1145ccb9c").unwrap(); + let expected_pubkey = + hex::decode("03ca634cae0d49acb401d8a4c6b6fe8c55b70d115bf400769cc1400f3258cd3138") + .unwrap(); + let expected_node_id = + hex::decode("a448f24c6d18e575453db13171562b71999873db5b286df957af199ec94617f7") + .unwrap(); + + let enr = text.parse::().unwrap(); + let pubkey = enr.public_key().encode(); + assert_eq!(enr.ip4(), Some(Ipv4Addr::new(127, 0, 0, 1))); + assert_eq!(enr.ip6(), None); + assert_eq!(enr.id(), Some(String::from("v4"))); + assert_eq!(enr.udp4(), Some(30303)); + assert_eq!(enr.udp6(), None); + assert_eq!(enr.signature(), &signature[..]); + assert_eq!(pubkey.to_vec(), expected_pubkey); + assert_eq!(enr.node_id().raw().to_vec(), expected_node_id); + assert!(enr.verify()); + } + + #[test] + fn test_vector_2_k256() { + let text = "enr:-IS4QHCYrYZbAKWCBRlAy5zzaDZXJBGkcnh4MHcBFZntXNFrdvJjX04jRzjzCBOonrkTfj499SZuOh8R33Ls8RRcy5wBgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQPKY0yuDUmstAHYpMa2_oxVtw0RW_QAdpzBQA8yWM0xOIN1ZHCCdl8"; + let expected_node_id = + hex::decode("a448f24c6d18e575453db13171562b71999873db5b286df957af199ec94617f7") + .unwrap(); + + let enr = text.parse::>().unwrap(); + assert_eq!(enr.ip4(), Some(Ipv4Addr::new(127, 0, 0, 1))); + assert_eq!(enr.id(), Some(String::from("v4"))); + assert_eq!(enr.udp4(), Some(30303)); + assert_eq!(enr.node_id().raw().to_vec(), expected_node_id); + assert!(enr.verify()); + } + + #[test] + fn test_read_enr_base64url_decoding_enforce_no_pad_no_extra_trailingbits() { + let test_data = [ + ( + "padded", + "Invalid base64 encoding: InvalidPadding", + "enr:-IS4QHCYrYZbAKWCBRlAy5zzaDZXJBGkcnh4MHcBFZntXNFrdvJjX04jRzjzCBOonrkTfj499SZuOh8R33Ls8RRcy5wBgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQPKY0yuDUmstAHYpMa2_oxVtw0RW_QAdpzBQA8yWM0xOIN1ZHCCdl8=", + ), + ( + "extra trailing bits", + "Invalid base64 encoding: InvalidLastSymbol(178, 57)", + "enr:-IS4QHCYrYZbAKWCBRlAy5zzaDZXJBGkcnh4MHcBFZntXNFrdvJjX04jRzjzCBOonrkTfj499SZuOh8R33Ls8RRcy5wBgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQPKY0yuDUmstAHYpMa2_oxVtw0RW_QAdpzBQA8yWM0xOIN1ZHCCdl9", + ), + ]; + for (test_name, err, text) in test_data { + assert_eq!(text.parse::().unwrap_err(), err, "{test_name}"); + } + } + + #[test] + fn test_read_enr_no_prefix() { + let text = "-Iu4QM-YJF2RRpMcZkFiWzMf2kRd1A5F1GIekPa4Sfi_v0DCLTDBfOMTMMWJhhawr1YLUPb5008CpnBKrgjY3sstjfgCgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQP8u1uyQFyJYuQUTyA1raXKhSw1HhhxNUQ2VE52LNHWMIN0Y3CCIyiDdWRwgiMo"; + text.parse::().unwrap(); + } + + #[test] + fn test_read_enr_prefix() { + let text = "enr:-Iu4QM-YJF2RRpMcZkFiWzMf2kRd1A5F1GIekPa4Sfi_v0DCLTDBfOMTMMWJhhawr1YLUPb5008CpnBKrgjY3sstjfgCgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQP8u1uyQFyJYuQUTyA1raXKhSw1HhhxNUQ2VE52LNHWMIN0Y3CCIyiDdWRwgiMo"; + text.parse::().unwrap(); + } + + #[test] + fn test_read_enr_reject_too_large_record() { + // 300-byte rlp encoded record, decode should succeed (large custom field is + // skipped after verification). + let text = concat!( + "enr:-QEpuEDaLyrPP4gxBI9YL7QE9U1tZig_Nt8rue8bRIuYv_IMziFc8OEt3LQMwkwt6da-Z0Y8BaqkDalZbBq647UtV2ei", + "AYJpZIJ2NIJpcIR_AAABiXNlY3AyNTZrMaEDymNMrg1JrLQB2KTGtv6MVbcNEVv0AHacwUAPMljNMTiDdWRwgnZferiieHh4", + "eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4", + "eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4", + "eHh4eHh4eHh4eHh4eHh4" + ); + let key_data = + hex::decode("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + .unwrap(); + let key = k256::ecdsa::SigningKey::from_slice(&key_data).unwrap(); + let mut record = text.parse::().unwrap(); + assert!(record.set_udp4(record.udp4().unwrap(), &key).is_ok()); + + // 301-byte record, creation should fail. + let text = concat!( + "enr:-QEquEBxABglcZbIGKJ8RHDCp2Ft59tdf61RhV3XXf2BKTlKE2XwzNfihH-46hKkANsXaGRwH8Dp7a3lTrKiv2FMMaFY", + "AYJpZIJ2NIJpcIR_AAABiXNlY3AyNTZrMaEDymNMrg1JrLQB2KTGtv6MVbcNEVv0AHacwUAPMljNMTiDdWRwgnZferijeHh4", + "eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4", + "eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4", + "eHh4eHh4eHh4eHh4eHh4eA" + ); + assert!(text.parse::().unwrap_err().contains("enr exceeds max size")); + } + + #[test] + fn test_rlp_integer_decoding() { + let text = "enr:-Ia4QHCYrYZbAKWCBRlAy5zzaDZXJBGkcnh4MHcBFZntXNFrdvJjX04jRzjzCBOonrkTfj499SZuOh8R33Ls8RRcy5yCAAGCaWSCdjSCaXCEfwAAAYlzZWNwMjU2azGhA8pjTK4NSay0Adikxrb-jFW3DRFb9AB2nMFADzJYzTE4g3VkcIJ2Xw"; + assert_eq!(text.parse::().unwrap_err(), "Invalid ENR: LeadingZero"); + } + + #[test] + fn test_encode_decode_k256() { + let key = k256::ecdsa::SigningKey::random(&mut rand::rngs::OsRng); + let ip = Ipv4Addr::new(127, 0, 0, 1); + let udp = 3000u16; + + let enr = Enr::builder().ip4(ip).udp4(udp).build(&key).unwrap(); + + let mut encoded_enr = BytesMut::new(); + enr.encode(&mut encoded_enr); + + let decoded_enr = + Enr::::decode(&mut encoded_enr.to_vec().as_slice()).unwrap(); + + assert_eq!(decoded_enr.id(), Some("v4".into())); + assert_eq!(decoded_enr.ip4(), Some(ip)); + assert_eq!(decoded_enr.udp4(), Some(udp)); + assert_eq!(decoded_enr.public_key().encode(), key.public().encode()); + decoded_enr.public_key().encode_uncompressed(); + assert!(decoded_enr.verify()); + } + + #[test] + fn test_set_ip() { + let mut rng = rand::thread_rng(); + let key = k256::ecdsa::SigningKey::random(&mut rng); + let udp = 30303u16; + let ip = Ipv4Addr::new(10, 0, 0, 1); + + let mut enr = Enr::builder().udp4(udp).build(&key).unwrap(); + + assert!(enr.set_ip(ip.into(), &key).is_ok()); + assert_eq!(enr.id(), Some("v4".into())); + assert_eq!(enr.ip4(), Some(ip)); + assert_eq!(enr.udp4(), Some(udp)); + assert!(enr.verify()); + assert_eq!(enr.public_key().encode(), key.public().encode()); + } + + #[test] + fn ip_mutation_static_node_id() { + let mut rng = rand::thread_rng(); + let key = k256::ecdsa::SigningKey::random(&mut rng); + let udp = 30303u16; + let ip = Ipv4Addr::new(10, 0, 0, 1); + + let mut enr = Enr::builder().ip4(ip).udp4(udp).build(&key).unwrap(); + let node_id = enr.node_id(); + + enr.set_udp_socket("192.168.0.1:800".parse::().unwrap(), &key).unwrap(); + assert_eq!(node_id, enr.node_id()); + assert_eq!(enr.udp4_socket(), Some("192.168.0.1:800".parse::().unwrap())); + } + + #[test] + fn test_read_enr_rlp_decoding_reject_extra_data() { + let record_hex = concat!( + "f884b8407098ad865b00a582051940cb9cf36836572411a47278783077011599", + "ed5cd16b76f2635f4e234738f30813a89eb9137e3e3df5266e3a1f11df72ecf1", + "145ccb9c01826964827634826970847f00000189736563703235366b31a103ca", + "634cae0d49acb401d8a4c6b6fe8c55b70d115bf400769cc1400f3258cd313883", + "75647082765f" + ); + let valid_record = hex::decode(record_hex).unwrap(); + let expected_pubkey = + hex::decode("03ca634cae0d49acb401d8a4c6b6fe8c55b70d115bf400769cc1400f3258cd3138") + .unwrap(); + + let enr = DefaultEnr::decode(&mut valid_record.as_slice()).unwrap(); + assert_eq!(enr.public_key().encode().to_vec(), expected_pubkey); + assert!(enr.verify()); + + // Truncated payload length + let invalid_hex = concat!( + "f883b8407098ad865b00a582051940cb9cf36836572411a47278783077011599", + "ed5cd16b76f2635f4e234738f30813a89eb9137e3e3df5266e3a1f11df72ecf1", + "145ccb9c01826964827634826970847f00000189736563703235366b31a103ca", + "634cae0d49acb401d8a4c6b6fe8c55b70d115bf400769cc1400f3258cd313883", + "75647082765f" + ); + DefaultEnr::decode(&mut hex::decode(invalid_hex).unwrap().as_slice()) + .expect_err("should reject truncated payload"); + } + + #[test] + fn test_compare_content() { + let key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); + let ip = Ipv4Addr::new(10, 0, 0, 1); + let udp = 30303u16; + + let enr1 = Enr::builder().ip4(ip).udp4(udp).build(&key).unwrap(); + let mut enr2 = enr1.clone(); + enr2.set_seq(1, &key).unwrap(); + let mut enr3 = enr1.clone(); + enr3.set_seq(2, &key).unwrap(); + + assert_ne!(enr1.signature(), enr2.signature()); + assert!(enr1.compare_content(&enr2)); + assert_ne!(enr1, enr2); + + assert_ne!(enr1.signature(), enr3.signature()); + assert!(!enr1.compare_content(&enr3)); + assert_ne!(enr1, enr3); + } + + #[test] + fn test_set_seq() { + let key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); + let mut enr = Enr::empty(&key).unwrap(); + enr.set_seq(30, &key).unwrap(); + assert_eq!(enr.seq(), 30); + enr.set_seq(u64::MAX, &key).unwrap(); + assert_eq!(enr.seq(), u64::MAX); + assert!(enr.verify()); + } + + #[test] + fn test_eth2_roundtrip() { + let key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); + let mut enr = Enr::builder().ip4(Ipv4Addr::LOCALHOST).udp4(9000u16).build(&key).unwrap(); + let eth2: [u8; 16] = std::array::from_fn(|i| (i + 1) as u8); + enr.set_eth2(eth2, &key).unwrap(); + assert_eq!(enr.eth2(), Some(eth2)); + assert!(enr.verify()); + + let mut encoded = BytesMut::new(); + enr.encode(&mut encoded); + let decoded = DefaultEnr::decode(&mut encoded.to_vec().as_slice()).unwrap(); + assert_eq!(decoded.eth2(), Some(eth2)); + assert!(decoded.verify()); + } + + #[test] + fn test_attnets_roundtrip() { + let key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); + let mut enr = Enr::builder().ip4(Ipv4Addr::LOCALHOST).udp4(9000u16).build(&key).unwrap(); + let attnets: [u8; 8] = [0xff, 0x00, 0xab, 0xcd, 0x12, 0x34, 0x56, 0x78]; + enr.set_attnets(attnets, &key).unwrap(); + assert_eq!(enr.attnets(), Some(attnets)); + assert!(enr.verify()); + + let mut encoded = BytesMut::new(); + enr.encode(&mut encoded); + let decoded = DefaultEnr::decode(&mut encoded.to_vec().as_slice()).unwrap(); + assert_eq!(decoded.attnets(), Some(attnets)); + assert!(decoded.verify()); + } + + #[test] + fn test_syncnets_roundtrip() { + let key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); + let mut enr = Enr::builder().ip4(Ipv4Addr::LOCALHOST).udp4(9000u16).build(&key).unwrap(); + let syncnets: u8 = 0x0f; + enr.set_syncnets(syncnets, &key).unwrap(); + assert_eq!(enr.syncnets(), Some(syncnets)); + assert!(enr.verify()); + + let mut encoded = BytesMut::new(); + enr.encode(&mut encoded); + let decoded = DefaultEnr::decode(&mut encoded.to_vec().as_slice()).unwrap(); + assert_eq!(decoded.syncnets(), Some(syncnets)); + assert!(decoded.verify()); + } + + #[test] + fn test_all_cl_fields_roundtrip_with_verify() { + let key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); + let mut enr = + Enr::builder().ip4(Ipv4Addr::new(10, 0, 0, 1)).udp4(30303u16).build(&key).unwrap(); + + let eth2: [u8; 16] = [0xde, 0xad, 0xbe, 0xef, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]; + let attnets: [u8; 8] = [0x01; 8]; + let syncnets: u8 = 0x03; + + enr.set_eth2(eth2, &key).unwrap(); + enr.set_attnets(attnets, &key).unwrap(); + enr.set_syncnets(syncnets, &key).unwrap(); + + assert_eq!(enr.eth2(), Some(eth2)); + assert_eq!(enr.attnets(), Some(attnets)); + assert_eq!(enr.syncnets(), Some(syncnets)); + assert!(enr.verify()); + + let mut encoded = BytesMut::new(); + enr.encode(&mut encoded); + let decoded = DefaultEnr::decode(&mut encoded.to_vec().as_slice()).unwrap(); + assert_eq!(decoded.eth2(), Some(eth2)); + assert_eq!(decoded.attnets(), Some(attnets)); + assert_eq!(decoded.syncnets(), Some(syncnets)); + assert!(decoded.verify()); + } +} diff --git a/crates/common/src/enr/node_id.rs b/crates/common/src/enr/node_id.rs new file mode 100644 index 0000000..7c9417f --- /dev/null +++ b/crates/common/src/enr/node_id.rs @@ -0,0 +1,183 @@ +// Adapted from https://github.com/sigp/enr (MIT License) + +use serde::{Deserialize, Serialize}; + +use super::{Enr, EnrKey, digest, keys::EnrPublicKey}; + +type RawNodeId = [u8; 32]; + +#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct NodeId { + #[serde(with = "serde_hex_prfx")] + raw: RawNodeId, +} + +impl NodeId { + pub const fn new(raw_input: &[u8; 32]) -> Self { + Self { raw: *raw_input } + } + + pub fn random() -> Self { + Self { raw: rand::random() } + } + + pub const fn raw(&self) -> RawNodeId { + self.raw + } +} + +impl<'a> From<&'a NodeId> for NodeId { + fn from(id: &'a NodeId) -> Self { + *id + } +} + +impl From for NodeId { + fn from(public_key: T) -> Self { + Self::new(&digest(public_key.encode_uncompressed().as_ref())) + } +} + +impl From> for NodeId { + fn from(enr: Enr) -> Self { + enr.node_id() + } +} + +impl From<&Enr> for NodeId { + fn from(enr: &Enr) -> Self { + enr.node_id() + } +} + +impl AsRef<[u8]> for NodeId { + fn as_ref(&self) -> &[u8] { + &self.raw[..] + } +} + +impl PartialEq for NodeId { + fn eq(&self, other: &RawNodeId) -> bool { + self.raw.eq(other) + } +} + +impl From for NodeId { + fn from(raw: RawNodeId) -> Self { + Self { raw } + } +} + +impl TryFrom<&[u8]> for NodeId { + type Error = &'static str; + + fn try_from(raw_input: &[u8]) -> Result { + raw_input.try_into().map(Self::new).map_err(|_| "NodeId must be exactly 32 bytes") + } +} + +impl std::fmt::Display for NodeId { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + let hex_encode = hex::encode(self.raw); + write!(f, "0x{}..{}", &hex_encode[0..4], &hex_encode[hex_encode.len() - 4..]) + } +} + +impl std::fmt::Debug for NodeId { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "0x{}", hex::encode(self.raw)) + } +} + +/// Serialize with the 0x prefix. +mod serde_hex_prfx { + + pub fn serialize + hex::ToHex, S: serde::Serializer>( + data: &T, + serializer: S, + ) -> Result { + let dst = format!("0x{}", hex::encode(data)); + serializer.serialize_str(&dst) + } + + /// Deserialize with the 0x prefix. + pub fn deserialize<'de, D, T>(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + T: hex::FromHex, + ::Error: std::fmt::Display, + { + /// Helper struct to obtain a owned string when necessary (using + /// [`serde_json`], for example) or a borrowed string with the + /// appropriate lifetime (most the time). + // NOTE: see https://github.com/serde-rs/serde/issues/1413#issuecomment-494892266 and + // https://github.com/sigp/enr/issues/62 + #[derive(serde::Deserialize)] + struct CowNodeId<'a>(#[serde(borrow)] std::borrow::Cow<'a, str>); + + let CowNodeId::<'de>(raw) = serde::Deserialize::deserialize(deserializer)?; + + let src = raw.strip_prefix("0x").unwrap_or(&raw); + hex::FromHex::from_hex(src).map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_eq_node_raw_node() { + let node = NodeId::random(); + let raw = node.raw; + assert_eq!(node, raw); + assert_eq!(node.as_ref(), &raw[..]); + } + + #[test] + fn test_serde_str() { + let node = NodeId::random(); + let json_string = serde_json::to_string(&node).unwrap(); + assert_eq!(node, serde_json::from_str::(&json_string).unwrap()); + } + + #[test] + fn test_serde_slice() { + let node = NodeId::random(); + let json_bytes = serde_json::to_vec(&node).unwrap(); + assert_eq!(node, serde_json::from_slice::(&json_bytes).unwrap()); + } + + #[test] + fn test_serde_value() { + let node = NodeId::random(); + let value = serde_json::to_value(node).unwrap(); + assert_eq!(node, serde_json::from_value::(value).unwrap()); + } + + #[test] + fn test_serde_0x() { + let raw = [ + 154, 95, 80, 100, 224, 32, 222, 137, 157, 219, 197, 24, 45, 143, 90, 106, 99, 12, 9, + 93, 44, 66, 196, 203, 35, 233, 26, 59, 50, 128, 168, 180, + ]; + let node = NodeId::new(&raw); + let json_string = serde_json::to_string(&node).unwrap(); + assert_eq!( + json_string, + "\"0x9a5f5064e020de899ddbc5182d8f5a6a630c095d2c42c4cb23e91a3b3280a8b4\"" + ); + let snode = serde_json::from_str::(&json_string).unwrap(); + assert_eq!(node, snode); + } + + #[test] + fn test_serde_as_hashmap_key() { + use std::collections::HashMap; + + let mut responses: HashMap = HashMap::default(); + responses.insert(NodeId::random(), 1); + let _ = serde_json::json!(responses); + } +} diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index bd3847e..b29ca5f 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -6,10 +6,14 @@ pub use crate::{ util::create_self_signed_certificate, }; +mod enr; mod error; mod id; mod util; +pub type Enr = enr::Enr; +pub use enr::NodeId; + #[from_spine("silver")] #[derive(Debug)] pub struct SilverSpine { diff --git a/crates/discovery/Cargo.toml b/crates/discovery/Cargo.toml new file mode 100644 index 0000000..3a3b1dc --- /dev/null +++ b/crates/discovery/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "discovery" +edition.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +aes.workspace = true +aes-gcm.workspace = true +alloy-rlp.workspace = true +ctr.workspace = true +bytes.workspace = true +flux.workspace = true +flux-utils.workspace = true +hkdf.workspace = true +k256.workspace = true +rand.workspace = true +serde.workspace = true +sha2.workspace = true +silver-common.workspace = true +rustc-hash.workspace = true +thiserror.workspace = true +tracing.workspace = true +uint.workspace = true + +[dev-dependencies] +hex.workspace = true +k256.workspace = true +rand.workspace = true +silver-common.workspace = true + +[lints] +workspace = true diff --git a/crates/discovery/src/config.rs b/crates/discovery/src/config.rs new file mode 100644 index 0000000..ea0e1fa --- /dev/null +++ b/crates/discovery/src/config.rs @@ -0,0 +1,21 @@ +use std::time::Duration; + +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +pub struct DiscoveryConfig { + pub find_nodes_peer_count: usize, + pub ping_frequency_s: u64, + pub query_parallelism: usize, + pub query_peer_timeout_ms: u64, +} + +impl DiscoveryConfig { + pub fn ping_frequency(&self) -> Duration { + Duration::from_secs(self.ping_frequency_s) + } + + pub fn query_peer_timeout(&self) -> Duration { + Duration::from_millis(self.query_peer_timeout_ms) + } +} diff --git a/crates/discovery/src/crypto.rs b/crates/discovery/src/crypto.rs new file mode 100644 index 0000000..456f13c --- /dev/null +++ b/crates/discovery/src/crypto.rs @@ -0,0 +1,262 @@ +use aes_gcm::{Aes128Gcm, aead::KeyInit}; +use flux::utils::ArrayVec; +use hkdf::Hkdf; +use k256::{ + ProjectivePoint, + ecdsa::{ + Signature, SigningKey, VerifyingKey, + signature::{DigestSigner, DigestVerifier}, + }, + elliptic_curve::sec1::ToEncodedPoint, +}; +use sha2::{Digest, Sha256}; +use silver_common::NodeId; + +pub const MAX_PACKET_SIZE: usize = 1280; + +const KEY_AGREEMENT_INFO_PREFIX: &[u8] = b"discovery v5 key agreement"; +const ID_SIGNATURE_PREFIX: &[u8] = b"discovery v5 identity proof"; + +pub type SessionCipher = Aes128Gcm; + +pub fn make_cipher(key: &[u8; 16]) -> SessionCipher { + Aes128Gcm::new_from_slice(key).expect("key is 16 bytes") +} + +/// Static ECDH: scalar multiply `remote_vk` by `local_sk`, return compressed +/// point. +pub fn ecdh(remote_vk: &VerifyingKey, local_sk: &SigningKey) -> [u8; 33] { + let pt = (ProjectivePoint::from(*remote_vk.as_affine()) * + local_sk.as_nonzero_scalar().as_ref()) + .to_affine(); + pt.to_encoded_point(true).as_bytes().try_into().expect("compressed point is 33 bytes") +} + +/// HKDF-SHA256 key derivation per discv5 spec. +/// `first_id` = initiator, `second_id` = responder. +/// Returns `(initiator_key, recipient_key)`. +pub fn derive_session_keys( + secret: &[u8], + first_id: &NodeId, + second_id: &NodeId, + challenge_data: &[u8], +) -> ([u8; 16], [u8; 16]) { + let mut info = [0u8; 90]; // 26 + 32 + 32 + info[..26].copy_from_slice(KEY_AGREEMENT_INFO_PREFIX); + info[26..58].copy_from_slice(&first_id.raw()); + info[58..].copy_from_slice(&second_id.raw()); + + let hk = Hkdf::::new(Some(challenge_data), secret); + let mut okm = [0u8; 32]; + hk.expand(&info, &mut okm).expect("32 bytes is valid HKDF output"); + + let mut k1 = [0u8; 16]; + let mut k2 = [0u8; 16]; + k1.copy_from_slice(&okm[..16]); + k2.copy_from_slice(&okm[16..]); + (k1, k2) +} + +/// Generate ephemeral keypair, ECDH with remote's permanent pubkey, derive +/// initiator session keys. Returns `(ephem_pk_bytes, initiator_key, +/// recipient_key)`. +pub fn ecdh_generate_and_derive( + remote_pubkey: &[u8; 33], + local_id: &NodeId, + remote_id: &NodeId, + challenge_data: &[u8], +) -> Option<([u8; 33], [u8; 16], [u8; 16])> { + let remote_vk = VerifyingKey::from_sec1_bytes(remote_pubkey).ok()?; + let ephem_sk = SigningKey::random(&mut rand::thread_rng()); + let ephem_pk_bytes: [u8; 33] = + ephem_sk.verifying_key().to_encoded_point(true).as_bytes().try_into().ok()?; + let secret = ecdh(&remote_vk, &ephem_sk); + let (initiator_key, recipient_key) = + derive_session_keys(&secret, local_id, remote_id, challenge_data); + Some((ephem_pk_bytes, initiator_key, recipient_key)) +} + +/// ECDH on responder side: local static key × peer's ephemeral pubkey. +/// Returns `(initiator_key, recipient_key)`. +pub fn ecdh_and_derive_keys_responder( + local_key: &SigningKey, + ephem_pubkey: &[u8; 33], + initiator_id: &NodeId, + responder_id: &NodeId, + challenge_data: &[u8], +) -> Option<([u8; 16], [u8; 16])> { + let ephem_vk = VerifyingKey::from_sec1_bytes(ephem_pubkey).ok()?; + let secret = ecdh(&ephem_vk, local_key); + Some(derive_session_keys(&secret, initiator_id, responder_id, challenge_data)) +} + +pub fn sign_id_nonce( + local_key: &SigningKey, + challenge_data: &[u8], + ephem_pubkey: &[u8; 33], + dst_id: &NodeId, +) -> Option<[u8; 64]> { + let digest = Sha256::new() + .chain_update(ID_SIGNATURE_PREFIX) + .chain_update(challenge_data) + .chain_update(ephem_pubkey) + .chain_update(dst_id.raw()); + let sig: Signature = local_key.sign_digest(digest); + let raw = sig.to_bytes(); + let mut out = [0u8; 64]; + out.copy_from_slice(raw.as_ref()); + Some(out) +} + +pub fn verify_id_nonce_sig( + remote_pubkey: &[u8; 33], + challenge_data: &[u8], + ephem_pubkey: &[u8; 33], + dst_id: &NodeId, + sig: &[u8; 64], +) -> bool { + let Ok(vk) = VerifyingKey::from_sec1_bytes(remote_pubkey) else { return false }; + let Ok(signature) = Signature::from_slice(sig) else { return false }; + let digest = Sha256::new() + .chain_update(ID_SIGNATURE_PREFIX) + .chain_update(challenge_data) + .chain_update(ephem_pubkey) + .chain_update(dst_id.raw()); + vk.verify_digest(digest, &signature).is_ok() +} + +pub fn encrypt_message( + cipher: &SessionCipher, + nonce: &[u8; 12], + aad: &[u8], + msg: &[u8], +) -> Option> { + use aes_gcm::aead::{Aead, Payload}; + let ct = cipher.encrypt(&(*nonce).into(), Payload { msg, aad }).ok()?; + let mut out = ArrayVec::new(); + out.extend(ct.iter().copied()); + Some(out) +} + +pub fn decrypt_message( + cipher: &SessionCipher, + nonce: &[u8; 12], + aad: &[u8], + msg: &[u8], +) -> Option> { + use aes_gcm::aead::{Aead, Payload}; + let pt = cipher.decrypt(&(*nonce).into(), Payload { msg, aad }).ok()?; + let mut out = ArrayVec::new(); + out.extend(pt.iter().copied()); + Some(out) +} + +#[cfg(test)] +mod tests { + use silver_common::NodeId; + + use super::*; + + fn h(s: &str) -> Vec { + hex::decode(s).unwrap() + } + + #[test] + fn ecdh_matches_spec() { + let sk = SigningKey::from_slice(&h( + "fb757dc581730490a1d7a00deea65e9b1936924caaea8f44d476014856b68736", + )) + .unwrap(); + let remote_vk = VerifyingKey::from_sec1_bytes(&h( + "039961e4c2356d61bedb83052c115d311acb3a96f5777296dcf297351130266231", + )) + .unwrap(); + let expected = h("033b11a2a1f214567e1537ce5e509ffd9b21373247f2a3ff6841f4976f53165e7e"); + assert_eq!(ecdh(&remote_vk, &sk).as_slice(), expected.as_slice()); + } + + #[test] + fn key_derivation_matches_spec() { + let ephem_sk = SigningKey::from_slice(&h( + "fb757dc581730490a1d7a00deea65e9b1936924caaea8f44d476014856b68736", + )) + .unwrap(); + let dest_vk = VerifyingKey::from_sec1_bytes(&h( + "0317931e6e0840220642f230037d285d122bc59063221ef3226b1f403ddc69ca91", + )) + .unwrap(); + let node_id_a = NodeId::new( + &h("aaaa8419e9f49d0083561b48287df592939a8d19947d8c0ef88f2a4856a69fbb") + .try_into() + .unwrap(), + ); + let node_id_b = NodeId::new( + &h("bbbb9d047f0488c0b5a93c1c3f2d8bafc7c8ff337024a55434a0d0555de64db9") + .try_into() + .unwrap(), + ); + let challenge_data = h( + "000000000000000000000000000000006469736376350001010102030405060708090a0b0c00180102030405060708090a0b0c0d0e0f100000000000000000", + ); + + let secret = ecdh(&dest_vk, &ephem_sk); + let (ik, rk) = derive_session_keys(&secret, &node_id_a, &node_id_b, &challenge_data); + + assert_eq!(ik.as_slice(), h("dccc82d81bd610f4f76d3ebe97a40571").as_slice()); + assert_eq!(rk.as_slice(), h("ac74bb8773749920b0d3a8881c173ec5").as_slice()); + } + + #[test] + fn id_nonce_sig_verifies_spec_vector() { + let sk = SigningKey::from_slice(&h( + "fb757dc581730490a1d7a00deea65e9b1936924caaea8f44d476014856b68736", + )) + .unwrap(); + let challenge_data = h( + "000000000000000000000000000000006469736376350001010102030405060708090a0b0c00180102030405060708090a0b0c0d0e0f100000000000000000", + ); + let ephem_pubkey: [u8; 33] = + h("039961e4c2356d61bedb83052c115d311acb3a96f5777296dcf297351130266231") + .try_into() + .unwrap(); + let dst_id = NodeId::new( + &h("bbbb9d047f0488c0b5a93c1c3f2d8bafc7c8ff337024a55434a0d0555de64db9") + .try_into() + .unwrap(), + ); + let expected_sig: [u8; 64] = h("94852a1e2318c4e5e9d422c98eaf19d1d90d876b29cd06ca7cb7546d0fff7b484fe86c09a064fe72bdbef73ba8e9c34df0cd2b53e9d65528c2c7f336d5dfc6e6") + .try_into() + .unwrap(); + + let static_pk: [u8; 33] = + sk.verifying_key().to_encoded_point(true).as_bytes().try_into().unwrap(); + + assert!(verify_id_nonce_sig( + &static_pk, + &challenge_data, + &ephem_pubkey, + &dst_id, + &expected_sig + )); + + let produced = sign_id_nonce(&sk, &challenge_data, &ephem_pubkey, &dst_id).unwrap(); + assert_eq!(produced, expected_sig); + } + + #[test] + fn aes_gcm_encrypt_matches_spec() { + let key: [u8; 16] = h("9f2d77db7004bf8a1a85107ac686990b").try_into().unwrap(); + let nonce: [u8; 12] = h("27b5af763c446acd2749fe8e").try_into().unwrap(); + let pt = h("01c20101"); + let ad = h("93a7400fa0d6a694ebc24d5cf570f65d04215b6ac00757875e3f3a5f42107903"); + let expected_ct = h("a5d12a2d94b8ccb3ba55558229867dc13bfa3648"); + + let cipher = make_cipher(&key); + let ct = encrypt_message(&cipher, &nonce, &ad, &pt).unwrap(); + assert_eq!(ct.as_slice(), expected_ct.as_slice()); + + let cipher2 = make_cipher(&key); + let recovered = decrypt_message(&cipher2, &nonce, &ad, &ct).unwrap(); + assert_eq!(recovered.as_slice(), pt.as_slice()); + } +} diff --git a/crates/discovery/src/discovery.rs b/crates/discovery/src/discovery.rs new file mode 100644 index 0000000..7b04e92 --- /dev/null +++ b/crates/discovery/src/discovery.rs @@ -0,0 +1,38 @@ +use std::{net::SocketAddr, time::Instant}; + +use flux::utils::ArrayVec; +use silver_common::NodeId; + +use crate::crypto::MAX_PACKET_SIZE; + +#[derive(Debug)] +#[allow(clippy::large_enum_variant)] +pub enum DiscoveryEvent { + SendMessage { to: SocketAddr, data: ArrayVec }, + NodeFound(NodeId), + SessionEstablished { node_id: NodeId, addr: SocketAddr }, + ExternalAddrChanged(SocketAddr), +} + +pub trait Discovery { + fn local_id(&self) -> NodeId; + + fn add_node( + &mut self, + id: NodeId, + addr: SocketAddr, + enr_seq: u64, + pubkey: [u8; 33], + now: Instant, + ); + + fn find_node(&mut self, target: NodeId); + + // todo @ nina: ban / unban +} + +pub trait DiscoveryNetworking { + fn handle(&mut self, src_addr: SocketAddr, data: &[u8], now: Instant); + + fn poll(&mut self, f: F); +} diff --git a/crates/discovery/src/discv5.rs b/crates/discovery/src/discv5.rs new file mode 100644 index 0000000..b7dac3a --- /dev/null +++ b/crates/discovery/src/discv5.rs @@ -0,0 +1,1476 @@ +use std::{ + net::{IpAddr, SocketAddr}, + time::{Duration, Instant}, +}; + +use alloy_rlp::{Decodable, Encodable}; +use flux::utils::ArrayVec; +use k256::ecdsa::SigningKey; +use rand::RngCore as _; +use rustc_hash::FxHashMap; +use silver_common::{Enr, NodeId}; +use tracing::warn; + +use crate::{ + config::DiscoveryConfig, + crypto::{ + MAX_PACKET_SIZE, SessionCipher, decrypt_message, ecdh_and_derive_keys_responder, + ecdh_generate_and_derive, encrypt_message, make_cipher, sign_id_nonce, verify_id_nonce_sig, + }, + discovery::{Discovery, DiscoveryEvent, DiscoveryNetworking}, + kbucket::{KBucketsTable, Key, MAX_NODES_PER_BUCKET}, + message::{Distances, ENR_RECORD_MAX, Message, MessageKind, Packet}, + query_pool::{FindNodeQueryConfig, QueryPool, QueryPoolState}, +}; + +const MAX_SESSIONS_COUNT: usize = 1024; +const NONCE_RING_SIZE: usize = 64; + +const SESSION_TIMEOUT: Duration = Duration::from_secs(20 * 60); +const CHALLENGE_TTL: Duration = Duration::from_secs(5); + +const IP_VOTE_THRESHOLD: u32 = 3; + +pub struct DiscV5 { + config: DiscoveryConfig, + local_key: SigningKey, + local_id: NodeId, + local_enr: Enr, + local_enr_raw: ArrayVec, + fork_digest: [u8; 4], + + kbuckets: KBucketsTable, + queries: QueryPool, + + sessions: FxHashMap, + + challenges: FxHashMap, + pending_findnodes: FxHashMap, + pending_probe_nonces: FxHashMap, + event_queue: Vec, + + ip_votes: FxHashMap, + ip_vote_counts: FxHashMap<(IpAddr, u16), u32>, + + next_request_id: u64, + last_ping: Instant, +} + +impl DiscV5 { + pub fn new( + config: DiscoveryConfig, + local_key: SigningKey, + local_enr: Enr, + fork_digest: [u8; 4], + ) -> Self { + let local_id = NodeId::from(*local_key.verifying_key()); + let kbuckets = KBucketsTable::new(Key::from(local_id), Duration::from_secs(60)); + let query_timeout = + config.query_peer_timeout() * (config.query_parallelism as u32).saturating_add(1); + let local_enr_raw = { + let mut buf: ArrayVec = ArrayVec::new(); + local_enr.encode(&mut buf); + buf + }; + Self { + config, + local_key, + local_id, + local_enr, + local_enr_raw, + fork_digest, + kbuckets, + queries: QueryPool::new(query_timeout), + sessions: FxHashMap::with_capacity_and_hasher(MAX_SESSIONS_COUNT, Default::default()), + challenges: FxHashMap::with_capacity_and_hasher(MAX_SESSIONS_COUNT, Default::default()), + pending_findnodes: FxHashMap::with_capacity_and_hasher( + MAX_SESSIONS_COUNT, + Default::default(), + ), + pending_probe_nonces: FxHashMap::with_capacity_and_hasher( + MAX_SESSIONS_COUNT, + Default::default(), + ), + ip_votes: FxHashMap::with_capacity_and_hasher(MAX_SESSIONS_COUNT, Default::default()), + ip_vote_counts: FxHashMap::with_capacity_and_hasher(8, Default::default()), + event_queue: Vec::with_capacity(MAX_SESSIONS_COUNT * 2), + next_request_id: 0, + last_ping: Instant::now(), + } + } + + fn next_id(&mut self) -> u64 { + let id = self.next_request_id; + self.next_request_id = self.next_request_id.wrapping_add(1); + id + } + + fn flush_pending_findnode(&mut self, node_id: NodeId, dest_addr: SocketAddr) { + if let Some((rid, distances)) = self.pending_findnodes.remove(&node_id) && + let Some(s) = self.sessions.get(&node_id) && + let Some(data) = + Packet::encode_message(self.local_id, node_id, &s.enc, Message::FindNode { + request_id: rid, + distances, + }) + { + self.event_queue.push(DiscoveryEvent::SendMessage { to: dest_addr, data }); + } + } + + /// Send a Message packet encrypted with a random key. The remote can't + /// decrypt it and will respond with WhoAreYou, initiating the handshake. + /// The probe nonce is recorded so we can verify the WhoAreYou echoes it. + // todo @nina: pre-generate random material at idle time? + fn send_probe(&mut self, dest_id: NodeId, dest_addr: SocketAddr, f: &mut F) + where + F: FnMut(DiscoveryEvent), + { + let mut rng_buf = [0u8; 44]; + rand::thread_rng().fill_bytes(&mut rng_buf); + + let nonce: [u8; 12] = rng_buf[..12].try_into().unwrap(); + let iv: u128 = u128::from_be_bytes(rng_buf[12..28].try_into().unwrap()); + let cipher = make_cipher(&rng_buf[28..].try_into().unwrap()); + + let mut plain: ArrayVec = ArrayVec::new(); + Message::Ping { request_id: 0, enr_seq: 0 }.encode(&mut plain); + + let tmp = + Packet { iv, src_id: self.local_id, nonce, kind: MessageKind::Message, message: &[] }; + let aad = tmp.authenticated_data(); + + if let Some(ct) = encrypt_message(&cipher, &nonce, &aad, &plain) { + let data = Packet { + iv, + src_id: self.local_id, + nonce, + kind: MessageKind::Message, + message: &ct, + } + .encode(&dest_id); + + self.pending_probe_nonces.insert(dest_id, nonce); + f(DiscoveryEvent::SendMessage { to: dest_addr, data }); + } + } + + fn send_whoareyou( + &mut self, + src_id: NodeId, + src_addr: SocketAddr, + nonce: [u8; 12], + now: Instant, + ) { + if self.challenges.contains_key(&src_id) { + return; + } + let known_enr_seq = + self.kbuckets.get(&Key::from(src_id)).map(|n| n.value.enr_seq).unwrap_or(0); + + let rng_buf: [u8; 32] = rand::random(); + let id_nonce: [u8; 16] = rng_buf[..16].try_into().unwrap(); + let iv: u128 = u128::from_be_bytes(rng_buf[16..].try_into().unwrap()); + + let wru = Packet { + iv, + src_id: NodeId::new(&[0; 32]), + nonce, + kind: MessageKind::WhoAreYou { id_nonce, enr_seq: known_enr_seq }, + message: &[], + }; + let aad_arr = wru.authenticated_data(); + + let mut data = [0u8; 63]; + data.copy_from_slice(&aad_arr); + + let wire = wru.encode(&src_id); + + self.challenges.insert(src_id, PendingChallenge { data, sent_at: now }); + self.event_queue.push(DiscoveryEvent::SendMessage { to: src_addr, data: wire }); + } + + fn on_ping( + &mut self, + src_id: NodeId, + src_addr: SocketAddr, + request_id: u64, + enr_seq: u64, + stored_enr_seq: u64, + ) { + // Compute rid before borrowing sessions to avoid &mut self conflict. + let enr_rid = (enr_seq > stored_enr_seq).then(|| self.next_id()); + let Some(s) = self.sessions.get(&src_id) else { return }; + + let pong = Message::Pong { + request_id, + enr_seq: self.local_enr.seq(), + ip: src_addr.ip(), + port: src_addr.port(), + }; + if let Some(data) = Packet::encode_message(self.local_id, src_id, &s.enc, pong) { + self.event_queue.push(DiscoveryEvent::SendMessage { to: src_addr, data }); + } + + // If the peer's ENR is newer than what we have, request it. + if let Some(rid) = enr_rid { + let mut distances = Distances::new(); + distances.push(0); + if let Some(data) = + Packet::encode_message(self.local_id, src_id, &s.enc, Message::FindNode { + request_id: rid, + distances, + }) + { + self.event_queue.push(DiscoveryEvent::SendMessage { to: src_addr, data }); + } + } + } + + fn on_pong(&mut self, src_id: NodeId, ip: IpAddr, port: u16) { + let addr = (ip, port); + let old = self.ip_votes.insert(src_id, addr); + if old == Some(addr) { + return; + } + if let Some(old_addr) = old { + if let Some(c) = self.ip_vote_counts.get_mut(&old_addr) { + *c = c.saturating_sub(1); + } + } + let count = self.ip_vote_counts.entry(addr).or_insert(0); + *count += 1; + + if *count >= IP_VOTE_THRESHOLD { + let changed = match ip { + IpAddr::V4(a) => self.local_enr.ip4() != Some(a), + IpAddr::V6(a) => self.local_enr.ip6() != Some(a), + }; + if changed { + let socket = SocketAddr::new(ip, port); + let _ = self.local_enr.set_udp_socket(socket, &self.local_key); + let mut raw: ArrayVec = ArrayVec::new(); + self.local_enr.encode(&mut raw); + self.local_enr_raw = raw; + self.event_queue.push(DiscoveryEvent::ExternalAddrChanged(socket)); + } + } + } + + fn on_nodes( + &mut self, + src_id: NodeId, + nodes: ArrayVec, 8>, + now: Instant, + ) { + let mut discovered: ArrayVec = ArrayVec::new(); + for raw in nodes.iter() { + let Ok(enr) = Enr::decode(&mut raw.as_slice()) else { continue }; + if enr.eth2().map(|e| e[..4] != self.fork_digest).unwrap_or(false) { + continue; + } + + let addr = if let (Some(ip4), Some(udp4)) = (enr.ip4(), enr.udp4()) { + SocketAddr::new(IpAddr::V4(ip4), udp4) + } else if let (Some(ip6), Some(udp6)) = (enr.ip6(), enr.udp6()) { + SocketAddr::new(IpAddr::V6(ip6), udp6) + } else { + continue; + }; + + let Ok(pk_bytes) = enr.public_key().to_encoded_point(true).as_bytes().try_into() else { + continue; + }; + + let node_id = enr.node_id(); + let _ = self.kbuckets.insert_or_update( + &Key::from(node_id), + NodeEntry { addr, enr_seq: enr.seq(), pubkey: pk_bytes, enr_raw: Some(*raw) }, + now, + ); + + if !discovered.is_full() { + discovered.push(node_id); + } + } + if let Some(query) = self.queries.find_query_for_peer(&src_id) { + query.on_success(&src_id, discovered); + } + } + + fn send_nodes_response( + &mut self, + dst_id: NodeId, + dst_addr: SocketAddr, + request_id: u64, + distances: &Distances, + ) { + let node_ids = self.kbuckets.nodes_by_distances(distances.as_slice(), MAX_NODES_PER_BUCKET); + + let mut enrs: ArrayVec, 16> = ArrayVec::new(); + if distances.iter().any(|&d| d == 0) { + enrs.push(self.local_enr_raw); + } + for id in node_ids { + if enrs.is_full() { + break; + } + if let Some(raw) = self.kbuckets.get(&Key::from(id)).and_then(|n| n.value.enr_raw) { + enrs.push(raw); + } + } + + let s = match self.sessions.get(&dst_id) { + Some(s) => s, + None => return, + }; + + // IV(16) + header(23) + auth_data(32) + GCM tag(16) + msg overhead(~18) + const BUDGET: usize = MAX_PACKET_SIZE - 105; + + let mut total: u8 = 1; + let mut acc: usize = 0; + for enr in enrs.iter() { + if acc > 0 && acc + enr.len() > BUDGET { + total = total.saturating_add(1); + acc = 0; + } + acc += enr.len(); + } + + let mut batch: ArrayVec, 8> = ArrayVec::new(); + let batch_bytes = |b: &ArrayVec, 8>| -> usize { + b.iter().map(|e| e.len()).sum() + }; + + for enr in enrs { + if batch_bytes(&batch) + enr.len() > BUDGET { + if let Some(data) = + Packet::encode_message(self.local_id, dst_id, &s.enc, Message::Nodes { + request_id, + total, + nodes: batch, + }) + { + self.event_queue.push(DiscoveryEvent::SendMessage { to: dst_addr, data }); + } + batch = ArrayVec::new(); + } + if !batch.is_full() { + batch.push(enr); + } + } + + if let Some(data) = Packet::encode_message(self.local_id, dst_id, &s.enc, Message::Nodes { + request_id, + total, + nodes: batch, + }) { + self.event_queue.push(DiscoveryEvent::SendMessage { to: dst_addr, data }); + } + } + + fn handle_message(&mut self, src_id: NodeId, src_addr: SocketAddr, bytes: &[u8], now: Instant) { + let msg = match Message::decode(bytes) { + Some(o) => o, + None => { + warn!(%src_id, %src_addr, len = bytes.len(), "failed to decode message payload"); + return; + } + }; + + let existing = match self.kbuckets.get(&Key::from(src_id)).map(|n| n.value) { + Some(e) => e, + None => { + warn!(%src_id, %src_addr, "message from peer with no kbuckets entry"); + return; + } + }; + + let msg_enr_seq = match &msg { + Message::Ping { enr_seq, .. } | Message::Pong { enr_seq, .. } => *enr_seq, + _ => existing.enr_seq, + }; + + let _ = self.kbuckets.insert_or_update( + &Key::from(src_id), + NodeEntry { + addr: src_addr, + enr_seq: msg_enr_seq, + pubkey: existing.pubkey, + enr_raw: existing.enr_raw, + }, + now, + ); + + match msg { + Message::Ping { request_id, enr_seq } => { + self.on_ping(src_id, src_addr, request_id, enr_seq, existing.enr_seq); + } + Message::Pong { ip, port, .. } => { + self.on_pong(src_id, ip, port); + } + Message::FindNode { request_id, distances } => { + self.send_nodes_response(src_id, src_addr, request_id, &distances); + } + Message::Nodes { nodes, .. } => { + self.on_nodes(src_id, nodes, now); + } + } + } + + fn handle_whoareyou( + &mut self, + probe_nonce: [u8; 12], + src_addr: SocketAddr, + aad: &[u8], + peer_enr_seq: u64, + now: Instant, + ) { + let (remote_id, remote_pubkey) = match self + .kbuckets + .iter_ref() + .find(|n| n.value.addr == src_addr) + .map(|n| (*n.key.preimage(), n.value.pubkey)) + { + Some(v) => v, + None => { + warn!(%src_addr, peer_enr_seq, "WhoAreYou from unknown addr, no kbuckets entry"); + return; + } + }; + + // Verify the WhoAreYou echoes the nonce from our probe. For stale-session + // re-challenges (we already have a session) we skip this check since the + // challenged packet was a regular Message, not a probe. + if !self.sessions.contains_key(&remote_id) { + match self.pending_probe_nonces.remove(&remote_id) { + Some(n) if n == probe_nonce => {} + _ => { + warn!(%remote_id, %src_addr, peer_enr_seq, "WhoAreYou nonce mismatch or no pending probe"); + return; + } + } + } else { + self.pending_probe_nonces.remove(&remote_id); + } + + // TODO @nina: pre-generate ephemeral keypairs at idle time? + let (ephem_pk_bytes, enc_key, dec_key) = match ecdh_generate_and_derive( + &remote_pubkey, + &self.local_id, + &remote_id, + aad, + ) { + Some(v) => v, + None => { + warn!(%remote_id, %src_addr, "ECDH key derivation failed during WhoAreYou handling"); + return; + } + }; + + let sig_bytes = match sign_id_nonce(&self.local_key, aad, &ephem_pk_bytes, &remote_id) { + Some(s) => s, + None => { + warn!(%remote_id, %src_addr, "id-nonce signing failed during WhoAreYou handling"); + return; + } + }; + + let rng_buf: [u8; 28] = rand::random(); + let handshake_nonce: [u8; 12] = rng_buf[..12].try_into().unwrap(); + let iv: u128 = u128::from_be_bytes(rng_buf[12..].try_into().unwrap()); + + let enr_record = + if peer_enr_seq < self.local_enr.seq() { Some(self.local_enr_raw) } else { None }; + + let kind = MessageKind::Handshake { + id_nonce_sig: sig_bytes, + ephem_pubkey: ephem_pk_bytes, + enr_record, + }; + + // Compute the handshake AAD (doesn't include the message body). + let hs_aad = Packet { + iv, + src_id: self.local_id, + nonce: handshake_nonce, + kind: kind.clone(), + message: &[], + } + .authenticated_data(); + + let enc_cipher = make_cipher(&enc_key); + let ciphertext = if let Some((rid, dists)) = self.pending_findnodes.remove(&remote_id) { + let mut plain: ArrayVec = ArrayVec::new(); + Message::FindNode { request_id: rid, distances: dists }.encode(&mut plain); + encrypt_message(&enc_cipher, &handshake_nonce, &hs_aad, &plain) + } else { + None + }; + + let wire = Packet { + iv, + src_id: self.local_id, + nonce: handshake_nonce, + kind, + message: ciphertext.as_ref().map(|c| c.as_slice()).unwrap_or(&[]), + } + .encode(&remote_id); + + self.sessions.insert(remote_id, Session::new(enc_key, dec_key, src_addr, now)); + + self.event_queue.push(DiscoveryEvent::SendMessage { to: src_addr, data: wire }); + self.event_queue + .push(DiscoveryEvent::SessionEstablished { node_id: remote_id, addr: src_addr }); + } + + #[allow(clippy::too_many_arguments)] + fn handle_handshake( + &mut self, + src_id: NodeId, + src_addr: SocketAddr, + nonce: [u8; 12], + aad: &[u8], + id_nonce_sig: [u8; 64], + ephem_pubkey: [u8; 33], + enr_record: Option>, + message: &[u8], + now: Instant, + ) { + let challenge = match self.challenges.remove(&src_id) { + Some(c) => c, + None => { + warn!(%src_id, %src_addr, "handshake from peer with no pending challenge"); + return; + } + }; + + let existing_entry = self.kbuckets.get(&Key::from(src_id)).map(|n| n.value); + let (remote_pubkey, stored_enr_raw) = match existing_entry { + Some(e) => (e.pubkey, e.enr_raw), + None => { + // If we don't have the node's record, Handshake must contain it. + let raw = match enr_record { + Some(b) => b, + None => { + warn!(%src_id, %src_addr, "handshake missing ENR for unknown peer"); + return; + } + }; + + let enr = match Enr::decode(&mut raw.as_slice()) { + Ok(e) => e, + Err(_) => { + warn!(%src_id, %src_addr, "handshake contains invalid ENR"); + return; + } + }; + + if enr.node_id() != src_id { + warn!(%src_id, %src_addr, "handshake ENR node-id mismatch"); + return; + } + + let pk_bytes: [u8; 33] = + match enr.public_key().to_encoded_point(true).as_bytes().try_into() { + Ok(b) => b, + Err(_) => { + warn!(%src_id, %src_addr, "handshake ENR has invalid public key"); + return; + } + }; + + self.kbuckets.insert_or_update( + &Key::from(src_id), + NodeEntry { + addr: src_addr, + enr_seq: enr.seq(), + pubkey: pk_bytes, + enr_raw: Some(raw), + }, + now, + ); + + (pk_bytes, Some(raw)) + } + }; + + // Update enr_raw if a fresher record was provided in this Handshake. + if let Some(raw) = enr_record { + if stored_enr_raw != Some(raw) { + if let Ok(enr) = Enr::decode(&mut raw.as_slice()) { + if enr.node_id() == src_id { + if let Ok(pk_bytes) = + enr.public_key().to_encoded_point(true).as_bytes().try_into() + { + let _ = self.kbuckets.insert_or_update( + &Key::from(src_id), + NodeEntry { + addr: src_addr, + enr_seq: enr.seq(), + pubkey: pk_bytes, + enr_raw: Some(raw), + }, + now, + ); + } + } + } + } + } + + if !verify_id_nonce_sig( + &remote_pubkey, + &challenge.data, + &ephem_pubkey, + &self.local_id, + &id_nonce_sig, + ) { + warn!(%src_id, %src_addr, "handshake id-nonce signature verification failed"); + return; + } + + let (initiator_key, recipient_key) = match ecdh_and_derive_keys_responder( + &self.local_key, + &ephem_pubkey, + &src_id, + &self.local_id, + &challenge.data, + ) { + Some(v) => v, + None => { + warn!(%src_id, %src_addr, "ECDH key derivation failed during handshake"); + return; + } + }; + + let session = Session::new(recipient_key, initiator_key, src_addr, now); + + if message.is_empty() { + self.sessions.insert(src_id, session); + } else if let Some(bytes) = decrypt_message(&session.dec, &nonce, aad, message) { + self.sessions.insert(src_id, session); + self.handle_message(src_id, src_addr, &bytes, now); + } else { + warn!(%src_id, %src_addr, msg_len = message.len(), "handshake message decryption failed"); + return; + } + + self.pending_probe_nonces.remove(&src_id); + + self.event_queue + .push(DiscoveryEvent::SessionEstablished { node_id: src_id, addr: src_addr }); + } + + fn to_log2distances(&self, target: NodeId) -> Distances { + let local_key = Key::from(self.local_id); + let target_key = Key::from(target); + let base = local_key.log2_distance(&target_key).unwrap_or(255); + + let count = (self.config.find_nodes_peer_count as u64).min(64); + let mut distances = Distances::new(); + for i in 0..count { + let d = base.saturating_sub(i / 2).clamp(1, 256); + if !distances.iter().any(|&x| x == d) && distances.len() < 64 { + distances.push(d); + } + } + + distances + } +} + +impl Discovery for DiscV5 { + fn local_id(&self) -> NodeId { + self.local_id + } + + fn add_node( + &mut self, + id: NodeId, + addr: SocketAddr, + enr_seq: u64, + pubkey: [u8; 33], + now: Instant, + ) { + let _ = self.kbuckets.insert_or_update( + &Key::from(id), + NodeEntry { addr, enr_seq, pubkey, enr_raw: None }, + now, + ); + } + + fn find_node(&mut self, target: NodeId) { + let target_key = Key::from(target); + let (kbuckets, queries) = (&mut self.kbuckets, &mut self.queries); + + let mut closest = + kbuckets.closest_keys(&target_key).take(self.config.find_nodes_peer_count).peekable(); + if closest.peek().is_none() { + return; + } + + queries.add_findnode_query( + FindNodeQueryConfig::new_from_config(&self.config), + target_key, + closest, + ); + } +} + +impl DiscoveryNetworking for DiscV5 { + fn poll(&mut self, mut f: F) + where + F: FnMut(DiscoveryEvent), + { + for event in self.event_queue.drain(..) { + f(event); + } + + self.challenges.retain(|_, c| c.sent_at.elapsed() < CHALLENGE_TTL); + self.sessions.retain(|_, s| s.last_active.elapsed() < SESSION_TIMEOUT); + + while let Some(applied) = self.kbuckets.take_applied_pending() { + f(DiscoveryEvent::NodeFound(*applied.inserted.preimage())); + } + + if self.last_ping.elapsed() >= self.config.ping_frequency() { + self.last_ping = Instant::now(); + + let mut rid = self.next_request_id; + for node in self.kbuckets.iter_ref() { + let node_id = *node.key.preimage(); + if let Some(s) = self.sessions.get(&node_id) && + let Some(data) = + Packet::encode_message(self.local_id, node_id, &s.enc, Message::Ping { + request_id: rid, + enr_seq: self.local_enr.seq(), + }) + { + rid = rid.wrapping_add(1); + f(DiscoveryEvent::SendMessage { to: node.value.addr, data }); + } + } + + self.next_request_id = rid.wrapping_add(1); + } + + loop { + match self.queries.poll() { + QueryPoolState::Idle | QueryPoolState::Waiting(None) => break, + QueryPoolState::Waiting(Some((query, node_id))) => { + let qid = query.id(); + let addr = self.kbuckets.get(&Key::from(node_id)).map(|n| n.value.addr); + if let Some(addr) = addr { + let rid = self.next_id(); + let distances = self.to_log2distances(node_id); + + if let Some(s) = self.sessions.get(&node_id) { + if let Some(data) = Packet::encode_message( + self.local_id, + node_id, + &s.enc, + Message::FindNode { request_id: rid, distances }, + ) { + f(DiscoveryEvent::SendMessage { to: addr, data }); + } + } else { + // No session: probe to trigger WhoAreYou→Handshake; + // queue FINDNODE for once the session is up. + // Only probe once — if an entry already exists we're waiting. + let is_new = + self.pending_findnodes.insert(node_id, (rid, distances)).is_none(); + if is_new { + self.send_probe(node_id, addr, &mut f); + } + } + } else if let Some(query) = self.queries.get_mut(qid) { + query.on_failure(&node_id); + } + } + QueryPoolState::Finished(query) | QueryPoolState::Timeout(query) => { + for node_id in query.into_result() { + f(DiscoveryEvent::NodeFound(node_id)); + } + } + } + } + } + + fn handle(&mut self, src_addr: SocketAddr, data: &[u8], now: Instant) { + let (packet, aad) = match Packet::decode(&self.local_id, data) { + Ok(v) => v, + Err(e) => { + warn!(%src_addr, %e, len = data.len(), "packet decode failed"); + return; + } + }; + + let src_id = packet.src_id; + let nonce = packet.nonce; + + match packet.kind { + MessageKind::Message => { + // Invalidate session if peer's endpoint changed. + if self.sessions.get(&src_id).is_some_and(|s| s.addr != src_addr) { + self.sessions.remove(&src_id); + } + + if let Some(s) = self.sessions.get_mut(&src_id) { + if !s.check_and_record_nonce(&nonce, now) { + warn!(%src_id, %src_addr, ?nonce, "replayed nonce, dropping message"); + return; + } + + let plain = decrypt_message(&s.dec, &nonce, &aad, packet.message); + if let Some(bytes) = plain { + self.handle_message(src_id, src_addr, &bytes, now); + } else { + // Stale session or decryption failure; re-challenge. + self.send_whoareyou(src_id, src_addr, nonce, now); + } + } else { + self.send_whoareyou(src_id, src_addr, nonce, now); + } + } + + MessageKind::WhoAreYou { enr_seq, .. } => { + self.handle_whoareyou(nonce, src_addr, &aad, enr_seq, now); + } + + MessageKind::Handshake { id_nonce_sig, ephem_pubkey, enr_record } => { + self.handle_handshake( + src_id, + src_addr, + nonce, + &aad, + id_nonce_sig, + ephem_pubkey, + enr_record, + packet.message, + now, + ); + + self.flush_pending_findnode(src_id, src_addr); + } + } + } +} + +struct NonceRing { + buf: [[u8; 12]; NONCE_RING_SIZE], + head: usize, + len: usize, +} + +impl NonceRing { + fn new() -> Self { + Self { buf: [[0u8; 12]; NONCE_RING_SIZE], head: 0, len: 0 } + } + + fn insert(&mut self, nonce: [u8; 12]) -> bool { + let valid = self.len.min(NONCE_RING_SIZE); + if self.buf[..valid].contains(&nonce) { + return false; + } + self.buf[self.head] = nonce; + self.head = (self.head + 1) % NONCE_RING_SIZE; + if self.len < NONCE_RING_SIZE { + self.len += 1; + } + true + } +} + +struct Session { + enc: SessionCipher, + dec: SessionCipher, + seen_nonces: NonceRing, + addr: SocketAddr, + last_active: Instant, +} + +impl Session { + fn new(enc_key: [u8; 16], dec_key: [u8; 16], addr: SocketAddr, now: Instant) -> Self { + Session { + enc: make_cipher(&enc_key), + dec: make_cipher(&dec_key), + seen_nonces: NonceRing::new(), + addr, + last_active: now, + } + } + + fn check_and_record_nonce(&mut self, nonce: &[u8; 12], now: Instant) -> bool { + if !self.seen_nonces.insert(*nonce) { + return false; + } + self.last_active = now; + true + } +} + +/// Challenge data: `iv(16) || static_header(23) || auth_data(24)` +struct PendingChallenge { + data: [u8; 63], + sent_at: Instant, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NodeEntry { + pub addr: SocketAddr, + pub enr_seq: u64, + /// Compressed secp256k1 public key. Required for ECDH during + /// WhoAreYou response. + pub pubkey: [u8; 33], + /// Raw RLP-encoded ENR record, if known. + pub enr_raw: Option>, +} + +#[cfg(test)] +mod tests { + use std::{ + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, + time::Instant, + }; + + use k256::ecdsa::SigningKey; + use silver_common::{Enr, NodeId}; + + use super::*; + use crate::{ + config::DiscoveryConfig, + discovery::{Discovery, DiscoveryEvent, DiscoveryNetworking}, + message::{Message, Packet}, + }; + + fn test_config() -> DiscoveryConfig { + DiscoveryConfig { + find_nodes_peer_count: 3, + ping_frequency_s: 3600, + query_parallelism: 3, + query_peer_timeout_ms: 5_000, + } + } + + fn make_node(port: u16) -> (DiscV5, SocketAddr, [u8; 33]) { + let key = SigningKey::random(&mut rand::thread_rng()); + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); + let enr = Enr::builder().ip4(Ipv4Addr::LOCALHOST).udp4(port).build(&key).unwrap(); + let pubkey: [u8; 33] = + key.verifying_key().to_encoded_point(true).as_bytes().try_into().unwrap(); + let disco = DiscV5::new(test_config(), key, enr, [0u8; 4]); + (disco, addr, pubkey) + } + + fn collect_sends(node: &mut DiscV5) -> Vec<(SocketAddr, Vec)> { + let mut out = Vec::new(); + node.poll(|e| { + if let DiscoveryEvent::SendMessage { to, data } = e { + out.push((to, data.to_vec())); + } + }); + out + } + + fn collect_events(node: &mut DiscV5) -> Vec { + let mut out = Vec::new(); + node.poll(|e| out.push(e)); + out + } + + fn inject_message( + from: &mut DiscV5, + to: &mut DiscV5, + from_addr: SocketAddr, + msg: Message, + now: Instant, + ) { + let to_id = to.local_id; + let data = { + let s = from.sessions.get(&to_id).expect("no session from->to"); + Packet::encode_message(from.local_id, to_id, &s.enc, msg).expect("encode failed") + }; + to.handle(from_addr, &data, now); + } + + /// Drive the probe → WhoAreYou → Handshake exchange between two nodes. + /// After return both nodes have an established session. + fn do_handshake( + a: &mut DiscV5, + a_addr: SocketAddr, + b: &mut DiscV5, + b_addr: SocketAddr, + b_pubkey: [u8; 33], + now: Instant, + ) { + a.add_node(b.local_id, b_addr, b.local_enr.seq(), b_pubkey, now); + a.find_node(NodeId::random()); + + // A → probe + let a_sends = collect_sends(a); + let probe = a_sends.iter().find(|(to, _)| *to == b_addr).map(|(_, d)| d.clone()).unwrap(); + + // B → WhoAreYou + b.handle(a_addr, &probe, now); + let b_sends = collect_sends(b); + let wru = b_sends.iter().find(|(to, _)| *to == a_addr).map(|(_, d)| d.clone()).unwrap(); + + // A handles WhoAreYou → queues Handshake + FindNode + a.handle(b_addr, &wru, now); + let a_sends = collect_sends(a); + + // Route all A→B packets (Handshake first, then FindNode) + for (to, data) in &a_sends { + if *to == b_addr { + b.handle(a_addr, data, now); + } + } + // B drains its queue (SessionEstablished, etc.) + collect_events(b); + } + + #[test] + fn test_session_establishment() { + let now = Instant::now(); + let (mut a, a_addr, _) = make_node(19001); + let (mut b, b_addr, b_pubkey) = make_node(19002); + + do_handshake(&mut a, a_addr, &mut b, b_addr, b_pubkey, now); + + assert!(a.sessions.contains_key(&b.local_id), "A missing session for B"); + assert!(b.sessions.contains_key(&a.local_id), "B missing session for A"); + assert!(!b.challenges.contains_key(&a.local_id), "B still has challenge for A"); + } + + #[test] + fn test_ping_pong_roundtrip() { + let now = Instant::now(); + let (mut a, a_addr, _) = make_node(19011); + let (mut b, b_addr, b_pubkey) = make_node(19012); + + do_handshake(&mut a, a_addr, &mut b, b_addr, b_pubkey, now); + + let votes_before = a.ip_votes.len(); + + // A sends Ping; B replies with Pong. + inject_message(&mut a, &mut b, a_addr, Message::Ping { request_id: 1, enr_seq: 0 }, now); + let b_sends = collect_sends(&mut b); + for (to, data) in &b_sends { + if *to == a_addr { + a.handle(b_addr, data, now); + } + } + collect_events(&mut a); + + let votes_after = a.ip_votes.len(); + assert!(votes_after > votes_before, "ip_votes not updated after Pong"); + } + + #[test] + fn test_findnode_distance_zero_returns_own_enr() { + let now = Instant::now(); + let (mut a, a_addr, _) = make_node(19021); + let (mut b, b_addr, b_pubkey) = make_node(19022); + + do_handshake(&mut a, a_addr, &mut b, b_addr, b_pubkey, now); + + // B sends FindNode(distance=0) to A. + let mut distances = Distances::new(); + distances.push(0); + inject_message( + &mut b, + &mut a, + b_addr, + Message::FindNode { request_id: 10, distances }, + now, + ); + let a_sends = collect_sends(&mut a); + + // Route A's Nodes reply to B. + for (to, data) in &a_sends { + if *to == b_addr { + b.handle(a_addr, data, now); + } + } + collect_events(&mut b); + + // A's ENR should now be stored in B's kbuckets. + let has_a_enr = b + .kbuckets + .iter_ref() + .any(|n| *n.key.preimage() == a.local_id && n.value.enr_raw.is_some()); + assert!(has_a_enr, "B should have A's ENR after distance-0 FindNode"); + } + + #[test] + fn test_findnode_returns_discovered_peers() { + let now = Instant::now(); + let (mut a, a_addr, _) = make_node(19031); + let (mut b, b_addr, b_pubkey) = make_node(19032); + // C is a third node known to A with a full ENR. + let (c, c_addr, c_pubkey) = make_node(19033); + let c_id = c.local_id; + + // Pre-populate A's kbuckets with C's ENR. + let c_enr_raw = { + let mut buf: ArrayVec = ArrayVec::new(); + c.local_enr.encode(&mut buf); + buf + }; + let _ = a.kbuckets.insert_or_update( + &Key::from(c_id), + NodeEntry { + addr: c_addr, + enr_seq: c.local_enr.seq(), + pubkey: c_pubkey, + enr_raw: Some(c_enr_raw), + }, + now, + ); + + do_handshake(&mut a, a_addr, &mut b, b_addr, b_pubkey, now); + + // B sends FindNode over all plausible distances; A should include C. + let mut distances = Distances::new(); + for d in 250u64..=256 { + distances.push(d); + } + inject_message( + &mut b, + &mut a, + b_addr, + Message::FindNode { request_id: 11, distances }, + now, + ); + let a_sends = collect_sends(&mut a); + + for (to, data) in &a_sends { + if *to == b_addr { + b.handle(a_addr, data, now); + } + } + collect_events(&mut b); + + let has_c = b.kbuckets.iter_ref().any(|n| *n.key.preimage() == c_id); + assert!(has_c, "B should have discovered C via A's FindNode reply"); + } + + #[test] + fn test_pong_ipv6_vote_updates_enr() { + let now = Instant::now(); + // A has no IP so every Pong-observed address is "new". + let key = SigningKey::random(&mut rand::thread_rng()); + let a_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 19041); + let enr = Enr::builder().build(&key).unwrap(); + let mut a = DiscV5::new(test_config(), key, enr, [0u8; 4]); + + // Need 3 distinct peers to cross IP_VOTE_THRESHOLD (one vote per NodeId). + let (mut b, b_addr, b_pubkey) = make_node(19042); + let (mut c, c_addr, c_pubkey) = make_node(19043); + let (mut d, d_addr, d_pubkey) = make_node(19044); + do_handshake(&mut a, a_addr, &mut b, b_addr, b_pubkey, now); + do_handshake(&mut a, a_addr, &mut c, c_addr, c_pubkey, now); + do_handshake(&mut a, a_addr, &mut d, d_addr, d_pubkey, now); + + let ipv6 = IpAddr::V6(Ipv6Addr::LOCALHOST); + let port = 19041u16; + + inject_message( + &mut b, + &mut a, + b_addr, + Message::Pong { request_id: 0, enr_seq: 0, ip: ipv6, port }, + now, + ); + inject_message( + &mut c, + &mut a, + c_addr, + Message::Pong { request_id: 0, enr_seq: 0, ip: ipv6, port }, + now, + ); + inject_message( + &mut d, + &mut a, + d_addr, + Message::Pong { request_id: 0, enr_seq: 0, ip: ipv6, port }, + now, + ); + + let events = collect_events(&mut a); + assert!( + events + .iter() + .any(|e| matches!(e, DiscoveryEvent::ExternalAddrChanged(sa) if sa.ip() == ipv6)), + "expected ExternalAddrChanged with IPv6 address" + ); + } + + #[test] + fn test_nodes_fork_digest_filter() { + let now = Instant::now(); + let fork_digest = [0x01, 0x02, 0x03, 0x04u8]; + let key_a = SigningKey::random(&mut rand::thread_rng()); + let a_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 19051); + let enr_a = Enr::builder().ip4(Ipv4Addr::LOCALHOST).udp4(19051u16).build(&key_a).unwrap(); + let mut a = DiscV5::new(test_config(), key_a, enr_a, fork_digest); + + let (mut b, b_addr, b_pubkey) = make_node(19052); + do_handshake(&mut a, a_addr, &mut b, b_addr, b_pubkey, now); + + // Build two ENRs: + // - good: no eth2 field (passes the filter) + // - bad: eth2 with wrong fork_digest (dropped) + let key_good = SigningKey::random(&mut rand::thread_rng()); + let enr_good = + Enr::builder().ip4(Ipv4Addr::new(10, 0, 0, 1)).udp4(19053u16).build(&key_good).unwrap(); + let id_good = enr_good.node_id(); + + let key_bad = SigningKey::random(&mut rand::thread_rng()); + let mut enr_bad = + Enr::builder().ip4(Ipv4Addr::new(10, 0, 0, 2)).udp4(19054u16).build(&key_bad).unwrap(); + // Wrong fork digest: all zeros. + enr_bad.set_eth2([0u8; 16], &key_bad).unwrap(); + let id_bad = enr_bad.node_id(); + + let mut good_raw: ArrayVec = ArrayVec::new(); + enr_good.encode(&mut good_raw); + let mut bad_raw: ArrayVec = ArrayVec::new(); + enr_bad.encode(&mut bad_raw); + + let mut nodes: ArrayVec, 8> = ArrayVec::new(); + nodes.push(bad_raw); + nodes.push(good_raw); + + inject_message( + &mut b, + &mut a, + b_addr, + Message::Nodes { request_id: 20, total: 1, nodes }, + now, + ); + collect_events(&mut a); + + assert!( + a.kbuckets.iter_ref().any(|n| *n.key.preimage() == id_good), + "good (no-eth2) node should be in kbuckets" + ); + assert!( + !a.kbuckets.iter_ref().any(|n| *n.key.preimage() == id_bad), + "bad (wrong fork_digest) node should be filtered" + ); + } + + #[test] + fn test_nodes_response_splits_across_packets() { + let now = Instant::now(); + let (mut a, a_addr, _) = make_node(19081); + let (mut b, b_addr, b_pubkey) = make_node(19082); + + do_handshake(&mut a, a_addr, &mut b, b_addr, b_pubkey, now); + + // Populate A's kbuckets with 8 peers whose ENRs include CL fields + // to push each record to ~180 bytes, forcing multi-packet responses. + let mut peer_ids: Vec = Vec::new(); + for i in 0u16..8 { + let pk = SigningKey::random(&mut rand::thread_rng()); + let port = 20000 + i; + let mut enr = Enr::builder() + .ip4(Ipv4Addr::new(10, 0, 0, (i + 1) as u8)) + .udp4(port) + .build(&pk) + .unwrap(); + // fork_digest [0;4] matches B's fork_digest from make_node. + enr.set_eth2([0u8; 16], &pk).unwrap(); + enr.set_attnets([0xFF; 8], &pk).unwrap(); + enr.set_syncnets(0x0F, &pk).unwrap(); + let p_id = enr.node_id(); + let p_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, (i + 1) as u8)), port); + let p_pubkey: [u8; 33] = + pk.verifying_key().to_encoded_point(true).as_bytes().try_into().unwrap(); + let mut raw: ArrayVec = ArrayVec::new(); + enr.encode(&mut raw); + let _ = a.kbuckets.insert_or_update( + &Key::from(p_id), + NodeEntry { + addr: p_addr, + enr_seq: enr.seq(), + pubkey: p_pubkey, + enr_raw: Some(raw), + }, + now, + ); + peer_ids.push(p_id); + } + + // Request all distances so A returns as many peers as possible. + let mut distances = Distances::new(); + for d in 240u64..=256 { + distances.push(d); + } + inject_message( + &mut b, + &mut a, + b_addr, + Message::FindNode { request_id: 50, distances }, + now, + ); + + // Collect raw packets A sends to B. + let a_sends = collect_sends(&mut a); + let packets_to_b: Vec<_> = a_sends.iter().filter(|(to, _)| *to == b_addr).collect(); + + assert!( + packets_to_b.len() > 1, + "expected multiple Nodes packets, got {}", + packets_to_b.len() + ); + + // Deliver all packets to B and verify peers landed in kbuckets. + for (_, data) in &packets_to_b { + b.handle(a_addr, data, now); + } + collect_events(&mut b); + + // Verify all peers landed in B's kbuckets. + let found: Vec<_> = peer_ids + .iter() + .filter(|id| b.kbuckets.iter_ref().any(|n| n.key.preimage() == *id)) + .collect(); + assert_eq!(found.len(), peer_ids.len(), "B should have discovered all peers"); + + // Decrypt each packet and verify the `total` field matches the actual count. + let n_packets = packets_to_b.len(); + for (_, wire) in &packets_to_b { + let (pkt, aad) = Packet::decode(&b.local_id, wire).unwrap(); + let s = b.sessions.get(&a.local_id).unwrap(); + let plain = + crate::crypto::decrypt_message(&s.dec, &pkt.nonce, &aad, pkt.message).unwrap(); + let msg = Message::decode(&plain).unwrap(); + match msg { + Message::Nodes { total, .. } => { + assert_eq!( + total as usize, n_packets, + "total field ({total}) must match packet count ({n_packets})" + ); + } + other => panic!("expected Nodes, got {other:?}"), + } + } + } + + #[test] + fn test_nodes_response_empty_distances() { + let now = Instant::now(); + let (mut a, a_addr, _) = make_node(19091); + let (mut b, b_addr, b_pubkey) = make_node(19092); + + do_handshake(&mut a, a_addr, &mut b, b_addr, b_pubkey, now); + + // Request distance 1 — extremely unlikely any random peer lands there. + let mut distances = Distances::new(); + distances.push(1); + inject_message( + &mut b, + &mut a, + b_addr, + Message::FindNode { request_id: 60, distances }, + now, + ); + + let a_sends = collect_sends(&mut a); + let packets_to_b: Vec<_> = a_sends.iter().filter(|(to, _)| *to == b_addr).collect(); + + // Should still get exactly one Nodes message (with empty list). + assert_eq!(packets_to_b.len(), 1, "expected 1 empty Nodes response"); + } + + #[test] + fn test_challenge_inserted_before_handshake_complete() { + let now = Instant::now(); + let (mut a, a_addr, _) = make_node(19061); + let (mut b, b_addr, b_pubkey) = make_node(19062); + + // Add B to A's kbuckets and trigger a probe to generate a challenge in B. + a.add_node(b.local_id, b_addr, b.local_enr.seq(), b_pubkey, now); + a.find_node(NodeId::random()); + let a_sends = collect_sends(&mut a); + let probe = a_sends.iter().find(|(to, _)| *to == b_addr).map(|(_, d)| d.clone()).unwrap(); + + b.handle(a_addr, &probe, now); + collect_sends(&mut b); // drain WhoAreYou + + // Challenge was recorded in B. + assert!(b.challenges.contains_key(&a.local_id), "challenge should be present"); + // Session is not yet established (handshake not completed). + assert!(!b.sessions.contains_key(&a.local_id)); + } + + #[test] + fn test_nonce_replay_rejected() { + let now = Instant::now(); + let (mut a, a_addr, _) = make_node(19071); + let (mut b, b_addr, b_pubkey) = make_node(19072); + do_handshake(&mut a, a_addr, &mut b, b_addr, b_pubkey, now); + + let wire = { + let s = a.sessions.get(&b.local_id).expect("no session a→b"); + Packet::encode_message(a.local_id, b.local_id, &s.enc, Message::Ping { + request_id: 42, + enr_seq: 0, + }) + .expect("encode") + }; + + // First delivery succeeds (Pong produced). + b.handle(a_addr, &wire, now); + let sends1 = collect_sends(&mut b); + assert!(!sends1.is_empty(), "first Ping should produce Pong"); + + // Replay identical bytes — nonce ring rejects, no response. + b.handle(a_addr, &wire, now); + let sends2 = collect_sends(&mut b); + assert!(sends2.is_empty(), "replayed packet must be dropped"); + } + + #[test] + fn test_session_expiry_on_poll() { + let now = Instant::now(); + let (mut a, a_addr, _) = make_node(19111); + let (mut b, b_addr, b_pubkey) = make_node(19112); + do_handshake(&mut a, a_addr, &mut b, b_addr, b_pubkey, now); + assert!(b.sessions.contains_key(&a.local_id)); + + b.sessions.get_mut(&a.local_id).unwrap().last_active = Instant::now() + .checked_sub(SESSION_TIMEOUT + Duration::from_secs(1)) + .expect("system uptime > SESSION_TIMEOUT"); + + collect_events(&mut b); + assert!(!b.sessions.contains_key(&a.local_id), "expired session should be removed"); + } + + #[test] + fn test_challenge_ttl_expiry() { + let now = Instant::now(); + let (mut a, a_addr, _) = make_node(19121); + let (mut b, b_addr, b_pubkey) = make_node(19122); + + a.add_node(b.local_id, b_addr, b.local_enr.seq(), b_pubkey, now); + a.find_node(NodeId::random()); + let a_sends = collect_sends(&mut a); + let probe = a_sends.iter().find(|(to, _)| *to == b_addr).unwrap().1.clone(); + + b.handle(a_addr, &probe, now); + collect_sends(&mut b); + assert!(b.challenges.contains_key(&a.local_id)); + + b.challenges.get_mut(&a.local_id).unwrap().sent_at = Instant::now() + .checked_sub(CHALLENGE_TTL + Duration::from_secs(1)) + .expect("system uptime > CHALLENGE_TTL"); + + collect_events(&mut b); + assert!(!b.challenges.contains_key(&a.local_id), "expired challenge should be removed"); + } +} diff --git a/crates/discovery/src/kbucket.rs b/crates/discovery/src/kbucket.rs new file mode 100644 index 0000000..99d7e14 --- /dev/null +++ b/crates/discovery/src/kbucket.rs @@ -0,0 +1,494 @@ +// Copyright 2018 Parity Technologies (UK) Ltd. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +// This basis of this file has been taken from the rust-libp2p codebase: +// https://github.com/libp2p/rust-libp2p + +use std::{ + collections::VecDeque, + time::{Duration, Instant}, +}; + +use flux::utils::ArrayVec; +use silver_common::NodeId; +use uint::construct_uint; + +construct_uint! { + struct U256(4); +} + +/// A node's position in the Kademlia keyspace. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct Key { + preimage: NodeId, + hash: U256, +} + +impl Key { + pub fn preimage(&self) -> &NodeId { + &self.preimage + } + + /// XOR distance between two keys. + pub fn distance(&self, other: &Key) -> Distance { + Distance(self.hash ^ other.hash) + } + + /// Integer log-2 of the XOR distance. Returns `None` if keys are identical. + /// Range: 1–256. + pub fn log2_distance(&self, other: &Key) -> Option { + let xor = self.distance(other); + let log = u64::from(256 - xor.0.leading_zeros()); + if log == 0 { None } else { Some(log) } + } +} + +impl From for Key { + fn from(node_id: NodeId) -> Self { + Key { preimage: node_id, hash: U256::from_big_endian(&node_id.raw()) } + } +} + +impl AsRef for Key { + fn as_ref(&self) -> &Key { + self + } +} + +/// XOR distance between two `Key`s. +#[derive(Copy, Clone, PartialEq, Eq, Default, PartialOrd, Ord, Debug)] +pub struct Distance(U256); + +pub const MAX_NODES_PER_BUCKET: usize = 16; + +/// A node in a k-bucket. +#[derive(Debug, Clone, Copy)] +pub struct Node { + pub key: Key, + pub value: T, + /// Timestamp of the last received message from this node. + pub last_seen: Instant, +} + +#[derive(Clone, Copy)] +struct PendingNode { + node: Node, + replace: Instant, +} + +/// A k-bucket holding at most `MAX_NODES_PER_BUCKET` nodes, ordered by +/// `last_seen` ascending — index 0 is the LRU and eviction candidate. +#[derive(Clone)] +struct KBucket { + nodes: ArrayVec, MAX_NODES_PER_BUCKET>, + pending: Option>, + pending_timeout: Duration, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InsertResult { + Inserted, + Updated, + /// Bucket full; `oldest` is the current LRU that will be evicted after + /// `pending_timeout` unless it sends a message first. + Pending { + oldest: Key, + }, + Failed(FailureReason), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FailureReason { + BucketFull, + SelfUpdate, +} + +pub struct AppliedPending { + pub inserted: Key, + #[allow(dead_code)] + pub evicted: Option>, +} + +impl KBucket { + fn new(pending_timeout: Duration) -> Self { + KBucket { nodes: ArrayVec::new(), pending: None, pending_timeout } + } + + fn iter(&self) -> impl Iterator> { + self.nodes.iter() + } + + fn position(&self, key: &Key) -> Option { + self.nodes.iter().position(|n| &n.key == key) + } + + /// Insert or update a node. + /// + /// - Existing node in nodes: update value + `last_seen`, move to tail. + /// - Existing node in pending slot: update value + `last_seen` in place. + /// - Bucket not full: insert at tail. + /// - Bucket full, no pending: record as pending, return `Pending{oldest}` + /// - Bucket full, pending occupied: `Failed(BucketFull)`. + fn insert_or_update(&mut self, key: &Key, value: T, now: Instant) -> InsertResult { + if let Some(pos) = self.position(key) { + // Copy out, shift left to close the gap, then re-push at tail. + let mut node = self.nodes[pos]; + let len = self.nodes.len(); + self.nodes.copy_within(pos + 1..len, pos); + self.nodes.truncate(len - 1); + node.value = value; + node.last_seen = now; + self.nodes.push(node); + return InsertResult::Updated; + } + + if let Some(ref mut p) = self.pending { + if &p.node.key == key { + p.node.value = value; + p.node.last_seen = now; + return InsertResult::Updated; + } + } + + if self.nodes.is_full() { + if self.pending.is_some() { + return InsertResult::Failed(FailureReason::BucketFull); + } + let oldest = self.nodes[0].key; + self.pending = Some(PendingNode { + node: Node { key: *key, value, last_seen: now }, + replace: now + self.pending_timeout, + }); + return InsertResult::Pending { oldest }; + } + + self.nodes.push(Node { key: *key, value, last_seen: now }); + InsertResult::Inserted + } + + /// Promote the pending node if its timeout has elapsed, evicting the + /// current LRU. + fn apply_pending(&mut self) -> Option> { + let pending = self.pending.take()?; + if pending.replace > Instant::now() { + self.pending = Some(pending); + return None; + } + let inserted = pending.node.key; + let evicted = if self.nodes.is_full() { + let node = self.nodes[0]; + let len = self.nodes.len(); + self.nodes.copy_within(1..len, 0); + self.nodes.truncate(len - 1); + Some(node) + } else { + None + }; + self.nodes.push(pending.node); + Some(AppliedPending { inserted, evicted }) + } +} + +const NUM_BUCKETS: usize = 256; + +/// A Kademlia routing table keyed by `NodeId`. +pub struct KBucketsTable { + local_key: Key, + buckets: Vec>, + applied_pending: VecDeque>, +} + +impl KBucketsTable { + pub fn new(local_key: Key, pending_timeout: Duration) -> Self { + KBucketsTable { + local_key, + buckets: (0..NUM_BUCKETS).map(|_| KBucket::new(pending_timeout)).collect(), + applied_pending: VecDeque::new(), + } + } + + /// Insert or update a node. `now` is the message receive timestamp. + pub fn insert_or_update(&mut self, key: &Key, value: T, now: Instant) -> InsertResult { + let Some(i) = BucketIndex::new(&self.local_key.distance(key)) else { + return InsertResult::Failed(FailureReason::SelfUpdate); + }; + let bucket = &mut self.buckets[i.get()]; + if let Some(applied) = bucket.apply_pending() { + self.applied_pending.push_back(applied); + } + bucket.insert_or_update(key, value, now) + } + + /// Iterate all nodes (no pending promotion triggered). + pub fn iter_ref(&self) -> impl Iterator> { + self.buckets.iter().flat_map(|b| b.iter()) + } + + /// Consume the next pending-eviction event, if any. + pub fn take_applied_pending(&mut self) -> Option> { + self.applied_pending.pop_front() + } + + /// Return up to `max_nodes` node IDs from buckets at the given log2 + /// distances (1–256). Applies pending promotions on traversed buckets. + pub fn nodes_by_distances( + &mut self, + log2_distances: &[u64], + max_nodes: usize, + ) -> ArrayVec { + // Apply pending promotions first, then collect. + for &d in log2_distances { + if d > 0 && d <= NUM_BUCKETS as u64 { + let bucket = &mut self.buckets[(d - 1) as usize]; + if let Some(applied) = bucket.apply_pending() { + self.applied_pending.push_back(applied); + } + } + } + + let mut out: ArrayVec = ArrayVec::new(); + for &d in log2_distances { + if d == 0 || d > NUM_BUCKETS as u64 { + continue; + } + for node in self.buckets[(d - 1) as usize].iter() { + out.push(*node.key.preimage()); + if out.len() >= max_nodes { + return out; + } + } + } + out + } + + /// Lookup by key. Does not trigger pending promotion. + pub fn get(&self, key: &Key) -> Option<&Node> { + let i = BucketIndex::new(&self.local_key.distance(key))?; + let pos = self.buckets[i.get()].position(key)?; + Some(&self.buckets[i.get()].nodes[pos]) + } + + /// Iterator over keys closest to `target`, ordered by increasing XOR + /// distance. + pub fn closest_keys(&mut self, target: &Key) -> impl Iterator + '_ { + let distance = self.local_key.distance(target); + ClosestIter { + target: *target, + iter: None, + table: self, + buckets_iter: ClosestBucketsIter::new(distance), + fmap: |b: &KBucket| -> ArrayVec<_, MAX_NODES_PER_BUCKET> { + b.iter().map(|n| n.key).collect() + }, + } + } +} + +#[derive(Copy, Clone)] +struct BucketIndex(usize); + +impl BucketIndex { + fn new(d: &Distance) -> Option { + (NUM_BUCKETS - d.0.leading_zeros() as usize).checked_sub(1).map(BucketIndex) + } + + fn get(self) -> usize { + self.0 + } +} + +struct ClosestIter<'a, T: Copy, TMap, TOut: Copy> { + target: Key, + table: &'a mut KBucketsTable, + buckets_iter: ClosestBucketsIter, + iter: Option< as IntoIterator>::IntoIter>, + fmap: TMap, +} + +struct ClosestBucketsIter { + distance: Distance, + state: ClosestBucketsIterState, +} + +enum ClosestBucketsIterState { + Start(BucketIndex), + ZoomIn(BucketIndex), + ZoomOut(BucketIndex), + Done, +} + +impl ClosestBucketsIter { + fn new(distance: Distance) -> Self { + let state = match BucketIndex::new(&distance) { + Some(i) => ClosestBucketsIterState::Start(i), + None => ClosestBucketsIterState::Start(BucketIndex(0)), + }; + Self { distance, state } + } + + fn next_in(&self, i: BucketIndex) -> Option { + (0..i.get()) + .rev() + .find_map(|i| if self.distance.0.bit(i) { Some(BucketIndex(i)) } else { None }) + } + + fn next_out(&self, i: BucketIndex) -> Option { + (i.get() + 1..NUM_BUCKETS) + .find_map(|i| if !self.distance.0.bit(i) { Some(BucketIndex(i)) } else { None }) + } +} + +impl Iterator for ClosestBucketsIter { + type Item = BucketIndex; + + fn next(&mut self) -> Option { + match self.state { + ClosestBucketsIterState::Start(i) => { + self.state = ClosestBucketsIterState::ZoomIn(i); + Some(i) + } + ClosestBucketsIterState::ZoomIn(i) => { + if let Some(i) = self.next_in(i) { + self.state = ClosestBucketsIterState::ZoomIn(i); + Some(i) + } else { + let i = BucketIndex(0); + self.state = ClosestBucketsIterState::ZoomOut(i); + Some(i) + } + } + ClosestBucketsIterState::ZoomOut(i) => { + if let Some(i) = self.next_out(i) { + self.state = ClosestBucketsIterState::ZoomOut(i); + Some(i) + } else { + self.state = ClosestBucketsIterState::Done; + None + } + } + ClosestBucketsIterState::Done => None, + } + } +} + +impl Iterator for ClosestIter<'_, T, TMap, TOut> +where + TMap: Fn(&KBucket) -> ArrayVec, + TOut: AsRef, +{ + type Item = TOut; + + fn next(&mut self) -> Option { + loop { + match &mut self.iter { + Some(iter) => match iter.next() { + Some(k) => return Some(k), + None => self.iter = None, + }, + None => { + if let Some(i) = self.buckets_iter.next() { + let bucket = &mut self.table.buckets[i.get()]; + if let Some(applied) = bucket.apply_pending() { + self.table.applied_pending.push_back(applied); + } + let mut v = (self.fmap)(bucket); + let target = self.target; + v.sort_by(|a, b| { + target.distance(a.as_ref()).cmp(&target.distance(b.as_ref())) + }); + self.iter = Some(v.into_iter()); + } else { + return None; + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use silver_common::NodeId; + + use super::*; + + #[test] + fn basic_closest() { + let local_key = Key::from(NodeId::random()); + let other_key = Key::from(NodeId::random()); + let mut table = KBucketsTable::<()>::new(local_key, Duration::from_secs(5)); + assert!(matches!( + table.insert_or_update(&other_key, (), Instant::now()), + InsertResult::Inserted + )); + let res: Vec<_> = table.closest_keys(&other_key).collect(); + assert_eq!(res.len(), 1); + assert_eq!(res[0], other_key); + } + + #[test] + fn insert_local_fails() { + let local_key = Key::from(NodeId::random()); + let mut table = KBucketsTable::<()>::new(local_key, Duration::from_secs(5)); + assert!(matches!( + table.insert_or_update(&local_key, (), Instant::now()), + InsertResult::Failed(FailureReason::SelfUpdate) + )); + } + + #[test] + fn closest_sorted() { + let local_key = Key::from(NodeId::random()); + let mut table = KBucketsTable::<()>::new(local_key, Duration::from_secs(5)); + let now = Instant::now(); + let mut count = 0; + while count < 100 { + let key = Key::from(NodeId::random()); + if matches!(table.insert_or_update(&key, (), now), InsertResult::Inserted) { + count += 1; + } + } + let mut expected: Vec = + table.buckets.iter().flat_map(|b| b.iter().map(|n| n.key)).collect(); + for _ in 0..10 { + let target = Key::from(NodeId::random()); + let keys: Vec<_> = table.closest_keys(&target).collect(); + expected.sort_by_key(|k| k.distance(&target)); + assert_eq!(keys, expected); + } + } + + #[test] + fn closest_local() { + let local_key = Key::from(NodeId::random()); + let mut table = KBucketsTable::<()>::new(local_key, Duration::from_secs(5)); + let now = Instant::now(); + let mut count = 0; + while count < 100 { + let key = Key::from(NodeId::random()); + if matches!(table.insert_or_update(&key, (), now), InsertResult::Inserted) { + count += 1; + } + } + assert_eq!(table.closest_keys(&local_key).count(), count); + } +} diff --git a/crates/discovery/src/lib.rs b/crates/discovery/src/lib.rs new file mode 100644 index 0000000..596d149 --- /dev/null +++ b/crates/discovery/src/lib.rs @@ -0,0 +1,11 @@ +mod config; +mod crypto; +mod discovery; +mod discv5; +mod kbucket; +mod message; +mod query_pool; + +pub use config::DiscoveryConfig; +pub use discovery::{Discovery, DiscoveryEvent, DiscoveryNetworking}; +pub use discv5::DiscV5; diff --git a/crates/discovery/src/message.rs b/crates/discovery/src/message.rs new file mode 100644 index 0000000..fb566c6 --- /dev/null +++ b/crates/discovery/src/message.rs @@ -0,0 +1,827 @@ +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +use aes::cipher::{KeyIvInit, StreamCipher}; +use alloy_rlp::{Decodable, Encodable, Header}; +use bytes::BufMut; +use flux::utils::ArrayVec; +use silver_common::NodeId; + +use crate::crypto::{MAX_PACKET_SIZE, SessionCipher, encrypt_message}; + +type Aes128Ctr = ctr::Ctr64BE; + +pub const IV_LENGTH: usize = 16; + +/// 6 (protocol_id) + 2 (version) + 1 (flag) + 12 (nonce) + 2 (authdata-size). +pub const STATIC_HEADER_LENGTH: usize = 23; +const MIN_PACKET_SIZE: usize = IV_LENGTH + STATIC_HEADER_LENGTH + 24; + +pub const ENR_RECORD_MAX: usize = 300; + +const PROTOCOL_ID: [u8; 6] = *b"discv5"; +const PROTOCOL_VERSION: [u8; 2] = [0x00, 0x01]; + +const FLAG_MSG: u8 = 0; +const FLAG_WHOAREYOU: u8 = 1; +const FLAG_HANDSHAKE: u8 = 2; + +const MAX_HEADER_SIZE: usize = STATIC_HEADER_LENGTH + 131 + ENR_RECORD_MAX; + +pub type Distances = ArrayVec; + +#[derive(Debug, Clone)] +#[allow(clippy::large_enum_variant)] +pub enum Message { + Ping { request_id: u64, enr_seq: u64 }, + Pong { request_id: u64, enr_seq: u64, ip: IpAddr, port: u16 }, + FindNode { request_id: u64, distances: Distances }, + Nodes { request_id: u64, total: u8, nodes: ArrayVec, 8> }, +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum PacketError { + #[error("packet exceeds max size ({MAX_PACKET_SIZE} bytes)")] + TooLarge, + #[error("packet below min size ({MIN_PACKET_SIZE} bytes)")] + TooSmall, + #[error("auth-data length mismatch for packet kind")] + InvalidAuthDataSize, + #[error("AES-CTR header decryption produced invalid protocol header")] + HeaderDecryptionFailed, + #[error("unknown packet flag")] + UnknownPacket, + #[error("invalid node ID in auth-data")] + InvalidNodeId, + #[error("unsupported protocol version {0:#06x}")] + InvalidVersion(u16), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(clippy::large_enum_variant)] +pub enum MessageKind { + Message, + WhoAreYou { + id_nonce: [u8; 16], + enr_seq: u64, + }, + Handshake { + id_nonce_sig: [u8; 64], + ephem_pubkey: [u8; 33], + enr_record: Option>, + }, +} + +pub struct Packet<'a> { + pub iv: u128, + pub src_id: NodeId, + pub nonce: [u8; 12], + pub kind: MessageKind, + pub message: &'a [u8], +} + +impl Message { + pub(crate) fn encode(&self, out: &mut dyn BufMut) { + match self { + Message::Ping { request_id, enr_seq } => { + out.put_u8(0x01); + Header { list: true, payload_length: request_id.length() + enr_seq.length() } + .encode(out); + request_id.encode(out); + enr_seq.encode(out); + } + Message::Pong { request_id, enr_seq, ip, port } => { + let ip4_bytes; + let ip6_bytes; + let ip_bytes: &[u8] = match ip { + IpAddr::V4(a) => { + ip4_bytes = a.octets(); + &ip4_bytes + } + IpAddr::V6(a) => { + ip6_bytes = a.octets(); + &ip6_bytes + } + }; + let payload_len = request_id.length() + + enr_seq.length() + + ip_bytes.length() + + (*port as u64).length(); + out.put_u8(0x02); + Header { list: true, payload_length: payload_len }.encode(out); + request_id.encode(out); + enr_seq.encode(out); + ip_bytes.encode(out); + (*port as u64).encode(out); + } + Message::FindNode { request_id, distances } => { + let dist_payload: usize = distances.iter().map(|d| d.length()).sum::(); + let dist_list_len = + Header { list: true, payload_length: dist_payload }.length() + dist_payload; + let payload_len = request_id.length() + dist_list_len; + out.put_u8(0x03); + Header { list: true, payload_length: payload_len }.encode(out); + request_id.encode(out); + Header { list: true, payload_length: dist_payload }.encode(out); + for &d in distances.iter() { + d.encode(out); + } + } + Message::Nodes { request_id, total, nodes } => { + let nodes_payload: usize = nodes.iter().map(|e| e.len()).sum(); + let nodes_list_len = + Header { list: true, payload_length: nodes_payload }.length() + nodes_payload; + let payload_len = request_id.length() + (*total as u64).length() + nodes_list_len; + + out.put_u8(0x04); + Header { list: true, payload_length: payload_len }.encode(out); + request_id.encode(out); + (*total as u64).encode(out); + Header { list: true, payload_length: nodes_payload }.encode(out); + for enr in nodes.iter() { + out.put_slice(enr.as_slice()); + } + } + } + } + + pub fn decode(data: &[u8]) -> Option { + let (&type_byte, rest) = data.split_first()?; + let mut buf = rest; + let h = Header::decode(&mut buf).ok()?; + if !h.list { + return None; + } + let mut payload = &buf[..h.payload_length]; + + match type_byte { + 0x01 => { + let request_id = u64::decode(&mut payload).ok()?; + let enr_seq = u64::decode(&mut payload).ok()?; + Some(Message::Ping { request_id, enr_seq }) + } + 0x02 => { + let request_id = u64::decode(&mut payload).ok()?; + let enr_seq = u64::decode(&mut payload).ok()?; + let ip_bytes = bytes::Bytes::decode(&mut payload).ok()?; + let ip = match ip_bytes.len() { + 4 => IpAddr::V4(Ipv4Addr::from(<[u8; 4]>::try_from(ip_bytes.as_ref()).ok()?)), + 16 => IpAddr::V6(Ipv6Addr::from(<[u8; 16]>::try_from(ip_bytes.as_ref()).ok()?)), + _ => return None, + }; + let port = u64::decode(&mut payload).ok()? as u16; + Some(Message::Pong { request_id, enr_seq, ip, port }) + } + 0x03 => { + let request_id = u64::decode(&mut payload).ok()?; + let dh = Header::decode(&mut payload).ok()?; + if !dh.list { + return None; + } + let mut dist_payload = &payload[..dh.payload_length]; + let mut distances = Distances::new(); + while !dist_payload.is_empty() && !distances.is_full() { + distances.push(u64::decode(&mut dist_payload).ok()?); + } + Some(Message::FindNode { request_id, distances }) + } + 0x04 => { + let request_id = u64::decode(&mut payload).ok()?; + let total = u64::decode(&mut payload).ok()? as u8; + let nh = Header::decode(&mut payload).ok()?; + if !nh.list { + return None; + } + let mut node_list = &payload[..nh.payload_length]; + let mut nodes: ArrayVec, 8> = ArrayVec::new(); + while !node_list.is_empty() && !nodes.is_full() { + // Capture full raw ENR bytes (list header + payload) for later decode. + let before = node_list; + let eh = Header::decode(&mut node_list).ok()?; + if !eh.list { + return None; + } + if node_list.len() < eh.payload_length { + return None; + } + let hdr_len = before.len() - node_list.len(); + let total_len = hdr_len + eh.payload_length; + if total_len > ENR_RECORD_MAX { + return None; + } + let mut av: ArrayVec = ArrayVec::new(); + av.extend(before[..total_len].iter().copied()); + nodes.push(av); + node_list = &node_list[eh.payload_length..]; + } + Some(Message::Nodes { request_id, total, nodes }) + } + _ => None, + } + } +} + +impl<'a> Packet<'a> { + /// Returns `iv || static_header || auth_data` (unmasked) — the AAD / + /// challenge data for AES-GCM decryption and HKDF session key derivation. + pub fn authenticated_data(&self) -> ArrayVec { + let header = self.build_header(); + let mut ad: ArrayVec = ArrayVec::new(); + ad.extend(self.iv.to_be_bytes().iter().copied()); + ad.extend(header.iter().copied()); + ad + } + + pub fn encode(&self, dst_id: &NodeId) -> ArrayVec { + let mut header = self.build_header(); + + let mut key = [0u8; 16]; + key.copy_from_slice(&dst_id.raw()[..16]); + let iv_arr: [u8; 16] = self.iv.to_be_bytes(); + let mut cipher = Aes128Ctr::new_from_slices(&key, &iv_arr).expect("key=16 iv=16"); + cipher.apply_keystream(header.as_mut_slice()); + + let mut out: ArrayVec = ArrayVec::new(); + out.extend(self.iv.to_be_bytes().iter().copied()); + out.extend(header.iter().copied()); + out.extend(self.message.iter().copied()); + out + } + + /// Encrypt `msg` with `cipher` and encode as a complete wire packet. + /// Returns `None` only if AES-GCM encryption fails (should not happen with + /// valid keys). + pub fn encode_message( + src_id: NodeId, + dst_id: NodeId, + cipher: &SessionCipher, + msg: Message, + ) -> Option> { + let rng_buf: [u8; 28] = rand::random(); + let nonce: [u8; 12] = rng_buf[..12].try_into().unwrap(); + let iv: u128 = u128::from_be_bytes(rng_buf[12..].try_into().unwrap()); + + // max plaintext = MAX_PACKET_SIZE - IV(16) - header(23) - auth_data(32) - GCM + // tag(16) + let mut plain: ArrayVec = ArrayVec::new(); + msg.encode(&mut plain); + let tmp = Packet { iv, src_id, nonce, kind: MessageKind::Message, message: &[] }; + let aad = tmp.authenticated_data(); + let ciphertext = encrypt_message(cipher, &nonce, &aad, &plain)?; + Some( + Packet { iv, src_id, nonce, kind: MessageKind::Message, message: &ciphertext } + .encode(&dst_id), + ) + } + + /// Decode a wire packet, unmasking the header with AES-128-CTR keyed on + /// `dst_id` (the local node, i.e., masking-key = dest-id[:16]). + /// + /// Returns `(packet, authenticated_data)` where `authenticated_data` is + /// the AAD for AES-GCM and the HKDF challenge_data input. + pub fn decode( + dst_id: &NodeId, + data: &'a [u8], + ) -> Result<(Self, ArrayVec), PacketError> { + if data.len() > MAX_PACKET_SIZE { + return Err(PacketError::TooLarge); + } + if data.len() < MIN_PACKET_SIZE { + return Err(PacketError::TooSmall); + } + + let iv_bytes = &data[..IV_LENGTH]; + let mut key = [0u8; 16]; + key.copy_from_slice(&dst_id.raw()[..16]); + let mut iv_arr = [0u8; IV_LENGTH]; + iv_arr.copy_from_slice(iv_bytes); + let mut cipher = Aes128Ctr::new_from_slices(&key, &iv_arr).expect("key=16 iv=16"); + + let mut static_header: [u8; STATIC_HEADER_LENGTH] = + data[IV_LENGTH..IV_LENGTH + STATIC_HEADER_LENGTH].try_into().unwrap(); + cipher.apply_keystream(&mut static_header); + + if static_header[..6] != PROTOCOL_ID { + return Err(PacketError::HeaderDecryptionFailed); + } + if static_header[6..8] != PROTOCOL_VERSION { + let v = u16::from_be_bytes([static_header[6], static_header[7]]); + return Err(PacketError::InvalidVersion(v)); + } + + let flag = static_header[8]; + let nonce: [u8; 12] = static_header[9..21].try_into().unwrap(); + let auth_data_size = u16::from_be_bytes([static_header[21], static_header[22]]) as usize; + + if auth_data_size > data.len() - (IV_LENGTH + STATIC_HEADER_LENGTH) { + return Err(PacketError::InvalidAuthDataSize); + } + + let ad_start = IV_LENGTH + STATIC_HEADER_LENGTH; + let mut auth_data: ArrayVec = ArrayVec::new(); + auth_data.extend(data[ad_start..ad_start + auth_data_size].iter().copied()); + cipher.apply_keystream(auth_data.as_mut_slice()); + + let msg_start = ad_start + auth_data_size; + let message = &data[msg_start..]; + + let mut authenticated_data: ArrayVec = ArrayVec::new(); + authenticated_data.extend(iv_bytes.iter().copied()); + authenticated_data.extend(static_header.iter().copied()); + authenticated_data.extend(auth_data.iter().copied()); + + let iv = u128::from_be_bytes(iv_bytes.try_into().unwrap()); + + let auth_data = auth_data.as_slice(); + let (src_id, kind) = match flag { + FLAG_MSG => { + if auth_data.len() != 32 { + return Err(PacketError::InvalidAuthDataSize); + } + let id = NodeId::new(auth_data.try_into().map_err(|_| PacketError::InvalidNodeId)?); + (id, MessageKind::Message) + } + FLAG_WHOAREYOU => { + if auth_data.len() != 24 { + return Err(PacketError::InvalidAuthDataSize); + } + if !message.is_empty() { + return Err(PacketError::UnknownPacket); + } + let id_nonce: [u8; 16] = auth_data[..16].try_into().unwrap(); + let enr_seq = u64::from_be_bytes(auth_data[16..24].try_into().unwrap()); + (NodeId::new(&[0; 32]), MessageKind::WhoAreYou { id_nonce, enr_seq }) + } + FLAG_HANDSHAKE => { + // 32 src_id + 1 sig_size + 1 key_size + 64 sig + 33 pubkey = 131 + if auth_data.len() < 131 { + return Err(PacketError::InvalidAuthDataSize); + } + let id = NodeId::new( + &auth_data[..32].try_into().map_err(|_| PacketError::InvalidNodeId)?, + ); + let id_nonce_sig: [u8; 64] = + auth_data[34..98].try_into().map_err(|_| PacketError::InvalidAuthDataSize)?; + let ephem_pubkey: [u8; 33] = + auth_data[98..131].try_into().map_err(|_| PacketError::InvalidAuthDataSize)?; + let enr_record = if auth_data.len() > 131 { + let tail = &auth_data[131..]; + if tail.len() > ENR_RECORD_MAX { + return Err(PacketError::InvalidAuthDataSize); + } + let mut av: ArrayVec = ArrayVec::new(); + av.extend(tail.iter().copied()); + Some(av) + } else { + None + }; + (id, MessageKind::Handshake { id_nonce_sig, ephem_pubkey, enr_record }) + } + _ => return Err(PacketError::UnknownPacket), + }; + + Ok((Packet { iv, src_id, nonce, kind, message }, authenticated_data)) + } + + fn build_header(&self) -> ArrayVec { + let mut auth_data: ArrayVec = ArrayVec::new(); + let flag = match &self.kind { + MessageKind::Message => { + auth_data.extend(self.src_id.raw().iter().copied()); + FLAG_MSG + } + MessageKind::WhoAreYou { id_nonce, enr_seq } => { + auth_data.extend(id_nonce.iter().copied()); + auth_data.extend(enr_seq.to_be_bytes().iter().copied()); + FLAG_WHOAREYOU + } + MessageKind::Handshake { id_nonce_sig, ephem_pubkey, enr_record } => { + auth_data.extend(self.src_id.raw().iter().copied()); + auth_data.push(64u8); + auth_data.push(33u8); + auth_data.extend(id_nonce_sig.iter().copied()); + auth_data.extend(ephem_pubkey.iter().copied()); + if let Some(enr) = enr_record { + auth_data.extend(enr.iter().copied()); + } + FLAG_HANDSHAKE + } + }; + + let mut h: ArrayVec = ArrayVec::new(); + h.extend(PROTOCOL_ID.iter().copied()); + h.extend(PROTOCOL_VERSION.iter().copied()); + h.push(flag); + h.extend(self.nonce.iter().copied()); + h.extend((auth_data.len() as u16).to_be_bytes().iter().copied()); + h.extend(auth_data.iter().copied()); + h + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hex_decode(s: &str) -> Vec { + hex::decode(s).unwrap() + } + + fn node_id_1() -> NodeId { + let sk = k256::ecdsa::SigningKey::from_slice(&hex_decode( + "eef77acb6c6a6eebc5b363a475ac583ec7eccdb42b6481424c60f59aa326547f", + )) + .unwrap(); + NodeId::from(*sk.verifying_key()) + } + + fn node_id_2() -> NodeId { + let sk = k256::ecdsa::SigningKey::from_slice(&hex_decode( + "66fb62bfbd66b9177a138c1e5cddbe4f7c30c343e94e68df8769459cb1cde628", + )) + .unwrap(); + NodeId::from(*sk.verifying_key()) + } + + #[test] + fn packet_encode_random() { + let src_id = node_id_1(); + let dst_id = node_id_2(); + let iv = 11u128; + let nonce = [12u8; 12]; + let message = [1u8; 12]; + + let expected = hex_decode( + "0000000000000000000000000000000b4f3ab1857252f96f758330a846b5d3d4a954d738dfcd6d1ed118ecc1d54f9b20fbf2be28db87805b23193e03c455d73d63ac71dfa91ffa010101010101010101010101", + ); + + let encoded = Packet { iv, src_id, nonce, kind: MessageKind::Message, message: &message } + .encode(&dst_id); + assert_eq!(encoded.as_slice(), expected.as_slice()); + } + + #[test] + fn packet_ref_test_encode_whoareyou() { + let dst_id = node_id_2(); + let nonce: [u8; 12] = hex_decode("0102030405060708090a0b0c").try_into().unwrap(); + let id_nonce: [u8; 16] = hex_decode("0102030405060708090a0b0c0d0e0f10").try_into().unwrap(); + let enr_seq = 0u64; + let iv = 0u128; + + let expected = hex_decode( + "00000000000000000000000000000000088b3d434277464933a1ccc59f5967ad1d6035f15e528627dde75cd68292f9e6c27d6b66c8100a873fcbaed4e16b8d", + ); + + let encoded = Packet { + iv, + src_id: NodeId::new(&[0; 32]), + nonce, + kind: MessageKind::WhoAreYou { id_nonce, enr_seq }, + message: &[], + } + .encode(&dst_id); + assert_eq!(encoded.as_slice(), expected.as_slice()); + } + + #[test] + fn packet_encode_handshake() { + let src_id = NodeId::new(&[3; 32]); + let dst_id = NodeId::new(&[4; 32]); + let nonce = [52u8; 12]; + let id_nonce_sig = [5u8; 64]; + let ephem_pubkey = [6u8; 33]; + let iv = 0u128; + + let expected = hex_decode( + "0000000000000000000000000000000035a14bcdb844ae25f36070f07e0b25e765ed72b4d69c99d5fe5a8d438a4b5b518dfead9d80200875c23e31d0acda6f1b2a6124a70e3dc1f2b8b0770f24d8da18605ff3f5b60b090c61515093a88ef4c02186f7d1b5c9a88fdb8cfae239f13e451758751561b439d8044e27cecdf646f2aa1c9ecbd5faf37eb67a4f6337f4b2a885391e631f72deb808c63bf0b0faed23d7117f7a2e1f98c28bd0", + ); + + let encoded = Packet { + iv, + src_id, + nonce, + kind: MessageKind::Handshake { id_nonce_sig, ephem_pubkey, enr_record: None }, + message: &[], + } + .encode(&dst_id); + assert_eq!(encoded.as_slice(), expected.as_slice()); + } + + #[test] + fn packet_encode_handshake_enr() { + let src_id = node_id_1(); + let dst_id = NodeId::new(&[4; 32]); + let nonce = [52u8; 12]; + let id_nonce_sig = [5u8; 64]; + let ephem_pubkey = [6u8; 33]; + let iv = 0u128; + + let enr: silver_common::Enr = "enr:-IS4QHXuNmr1vGEGVGDcy_sG2BZ7a3A7mbKS812BK_9rToQiF1Lfknsi5o0xKLnGJbTzBssJCzMcIj8SOiu1O9dnfZEBgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQMT0UIR4Ch7I2GhYViQqbUhIIBUbQoleuTP-Wz1NJksuYN0Y3CCIyg".parse().unwrap(); + let enr_bytes = alloy_rlp::encode(&enr); + let mut enr_record: ArrayVec = ArrayVec::new(); + enr_record.extend(enr_bytes.iter().copied()); + + let expected = hex_decode( + "0000000000000000000000000000000035a14bcdb844ae25f36070f07e0b25e765ed72b4d69d187c57dd97a97dd558d1d8e6e6b6fed699e55bb02b47d25562e0a6486ff2aba179f2b8b0770f24d8da18605ff3f5b60b090c61515093a88ef4c02186f7d1b5c9a88fdb8cfae239f13e451758751561b439d8044e27cecdf646f2aa1c9ecbd5faf37eb67a4f6337f4b2a885391e631f72deb808c63bf0b0faed23d7117f7a2e1f98c28bd0e9f1ce8b51cc89e592ed2efa671b8efd49e1ce8fd567fdb06ed308267d31f6bd75827812d21e8aa5a6c025e69b67faea57a15c1c9324d16938c4ebe71dba0bd5d7b00bb6de3e846ed37ef13a9d2e271f25233f5d97bbb026223dbe6595210f6a11cbee54589a0c0c20c7bb7c4c5bea46553480e1b7d4e83b2dd8305aac3b15", + ); + + let encoded = Packet { + iv, + src_id, + nonce, + kind: MessageKind::Handshake { + id_nonce_sig, + ephem_pubkey, + enr_record: Some(enr_record), + }, + message: &[], + } + .encode(&dst_id); + assert_eq!(encoded.as_slice(), expected.as_slice()); + } + + #[test] + fn packet_ref_test_encode_message() { + let src_id = node_id_1(); + let dst_id = node_id_2(); + let iv = 0u128; + let nonce = [52u8; 12]; + let ciphertext = [23u8; 12]; + + let expected = hex_decode( + "00000000000000000000000000000000088b3d43427746493294faf2af68559e215d0bce6652be8c7560413a7008f16c9e6d2f43bbea8814a546b7409ce783d34c4f53245d08da171717171717171717171717", + ); + + let encoded = + Packet { iv, src_id, nonce, kind: MessageKind::Message, message: &ciphertext } + .encode(&dst_id); + assert_eq!(encoded.as_slice(), expected.as_slice()); + } + + #[test] + fn packet_encode_decode_random() { + let src_id = node_id_1(); + let dst_id = node_id_2(); + let iv: u128 = rand::random(); + let nonce: [u8; 12] = rand::random(); + let message: Vec = (0..44).map(|_| rand::random::()).collect(); + + let encoded = Packet { iv, src_id, nonce, kind: MessageKind::Message, message: &message } + .encode(&dst_id); + let (decoded, _) = Packet::decode(&dst_id, &encoded).unwrap(); + + assert_eq!(decoded.iv, iv); + assert_eq!(decoded.src_id, src_id); + assert_eq!(decoded.nonce, nonce); + assert_eq!(decoded.kind, MessageKind::Message); + assert_eq!(decoded.message, message.as_slice()); + } + + #[test] + fn packet_encode_decode_whoareyou() { + let dst_id = node_id_2(); + let nonce: [u8; 12] = rand::random(); + let id_nonce: [u8; 16] = rand::random(); + let enr_seq: u64 = rand::random(); + let iv: u128 = rand::random(); + + let encoded = Packet { + iv, + src_id: NodeId::new(&[0; 32]), + nonce, + kind: MessageKind::WhoAreYou { id_nonce, enr_seq }, + message: &[], + } + .encode(&dst_id); + let (decoded, _) = Packet::decode(&dst_id, &encoded).unwrap(); + + assert_eq!(decoded.iv, iv); + assert_eq!(decoded.nonce, nonce); + assert_eq!(decoded.kind, MessageKind::WhoAreYou { id_nonce, enr_seq }); + assert!(decoded.message.is_empty()); + } + + #[test] + fn encode_decode_auth_packet() { + let src_id = node_id_1(); + let dst_id = node_id_2(); + let nonce: [u8; 12] = rand::random(); + let id_nonce_sig = [13u8; 64]; + let ephem_pubkey = [11u8; 33]; + let iv: u128 = rand::random(); + + let encoded = Packet { + iv, + src_id, + nonce, + kind: MessageKind::Handshake { id_nonce_sig, ephem_pubkey, enr_record: None }, + message: &[], + } + .encode(&dst_id); + let (decoded, _) = Packet::decode(&dst_id, &encoded).unwrap(); + + assert_eq!(decoded.iv, iv); + assert_eq!(decoded.src_id, src_id); + assert_eq!(decoded.nonce, nonce); + assert_eq!(decoded.kind, MessageKind::Handshake { + id_nonce_sig, + ephem_pubkey, + enr_record: None + }); + assert!(decoded.message.is_empty()); + } + + #[test] + fn packet_decode_ref_ping() { + let src_id = node_id_1(); + let dst_id = node_id_2(); + let nonce: [u8; 12] = hex_decode("ffffffffffffffffffffffff").try_into().unwrap(); + let ciphertext = hex_decode("b84102ed931f66d1492acb308fa1c6715b9d139b81acbdcc"); + + let encoded = hex_decode( + "00000000000000000000000000000000088b3d4342774649325f313964a39e55ea96c005ad52be8c7560413a7008f16c9e6d2f43bbea8814a546b7409ce783d34c4f53245d08dab84102ed931f66d1492acb308fa1c6715b9d139b81acbdcc", + ); + + let (decoded, _) = Packet::decode(&dst_id, &encoded).unwrap(); + assert_eq!(decoded.iv, 0u128); + assert_eq!(decoded.nonce, nonce); + assert_eq!(decoded.kind, MessageKind::Message); + assert_eq!(decoded.src_id, src_id); + assert_eq!(decoded.message, ciphertext.as_slice()); + } + + #[test] + fn packet_decode_ref_ping_handshake() { + let src_id = node_id_1(); + let dst_id = node_id_2(); + let nonce: [u8; 12] = hex_decode("ffffffffffffffffffffffff").try_into().unwrap(); + let id_nonce_sig: [u8; 64] = hex_decode("c0a04b36f276172afc66a62848eb0769800c670c4edbefab8f26785e7fda6b56506a3f27ca72a75b106edd392a2cbf8a69272f5c1785c36d1de9d98a0894b2db").try_into().unwrap(); + let ephem_pubkey: [u8; 33] = + hex_decode("039a003ba6517b473fa0cd74aefe99dadfdb34627f90fec6362df85803908f53a5") + .try_into() + .unwrap(); + let message = hex_decode("f1eadf5f0f4126b79336671cbcf7a885b1f8bd2a5d839cf8"); + + let encoded = hex_decode( + "00000000000000000000000000000000088b3d4342774649305f313964a39e55ea96c005ad521d8c7560413a7008f16c9e6d2f43bbea8814a546b7409ce783d34c4f53245d08da4bb252012b2cba3f4f374a90a75cff91f142fa9be3e0a5f3ef268ccb9065aeecfd67a999e7fdc137e062b2ec4a0eb92947f0d9a74bfbf44dfba776b21301f8b65efd5796706adff216ab862a9186875f9494150c4ae06fa4d1f0396c93f215fa4ef524f1eadf5f0f4126b79336671cbcf7a885b1f8bd2a5d839cf8", + ); + + let (decoded, _) = Packet::decode(&dst_id, &encoded).unwrap(); + assert_eq!(decoded.iv, 0u128); + assert_eq!(decoded.nonce, nonce); + assert_eq!(decoded.src_id, src_id); + assert_eq!(decoded.kind, MessageKind::Handshake { + id_nonce_sig, + ephem_pubkey, + enr_record: None + }); + assert_eq!(decoded.message, message.as_slice()); + } + + #[test] + fn packet_decode_ref_ping_handshake_enr() { + let src_id = node_id_1(); + let dst_id = node_id_2(); + let nonce: [u8; 12] = hex_decode("ffffffffffffffffffffffff").try_into().unwrap(); + let id_nonce_sig: [u8; 64] = hex_decode("a439e69918e3f53f555d8ca4838fbe8abeab56aa55b056a2ac4d49c157ee719240a93f56c9fccfe7742722a92b3f2dfa27a5452f5aca8adeeab8c4d5d87df555").try_into().unwrap(); + let ephem_pubkey: [u8; 33] = + hex_decode("039a003ba6517b473fa0cd74aefe99dadfdb34627f90fec6362df85803908f53a5") + .try_into() + .unwrap(); + let enr: silver_common::Enr = "enr:-H24QBfhsHORjaMtZAZCx2LA4ngWmOSXH4qzmnd0atrYPwHnb_yHTFkkgIu-fFCJCILCuKASh6CwgxLR1ToX1Rf16ycBgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQMT0UIR4Ch7I2GhYViQqbUhIIBUbQoleuTP-Wz1NJksuQ".parse().unwrap(); + let enr_bytes = alloy_rlp::encode(&enr); + let mut enr_record_av: ArrayVec = ArrayVec::new(); + enr_record_av.extend(enr_bytes.iter().copied()); + let message = hex_decode("08d65093ccab5aa596a34d7511401987662d8cf62b139471"); + + let encoded = hex_decode( + "00000000000000000000000000000000088b3d4342774649305f313964a39e55ea96c005ad539c8c7560413a7008f16c9e6d2f43bbea8814a546b7409ce783d34c4f53245d08da4bb23698868350aaad22e3ab8dd034f548a1c43cd246be98562fafa0a1fa86d8e7a3b95ae78cc2b988ded6a5b59eb83ad58097252188b902b21481e30e5e285f19735796706adff216ab862a9186875f9494150c4ae06fa4d1f0396c93f215fa4ef524e0ed04c3c21e39b1868e1ca8105e585ec17315e755e6cfc4dd6cb7fd8e1a1f55e49b4b5eb024221482105346f3c82b15fdaae36a3bb12a494683b4a3c7f2ae41306252fed84785e2bbff3b022812d0882f06978df84a80d443972213342d04b9048fc3b1d5fcb1df0f822152eced6da4d3f6df27e70e4539717307a0208cd208d65093ccab5aa596a34d7511401987662d8cf62b139471", + ); + + let (decoded, _) = Packet::decode(&dst_id, &encoded).unwrap(); + assert_eq!(decoded.iv, 0u128); + assert_eq!(decoded.nonce, nonce); + assert_eq!(decoded.src_id, src_id); + assert_eq!(decoded.kind, MessageKind::Handshake { + id_nonce_sig, + ephem_pubkey, + enr_record: Some(enr_record_av) + }); + assert_eq!(decoded.message, message.as_slice()); + } + + #[test] + fn packet_decode_invalid_packet_size() { + let src_id = node_id_1(); + + let data = [0u8; MAX_PACKET_SIZE + 1]; + assert!(matches!(Packet::decode(&src_id, &data), Err(PacketError::TooLarge))); + + let data = [0u8; MIN_PACKET_SIZE - 1]; + assert!(matches!(Packet::decode(&src_id, &data), Err(PacketError::TooSmall))); + } + + #[test] + fn test_ping_encode_decode() { + let msg = Message::Ping { request_id: 42, enr_seq: 7 }; + let mut buf = Vec::new(); + msg.encode(&mut buf); + match Message::decode(&buf).unwrap() { + Message::Ping { request_id, enr_seq } => { + assert_eq!(request_id, 42); + assert_eq!(enr_seq, 7); + } + _ => panic!("wrong variant"), + } + } + + #[test] + fn test_pong_ipv4_encode_decode() { + use std::net::{IpAddr, Ipv4Addr}; + let msg = Message::Pong { + request_id: 1, + enr_seq: 3, + ip: IpAddr::V4(Ipv4Addr::LOCALHOST), + port: 9000, + }; + let mut buf = Vec::new(); + msg.encode(&mut buf); + match Message::decode(&buf).unwrap() { + Message::Pong { request_id, enr_seq, ip, port } => { + assert_eq!(request_id, 1); + assert_eq!(enr_seq, 3); + assert_eq!(ip, IpAddr::V4(Ipv4Addr::LOCALHOST)); + assert_eq!(port, 9000); + } + _ => panic!("wrong variant"), + } + } + + #[test] + fn test_pong_ipv6_encode_decode() { + use std::net::{IpAddr, Ipv6Addr}; + let msg = Message::Pong { + request_id: 2, + enr_seq: 0, + ip: IpAddr::V6(Ipv6Addr::LOCALHOST), + port: 30303, + }; + let mut buf = Vec::new(); + msg.encode(&mut buf); + match Message::decode(&buf).unwrap() { + Message::Pong { request_id, enr_seq, ip, port } => { + assert_eq!(request_id, 2); + assert_eq!(enr_seq, 0); + assert_eq!(ip, IpAddr::V6(Ipv6Addr::LOCALHOST)); + assert_eq!(port, 30303); + } + _ => panic!("wrong variant"), + } + } + + #[test] + fn test_findnode_encode_decode() { + let mut distances = Distances::new(); + distances.push(256); + distances.push(255); + distances.push(1); + let msg = Message::FindNode { request_id: 99, distances }; + let mut buf = Vec::new(); + msg.encode(&mut buf); + match Message::decode(&buf).unwrap() { + Message::FindNode { request_id, distances } => { + assert_eq!(request_id, 99); + assert_eq!(distances.as_slice(), &[256u64, 255, 1]); + } + _ => panic!("wrong variant"), + } + } + + #[test] + fn test_nodes_encode_decode_enr_bytes() { + let key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); + let enr = silver_common::Enr::builder() + .ip4(std::net::Ipv4Addr::LOCALHOST) + .udp4(9000u16) + .build(&key) + .unwrap(); + let enr_bytes = alloy_rlp::encode(&enr); + let mut enr_raw: ArrayVec = ArrayVec::new(); + enr_raw.extend(enr_bytes.iter().copied()); + + let mut nodes: ArrayVec, 8> = ArrayVec::new(); + nodes.push(enr_raw.clone()); + + let msg = Message::Nodes { request_id: 5, total: 1, nodes }; + let mut buf = Vec::new(); + msg.encode(&mut buf); + match Message::decode(&buf).unwrap() { + Message::Nodes { request_id, total, nodes } => { + assert_eq!(request_id, 5); + assert_eq!(total, 1); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].as_slice(), enr_raw.as_slice()); + } + _ => panic!("wrong variant"), + } + } +} diff --git a/crates/discovery/src/query_pool.rs b/crates/discovery/src/query_pool.rs new file mode 100644 index 0000000..5c0623d --- /dev/null +++ b/crates/discovery/src/query_pool.rs @@ -0,0 +1,409 @@ +// Copyright 2019 Parity Technologies (UK) Ltd. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +// This basis of this file has been taken from the rust-libp2p codebase: +// https://github.com/libp2p/rust-libp2p + +use std::{ + collections::btree_map::{BTreeMap, Entry}, + time::{Duration, Instant}, +}; + +use rustc_hash::FxHashMap; +use silver_common::NodeId; + +use crate::{ + config::DiscoveryConfig, + kbucket::{Distance, Key, MAX_NODES_PER_BUCKET}, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum QueryState { + /// Waiting; `Some(peer)` is the next peer to contact. + Waiting(Option), + WaitingAtCapacity, + Finished, +} + +pub struct FindNodeQuery { + target_key: Key, + progress: QueryProgress, + /// Closest peers seen so far, ordered by XOR distance to target. + closest_peers: BTreeMap, + num_waiting: usize, + config: FindNodeQueryConfig, +} + +#[derive(Debug, Clone)] +pub struct FindNodeQueryConfig { + /// α: max parallel in-flight requests. + pub parallelism: usize, + /// k: number of successful results needed to terminate. + pub num_results: usize, + /// Per-peer timeout before marking unresponsive. + pub peer_timeout: Duration, +} + +impl FindNodeQueryConfig { + pub fn new_from_config(config: &DiscoveryConfig) -> Self { + Self { + parallelism: config.query_parallelism, + num_results: MAX_NODES_PER_BUCKET, + peer_timeout: config.query_peer_timeout(), + } + } +} + +impl FindNodeQuery { + pub fn with_config( + config: FindNodeQueryConfig, + target_key: Key, + known_closest_peers: impl IntoIterator, + ) -> Self { + let closest_peers = known_closest_peers + .into_iter() + .map(|key| { + let distance = key.distance(&target_key); + (distance, QueryPeer::new(key, QueryPeerState::NotContacted)) + }) + .take(config.num_results) + .collect(); + FindNodeQuery { + config, + target_key, + progress: QueryProgress::Iterating { no_progress: 0 }, + closest_peers, + num_waiting: 0, + } + } + + pub fn on_success(&mut self, node_id: &NodeId, closer_peers: impl IntoIterator) { + if let QueryProgress::Finished = self.progress { + return; + } + + let key = Key::from(*node_id); + let distance = key.distance(&self.target_key); + + match self.closest_peers.entry(distance) { + Entry::Vacant(..) => return, + Entry::Occupied(mut e) => match e.get().state { + QueryPeerState::Waiting(..) => { + debug_assert!(self.num_waiting > 0); + self.num_waiting -= 1; + e.get_mut().state = QueryPeerState::Succeeded; + } + QueryPeerState::Unresponsive => { + e.get_mut().state = QueryPeerState::Succeeded; + } + QueryPeerState::NotContacted | + QueryPeerState::Failed | + QueryPeerState::Succeeded => return, + }, + } + + let mut progress = false; + let num_closest = self.closest_peers.len(); + + for peer_id in closer_peers { + let key = Key::from(peer_id); + let distance = self.target_key.distance(&key); + let peer = QueryPeer::new(key, QueryPeerState::NotContacted); + self.closest_peers.entry(distance).or_insert(peer); + progress |= self.closest_peers.keys().next() == Some(&distance) || + num_closest < self.config.num_results; + } + + // Bound the map: evict furthest NotContacted entries beyond 3 * num_results. + // In-flight / completed entries are never evicted. + // todo @nina: necessary? + let cap = self.config.num_results * 3; + while self.closest_peers.len() > cap { + let last = match self.closest_peers.keys().next_back() { + Some(&d) => d, + None => break, + }; + if matches!(self.closest_peers[&last].state, QueryPeerState::NotContacted) { + self.closest_peers.remove(&last); + } else { + break; + } + } + + self.progress = match self.progress { + QueryProgress::Iterating { no_progress } => { + let no_progress = if progress { 0 } else { no_progress + 1 }; + if no_progress >= self.config.parallelism { + QueryProgress::Stalled + } else { + QueryProgress::Iterating { no_progress } + } + } + QueryProgress::Stalled => { + if progress { + QueryProgress::Iterating { no_progress: 0 } + } else { + QueryProgress::Stalled + } + } + QueryProgress::Finished => QueryProgress::Finished, + }; + } + + pub fn on_failure(&mut self, peer: &NodeId) { + if let QueryProgress::Finished = self.progress { + return; + } + + let key = Key::from(*peer); + let distance = key.distance(&self.target_key); + + match self.closest_peers.entry(distance) { + Entry::Vacant(_) => {} + Entry::Occupied(mut e) => match e.get().state { + QueryPeerState::Waiting(..) => { + debug_assert!(self.num_waiting > 0); + self.num_waiting -= 1; + e.get_mut().state = QueryPeerState::Failed; + } + QueryPeerState::Unresponsive => e.get_mut().state = QueryPeerState::Failed, + _ => {} + }, + } + } + + pub fn next(&mut self, now: Instant) -> QueryState { + if let QueryProgress::Finished = self.progress { + return QueryState::Finished; + } + + let mut result_counter = Some(0); + let at_capacity = self.at_capacity(); + + for peer in self.closest_peers.values_mut() { + match peer.state { + QueryPeerState::NotContacted => { + if !at_capacity { + let timeout = now + self.config.peer_timeout; + peer.state = QueryPeerState::Waiting(timeout); + self.num_waiting += 1; + return QueryState::Waiting(Some(*peer.key.preimage())); + } else { + return QueryState::WaitingAtCapacity; + } + } + QueryPeerState::Waiting(timeout) => { + if now >= timeout { + debug_assert!(self.num_waiting > 0); + self.num_waiting -= 1; + peer.state = QueryPeerState::Unresponsive; + } else if at_capacity { + return QueryState::WaitingAtCapacity; + } else { + result_counter = None; + } + } + QueryPeerState::Succeeded => { + if let Some(ref mut cnt) = result_counter { + *cnt += 1; + if *cnt >= self.config.num_results { + self.progress = QueryProgress::Finished; + return QueryState::Finished; + } + } + } + QueryPeerState::Failed | QueryPeerState::Unresponsive => {} + } + } + + if self.num_waiting > 0 { + QueryState::Waiting(None) + } else { + self.progress = QueryProgress::Finished; + QueryState::Finished + } + } + + pub fn into_result(self) -> impl Iterator { + let num_results = self.config.num_results; + self.closest_peers + .into_values() + .filter(|p| matches!(p.state, QueryPeerState::Succeeded)) + .take(num_results) + .map(|p| *p.key.preimage()) + } + + pub fn is_waiting_for(&self, peer: &NodeId) -> bool { + let key = Key::from(*peer); + let distance = key.distance(&self.target_key); + matches!( + self.closest_peers.get(&distance).map(|p| &p.state), + Some(QueryPeerState::Waiting(_) | QueryPeerState::Unresponsive) + ) + } + + fn at_capacity(&self) -> bool { + match self.progress { + QueryProgress::Stalled => self.num_waiting >= self.config.num_results, + QueryProgress::Iterating { .. } => self.num_waiting >= self.config.parallelism, + QueryProgress::Finished => true, + } + } +} + +#[derive(Debug, PartialEq, Eq, Copy, Clone)] +enum QueryProgress { + Iterating { no_progress: usize }, + Stalled, + Finished, +} + +#[derive(Debug, Clone)] +struct QueryPeer { + key: Key, + state: QueryPeerState, +} + +impl QueryPeer { + fn new(key: Key, state: QueryPeerState) -> Self { + QueryPeer { key, state } + } +} + +#[derive(Debug, Copy, Clone)] +enum QueryPeerState { + NotContacted, + Waiting(Instant), + Unresponsive, + Failed, + Succeeded, +} + +/// Pool of active iterative FINDNODE queries. +pub struct QueryPool { + next_id: usize, + query_timeout: Duration, + queries: FxHashMap, +} + +pub enum QueryPoolState<'a> { + Idle, + /// `Some` carries the next peer to contact. + Waiting(Option<(&'a mut Query, NodeId)>), + Finished(Query), + #[allow(dead_code)] + Timeout(Query), +} + +impl QueryPool { + pub fn new(query_timeout: Duration) -> Self { + QueryPool { next_id: 0, query_timeout, queries: Default::default() } + } + + pub fn add_findnode_query( + &mut self, + config: FindNodeQueryConfig, + target_key: Key, + peers: impl IntoIterator, + ) -> QueryId { + let inner = FindNodeQuery::with_config(config, target_key, peers); + let id = QueryId(self.next_id); + self.next_id = self.next_id.wrapping_add(1); + self.queries.insert(id, Query { id, inner, started: None }); + id + } + + pub fn get_mut(&mut self, id: QueryId) -> Option<&mut Query> { + self.queries.get_mut(&id) + } + + /// Find the query that sent a request to `peer` and is awaiting a response. + pub fn find_query_for_peer(&mut self, peer: &NodeId) -> Option<&mut Query> { + self.queries.values_mut().find(|q| q.inner.is_waiting_for(peer)) + } + + pub fn poll(&mut self) -> QueryPoolState<'_> { + let now = Instant::now(); + let mut waiting = None; + let mut finished = None; + let mut timeout = None; + + for (&id, query) in self.queries.iter_mut() { + query.started = query.started.or(Some(now)); + match query.inner.next(now) { + QueryState::Finished => { + finished = Some(id); + break; + } + QueryState::Waiting(Some(peer)) => { + waiting = Some((id, peer)); + break; + } + QueryState::Waiting(None) | QueryState::WaitingAtCapacity => { + let elapsed = now - query.started.unwrap_or(now); + if elapsed >= self.query_timeout { + timeout = Some(id); + break; + } + } + } + } + + if let Some((id, peer)) = waiting { + return QueryPoolState::Waiting(Some((self.queries.get_mut(&id).unwrap(), peer))); + } + if let Some(id) = finished { + return QueryPoolState::Finished(self.queries.remove(&id).unwrap()); + } + if let Some(id) = timeout { + return QueryPoolState::Timeout(self.queries.remove(&id).unwrap()); + } + if self.queries.is_empty() { QueryPoolState::Idle } else { QueryPoolState::Waiting(None) } + } +} + +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] +pub struct QueryId(pub usize); + +pub struct Query { + id: QueryId, + inner: FindNodeQuery, + started: Option, +} + +impl Query { + pub fn id(&self) -> QueryId { + self.id + } + + pub fn on_failure(&mut self, peer: &NodeId) { + self.inner.on_failure(peer); + } + + pub fn on_success(&mut self, peer: &NodeId, new_peers: impl IntoIterator) { + self.inner.on_success(peer, new_peers); + } + + /// Consume the query, returning the closest peers that responded + /// successfully. + pub fn into_result(self) -> impl Iterator { + self.inner.into_result() + } +} diff --git a/crates/discovery/tests/e2e.rs b/crates/discovery/tests/e2e.rs new file mode 100644 index 0000000..f5e9afe --- /dev/null +++ b/crates/discovery/tests/e2e.rs @@ -0,0 +1,220 @@ +use std::{ + net::{IpAddr, Ipv4Addr, SocketAddr}, + time::Instant, +}; + +use discovery::{DiscV5, Discovery, DiscoveryConfig, DiscoveryEvent, DiscoveryNetworking}; +use k256::ecdsa::SigningKey; +use silver_common::{Enr, NodeId}; + +struct TestNode { + disco: DiscV5, + addr: SocketAddr, + pubkey: [u8; 33], + enr_seq: u64, +} + +impl TestNode { + fn new(port: u16) -> Self { + Self::build(port, default_config(), true) + } + + /// No IP in ENR (so Pong-reported IP is always "new") and ping fires on + /// every poll call. + fn no_ip_fast_ping(port: u16) -> Self { + Self::build(port, zero_ping_config(), false) + } + + fn build(port: u16, config: DiscoveryConfig, with_ip: bool) -> Self { + let key = SigningKey::random(&mut rand::thread_rng()); + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); + let enr = if with_ip { + Enr::builder().ip4(Ipv4Addr::LOCALHOST).udp4(port).build(&key).unwrap() + } else { + Enr::builder().build(&key).unwrap() + }; + let enr_seq = enr.seq(); + let pubkey: [u8; 33] = + key.verifying_key().to_encoded_point(true).as_bytes().try_into().unwrap(); + Self { disco: DiscV5::new(config, key, enr, [0u8; 4]), addr, pubkey, enr_seq } + } + + fn node_id(&self) -> NodeId { + self.disco.local_id() + } + + fn poll(&mut self) -> Vec { + let mut ev = Vec::new(); + self.disco.poll(|e| ev.push(e)); + ev + } + + fn deliver(&mut self, from: SocketAddr, data: &[u8], now: Instant) { + self.disco.handle(from, data, now); + } +} + +fn default_config() -> DiscoveryConfig { + DiscoveryConfig { + find_nodes_peer_count: 3, + ping_frequency_s: 3600, + query_parallelism: 3, + query_peer_timeout_ms: 5_000, + } +} + +fn zero_ping_config() -> DiscoveryConfig { + DiscoveryConfig { + find_nodes_peer_count: 3, + ping_frequency_s: 0, + query_parallelism: 3, + query_peer_timeout_ms: 5_000, + } +} + +/// All SendMessage packets destined for `to`, in event order. +fn sends_to(events: &[DiscoveryEvent], to: SocketAddr) -> Vec> { + events + .iter() + .filter_map(|e| match e { + DiscoveryEvent::SendMessage { to: addr, data } if *addr == to => Some(data.to_vec()), + _ => None, + }) + .collect() +} + +fn has_session_established(events: &[DiscoveryEvent], node_id: NodeId) -> bool { + events.iter().any(|e| { + matches!(e, + DiscoveryEvent::SessionEstablished { node_id: id, .. } if *id == node_id + ) + }) +} + +/// Full WhoAreYou→Handshake exchange. Both sides emit SessionEstablished. +#[test] +fn handshake_establishes_session() { + let now = Instant::now(); + let mut a = TestNode::new(9001); + let mut b = TestNode::new(9002); + + a.disco.add_node(b.node_id(), b.addr, b.enr_seq, b.pubkey, now); + a.disco.find_node(NodeId::random()); + + // A → probe → B + let a_ev = a.poll(); + let probes = sends_to(&a_ev, b.addr); + assert!(!probes.is_empty(), "expected probe packet from A"); + + // B issues WhoAreYou + b.deliver(a.addr, &probes[0], now); + let b_ev = b.poll(); + let to_a = sends_to(&b_ev, a.addr); + assert!(!to_a.is_empty(), "expected WhoAreYou from B"); + + // A handles WhoAreYou, queues Handshake + FindNode + a.deliver(b.addr, &to_a[0], now); + let a_ev = a.poll(); + + assert!(has_session_established(&a_ev, b.node_id()), "A: expected SessionEstablished(B)"); + + // Route A's packets to B in order: Handshake must arrive before FindNode. + for pkt in sends_to(&a_ev, b.addr) { + b.deliver(a.addr, &pkt, now); + } + let b_ev = b.poll(); + + assert!(has_session_established(&b_ev, a.node_id()), "B: expected SessionEstablished(A)"); +} + +/// A message packet from an unknown source triggers a WhoAreYou reply. +#[test] +fn probe_from_unknown_source_triggers_whoareyou() { + let now = Instant::now(); + let mut a = TestNode::new(9011); + let mut b = TestNode::new(9012); + + // A initiates but B has no prior knowledge of A. + a.disco.add_node(b.node_id(), b.addr, b.enr_seq, b.pubkey, now); + a.disco.find_node(NodeId::random()); + + let a_ev = a.poll(); + let probes = sends_to(&a_ev, b.addr); + assert!(!probes.is_empty()); + + b.deliver(a.addr, &probes[0], now); + let b_ev = b.poll(); + + // B must have queued a response to A's address (the WhoAreYou). + let b_to_a = sends_to(&b_ev, a.addr); + assert!(!b_to_a.is_empty(), "B should respond to unknown sender with WhoAreYou"); +} + +/// Drive the probe → WhoAreYou → Handshake exchange so both sides have a +/// session. +fn do_handshake(a: &mut TestNode, b: &mut TestNode, now: Instant) { + a.disco.add_node(b.node_id(), b.addr, b.enr_seq, b.pubkey, now); + a.disco.find_node(NodeId::random()); + + let a_ev = a.poll(); + let probes = sends_to(&a_ev, b.addr); + b.deliver(a.addr, &probes[0], now); + + let b_ev = b.poll(); + let to_a = sends_to(&b_ev, a.addr); + a.deliver(b.addr, &to_a[0], now); + + let a_ev = a.poll(); + for pkt in sends_to(&a_ev, b.addr) { + b.deliver(a.addr, &pkt, now); + } + b.poll(); +} + +/// ExternalAddrChanged fires when IP_VOTE_THRESHOLD (3) **distinct** peers each +/// report the same external address via Pong. +#[test] +fn pong_ip_vote_triggers_external_addr_changed() { + let now = Instant::now(); + // A has no IP in ENR so the Pong-observed address is always new. + // zero_ping_config means pings fire on every poll. + let mut a = TestNode::no_ip_fast_ping(9021); + let mut b = TestNode::new(9022); + let mut c = TestNode::new(9023); + let mut d = TestNode::new(9024); + + do_handshake(&mut a, &mut b, now); + do_handshake(&mut a, &mut c, now); + do_handshake(&mut a, &mut d, now); + + // One ping round from A → B, C, D; each responds with one Pong. + let a_ev = a.poll(); + for pkt in sends_to(&a_ev, b.addr) { + b.deliver(a.addr, &pkt, now); + } + for pkt in sends_to(&a_ev, c.addr) { + c.deliver(a.addr, &pkt, now); + } + for pkt in sends_to(&a_ev, d.addr) { + d.deliver(a.addr, &pkt, now); + } + + let b_ev = b.poll(); + let c_ev = c.poll(); + let d_ev = d.poll(); + for pkt in sends_to(&b_ev, a.addr) { + a.deliver(b.addr, &pkt, now); + } + for pkt in sends_to(&c_ev, a.addr) { + a.deliver(c.addr, &pkt, now); + } + for pkt in sends_to(&d_ev, a.addr) { + a.deliver(d.addr, &pkt, now); + } + + let final_ev = a.poll(); + assert!( + final_ev.iter().any(|e| matches!(e, DiscoveryEvent::ExternalAddrChanged(_))), + "expected ExternalAddrChanged after 3 Pong votes from distinct peers" + ); +} diff --git a/crates/network/benches/quic_basic.rs b/crates/network/benches/quic_basic.rs index 43e8802..df3439d 100644 --- a/crates/network/benches/quic_basic.rs +++ b/crates/network/benches/quic_basic.rs @@ -1,8 +1,10 @@ use std::{ net::SocketAddr, sync::{ - atomic::{AtomicUsize, Ordering}, Arc - }, time::Duration, + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, }; use criterion::{BatchSize, Criterion, Throughput, criterion_group, criterion_main}; @@ -28,91 +30,101 @@ pub fn broadcast(c: &mut Criterion) { for i in 1..=3 { let criterion_batch_size = BatchSize::PerIteration; let throughput = Throughput::Elements((total * i) as u64); - - group.throughput(throughput.clone()).bench_function(format!("quic_basic_{BATCH_SIZE}_{i}"), |x| { - x.iter_batched( - || { - let recv_counter = Arc::new(AtomicUsize::default()); - - let (mut server_tile, server_id) = { - let secret = k256::ecdsa::SigningKey::random(&mut rng); - let key_bytes: [u8; 32] = secret.to_bytes().into(); - let keypair = Keypair::from_secret(&key_bytes).unwrap(); - let server_id = keypair.peer_id(); - let server_config = silver_network::create_server_config(&keypair).unwrap(); - let server_endpoint = Endpoint::new( - Arc::new(EndpointConfig::default()), - Some(Arc::new(server_config)), - false, - None, - ); - ( - NetworkTile::new( - keypair, - server_endpoint, - "0.0.0.0:20001".parse().unwrap(), - ServerHandler(recv_counter.clone()), + + group.throughput(throughput.clone()).bench_function( + format!("quic_basic_{BATCH_SIZE}_{i}"), + |x| { + x.iter_batched( + || { + let recv_counter = Arc::new(AtomicUsize::default()); + + let (mut server_tile, server_id) = { + let secret = k256::ecdsa::SigningKey::random(&mut rng); + let key_bytes: [u8; 32] = secret.to_bytes().into(); + let keypair = Keypair::from_secret(&key_bytes).unwrap(); + let server_id = keypair.peer_id(); + let server_config = + silver_network::create_server_config(&keypair).unwrap(); + let server_endpoint = Endpoint::new( + Arc::new(EndpointConfig::default()), + Some(Arc::new(server_config)), + false, + None, + ); + ( + NetworkTile::new( + keypair, + server_endpoint, + "0.0.0.0:20001".parse().unwrap(), + ServerHandler(recv_counter.clone()), + ) + .unwrap(), + server_id, ) - .unwrap(), - server_id, - ) - }; - - let server_handle = std::thread::spawn(move || { - loop { - server_tile.spin(); - if recv_counter.load(Ordering::Relaxed) == (total * i) { - tracing::info!("server completed"); - break; + }; + + let server_handle = std::thread::spawn(move || { + loop { + server_tile.spin(); + if recv_counter.load(Ordering::Relaxed) == (total * i) { + tracing::info!("server completed"); + break; + } } + }); + + let mut clients = vec![]; + for n in 0..i { + let data = data.clone(); + + let secret = k256::ecdsa::SigningKey::random(&mut rng); + let key_bytes: [u8; 32] = secret.to_bytes().into(); + let keypair = Keypair::from_secret(&key_bytes).unwrap(); + let client_endpoint = Endpoint::new( + Arc::new(EndpointConfig::default()), + None, + false, + None, + ); + + let client_data = ClientData { + server_id: Some(server_id.clone()), + server_addr: "127.0.0.1:20001".parse().unwrap(), + remote_peer: None, + remote_stream: None, + data, + offset: 0, + did_stream: false, + }; + + let addr = format!("127.0.0.1:{}", 20002 + n); + + clients.push( + NetworkTile::new( + keypair, + client_endpoint, + addr.parse().unwrap(), + client_data, + ) + .unwrap(), + ); } - }); - - let mut clients = vec![]; - for n in 0..i { - let data = data.clone(); - - let secret = k256::ecdsa::SigningKey::random(&mut rng); - let key_bytes: [u8; 32] = secret.to_bytes().into(); - let keypair = Keypair::from_secret(&key_bytes).unwrap(); - let client_endpoint = - Endpoint::new(Arc::new(EndpointConfig::default()), None, false, None); - - let client_data = ClientData { - server_id: Some(server_id.clone()), - server_addr: "127.0.0.1:20001".parse().unwrap(), - remote_peer: None, - remote_stream: None, - data, - offset: 0, - did_stream: false, - }; - let addr = format!("127.0.0.1:{}", 20002 + n); - - clients.push(NetworkTile::new( - keypair, - client_endpoint, - addr.parse().unwrap(), - client_data, - ) - .unwrap()); - } - - std::thread::sleep(Duration::from_millis(200)); - (server_handle, clients) - }, - |(handle, mut clients)| { - while !handle.is_finished() { - for client in &mut clients { - client.spin(); + std::thread::sleep(Duration::from_millis(200)); + (server_handle, clients) + }, + |(handle, mut clients)| { + while !handle.is_finished() { + for client in &mut clients { + client.spin(); + } } - } - handle.join().unwrap(); - }, - criterion_batch_size, - ); - }); + handle.join().unwrap(); + }, + criterion_batch_size, + ); + }, + ); } } @@ -139,14 +151,12 @@ impl silver_network::NetworkSend for ServerHandler { fn to_send(&mut self) -> Option<(silver_network::RemotePeer, quinn_proto::StreamId, &[u8])> { None } - + fn new_streams(&mut self) -> Option<(RemotePeer, quinn_proto::Dir)> { None } - - fn sent(&mut self, _peer: &RemotePeer, _stream: &quinn_proto::StreamId, _sent: usize) { - - } + + fn sent(&mut self, _peer: &RemotePeer, _stream: &quinn_proto::StreamId, _sent: usize) {} } impl silver_network::NetworkRecv for ServerHandler { @@ -193,24 +203,23 @@ impl silver_network::NetworkSend for ClientData { let Some(remote_stream) = self.remote_stream.as_ref() { if let Some(data) = self.data.last() { - return Some(( - remote_peer.clone(), - remote_stream.clone(), - &data[self.offset..], - )); + return Some((remote_peer.clone(), remote_stream.clone(), &data[self.offset..])); } } None } - + fn new_streams(&mut self) -> Option<(RemotePeer, quinn_proto::Dir)> { - if let Some(remote_peer) = self.remote_peer.as_ref() && self.remote_stream.is_none() && !self.did_stream { + if let Some(remote_peer) = self.remote_peer.as_ref() && + self.remote_stream.is_none() && + !self.did_stream + { self.did_stream = true; return Some((remote_peer.clone(), quinn_proto::Dir::Bi)); } None } - + fn sent(&mut self, _peer: &RemotePeer, _stream: &quinn_proto::StreamId, sent: usize) { self.offset += sent; let pop = self.data.last().map(|v| self.offset >= v.len()).unwrap_or_default(); diff --git a/crates/network/src/lib.rs b/crates/network/src/lib.rs index 1b1f06b..14f5f94 100644 --- a/crates/network/src/lib.rs +++ b/crates/network/src/lib.rs @@ -30,7 +30,7 @@ pub trait NetworkSend: Send { /// `None` is returned. fn to_send(&mut self) -> Option<(RemotePeer, StreamId, &[u8])>; - /// Send result callback. + /// Send result callback. fn sent(&mut self, peer: &RemotePeer, stream: &StreamId, sent: usize); } diff --git a/crates/network/src/p2p/quic/peer.rs b/crates/network/src/p2p/quic/peer.rs index 9508bc0..3250610 100644 --- a/crates/network/src/p2p/quic/peer.rs +++ b/crates/network/src/p2p/quic/peer.rs @@ -2,11 +2,11 @@ use std::{io::Error, time::Instant}; use bytes::Bytes; use quinn_proto::{ - Connection, ConnectionEvent, ConnectionHandle, Dir, EndpointEvent, StreamId, Transmit, VarInt + Connection, ConnectionEvent, ConnectionHandle, Dir, EndpointEvent, StreamId, Transmit, VarInt, }; use silver_common::PeerId; -use crate::{p2p::tls::peer_id_from_certificate, NetworkRecv, NetworkSend, RemotePeer}; +use crate::{NetworkRecv, NetworkSend, RemotePeer, p2p::tls::peer_id_from_certificate}; pub(crate) struct Peer { id: RemotePeer, @@ -38,7 +38,8 @@ impl Peer { now: Instant, ep_callback: &mut F, handler: &mut H, - ) -> Option where + ) -> Option + where F: FnMut(ConnectionHandle, EndpointEvent) -> Option, { while self.connection.poll_timeout().is_some_and(|t| t <= now) { @@ -75,7 +76,12 @@ impl Peer { match stream_event { quinn_proto::StreamEvent::Opened { dir } => { while let Some(id) = self.connection.streams().accept(dir) { - tracing::info!(?id, ?dir, spins=self.spin_count, "stream openned"); + tracing::info!( + ?id, + ?dir, + spins = self.spin_count, + "stream openned" + ); handler.new_stream(&self.id, &id); // try to read @@ -100,7 +106,8 @@ impl Peer { // conn.send_stream(id).write(data) { // } - //tracing::info!(spins=self.spin_count, "stream writable"); + //tracing::info!(spins=self.spin_count, "stream + // writable"); } quinn_proto::StreamEvent::Finished { id } => { tracing::info!(?id, "stream finished"); @@ -111,7 +118,7 @@ impl Peer { quinn_proto::StreamEvent::Available { dir } => { // Callback if it is now possible ot open a new stream (when previously // at limits) - tracing::info!(?dir, spins=self.spin_count, "stream available"); + tracing::info!(?dir, spins = self.spin_count, "stream available"); if let Some(id) = self.connection.streams().open(dir) {} } } diff --git a/justfile b/justfile index 7febb4b..a9b5cfa 100644 --- a/justfile +++ b/justfile @@ -16,4 +16,4 @@ clippy-fix: machete: cargo install cargo-machete && \ - cargo machete \ No newline at end of file + cargo machete From 37113a08629f17c264135fe2e5eeb6810129b26c Mon Sep 17 00:00:00 2001 From: Nina Date: Thu, 2 Apr 2026 11:37:14 +0100 Subject: [PATCH 2/5] workflows fix --- .github/workflows/lint.yml | 7 ++++++- .github/workflows/test.yml | 7 ++++++- justfile | 4 ++-- rust-toolchain.toml | 2 +- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 93cba7e..7c69cd8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -25,9 +25,14 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: - toolchain: nightly-2025-10-01 + toolchain: nightly-2025-06-01 components: clippy, rustfmt + - name: Install buf + uses: bufbuild/buf-action@v1 + with: + setup_only: true + - name: Setup just uses: extractions/setup-just@v2 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 91074f0..301a073 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,7 +25,12 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: - toolchain: nightly-2025-10-01 + toolchain: nightly-2025-06-01 + + - name: Install buf + uses: bufbuild/buf-action@v1 + with: + setup_only: true - name: Run tests run: cargo test diff --git a/justfile b/justfile index a9b5cfa..8447b29 100644 --- a/justfile +++ b/justfile @@ -1,11 +1,11 @@ toolchain := "nightly-2025-06-01" fmt: - rustup toolchain install {{toolchain}} > /dev/null 2>&1 && \ + rustup toolchain install {{toolchain}} --component rustfmt > /dev/null 2>&1 && \ cargo +{{toolchain}} fmt fmt-check: - rustup toolchain install {{toolchain}} > /dev/null 2>&1 && \ + rustup toolchain install {{toolchain}} --component rustfmt > /dev/null 2>&1 && \ cargo +{{toolchain}} fmt --check clippy: diff --git a/rust-toolchain.toml b/rust-toolchain.toml index d70af36..22fd8ba 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] channel = "1.91.0" -components = ["rust-analyzer", "rustfmt"] +components = ["clippy", "rust-analyzer", "rustfmt"] From 6e2c84c78fde27c9c0388233b15065b6a6669ba5 Mon Sep 17 00:00:00 2001 From: Nina Date: Thu, 2 Apr 2026 11:55:23 +0100 Subject: [PATCH 3/5] fix/suppress clippy errors --- crates/common/src/enr/builder.rs | 1 + crates/discovery/src/lib.rs | 3 +++ crates/network/src/lib.rs | 2 ++ crates/network/src/tile.rs | 1 - 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/common/src/enr/builder.rs b/crates/common/src/enr/builder.rs index f7d77e9..049f025 100644 --- a/crates/common/src/enr/builder.rs +++ b/crates/common/src/enr/builder.rs @@ -14,6 +14,7 @@ use super::{ }; #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +#[allow(clippy::enum_variant_names)] pub enum Error { #[error("enr exceeds max size")] ExceedsMaxSize, diff --git a/crates/discovery/src/lib.rs b/crates/discovery/src/lib.rs index 596d149..c3ed936 100644 --- a/crates/discovery/src/lib.rs +++ b/crates/discovery/src/lib.rs @@ -1,3 +1,6 @@ +// construct_uint! macro generates code triggering these lints. +#![allow(clippy::manual_div_ceil, clippy::assign_op_pattern)] + mod config; mod crypto; mod discovery; diff --git a/crates/network/src/lib.rs b/crates/network/src/lib.rs index 14f5f94..dfe4a94 100644 --- a/crates/network/src/lib.rs +++ b/crates/network/src/lib.rs @@ -1,3 +1,5 @@ +#![allow(dead_code, unused_variables, unused_mut)] + mod p2p; #[cfg(not(target_os = "linux"))] mod portable; diff --git a/crates/network/src/tile.rs b/crates/network/src/tile.rs index 30cdab1..6115137 100644 --- a/crates/network/src/tile.rs +++ b/crates/network/src/tile.rs @@ -3,7 +3,6 @@ use std::{ io::Error, net::SocketAddr, time::{Duration, Instant}, - usize, }; use flux::{tile::Tile, tracing}; From 42eaa30881a8366f26d01708e8d5394e3274ee42 Mon Sep 17 00:00:00 2001 From: Nina Date: Thu, 2 Apr 2026 15:39:35 +0100 Subject: [PATCH 4/5] ignore .local --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e68e4a5..79936ea 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ target/ .DS_Store # VSCode +.local .vscode .cursor .claude From 65a44052d8c41c54d7146b40299efbc718fcbebb Mon Sep 17 00:00:00 2001 From: Nina Date: Thu, 2 Apr 2026 18:25:58 +0100 Subject: [PATCH 5/5] secp256k1 --- Cargo.lock | 205 ++++---------------- Cargo.toml | 2 +- crates/common/Cargo.toml | 2 +- crates/common/src/enr/keys/k256_key.rs | 93 --------- crates/common/src/enr/keys/mod.rs | 2 +- crates/common/src/enr/keys/secp256k1_key.rs | 64 ++++++ crates/common/src/enr/mod.rs | 36 ++-- crates/common/src/id.rs | 22 +-- crates/common/src/lib.rs | 2 +- crates/discovery/Cargo.toml | 3 +- crates/discovery/src/crypto.rs | 84 ++++---- crates/discovery/src/discv5.rs | 160 ++++++++------- crates/discovery/src/message.rs | 21 +- crates/discovery/tests/e2e.rs | 13 +- crates/network/Cargo.toml | 2 +- crates/network/benches/quic_basic.rs | 8 +- crates/network/src/p2p/tls/certificate.rs | 11 +- 17 files changed, 291 insertions(+), 439 deletions(-) delete mode 100644 crates/common/src/enr/keys/k256_key.rs create mode 100644 crates/common/src/enr/keys/secp256k1_key.rs diff --git a/Cargo.lock b/Cargo.lock index 794cdbb..42ee686 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -324,24 +324,12 @@ dependencies = [ "windows-link", ] -[[package]] -name = "base16ct" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" - [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "bitcode" version = "0.6.9" @@ -366,6 +354,22 @@ dependencies = [ "syn", ] +[[package]] +name = "bitcoin-io" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" + +[[package]] +name = "bitcoin_hashes" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +dependencies = [ + "bitcoin-io", + "hex-conservative", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -581,12 +585,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -692,18 +690,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" -[[package]] -name = "crypto-bigint" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", -] - [[package]] name = "crypto-common" version = "0.1.6" @@ -752,16 +738,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "zeroize", -] - [[package]] name = "der-parser" version = "10.0.0" @@ -792,7 +768,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", - "const-oid", "crypto-common", "subtle", ] @@ -831,9 +806,9 @@ dependencies = [ "flux-utils", "hex", "hkdf", - "k256", "rand 0.8.5", "rustc-hash", + "secp256k1", "serde", "sha2", "silver-common", @@ -859,45 +834,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" -[[package]] -name = "ecdsa" -version = "0.16.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" -dependencies = [ - "der", - "digest", - "elliptic-curve", - "rfc6979", - "signature", - "spki", -] - [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -[[package]] -name = "elliptic-curve" -version = "0.13.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" -dependencies = [ - "base16ct", - "crypto-bigint", - "digest", - "ff", - "generic-array", - "group", - "pkcs8", - "rand_core 0.6.4", - "sec1", - "subtle", - "zeroize", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -959,16 +901,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -[[package]] -name = "ff" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1187,7 +1119,6 @@ checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", - "zeroize", ] [[package]] @@ -1271,17 +1202,6 @@ dependencies = [ "spinning_top", ] -[[package]] -name = "group" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" -dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "half" version = "2.7.1" @@ -1326,6 +1246,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -1457,20 +1386,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "k256" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" -dependencies = [ - "cfg-if", - "ecdsa", - "elliptic-curve", - "once_cell", - "sha2", - "signature", -] - [[package]] name = "keccak" version = "0.1.6" @@ -1810,16 +1725,6 @@ dependencies = [ "futures-io", ] -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - [[package]] name = "pkg-config" version = "0.3.32" @@ -2163,16 +2068,6 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" -[[package]] -name = "rfc6979" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" -dependencies = [ - "hmac", - "subtle", -] - [[package]] name = "rgb" version = "0.8.53" @@ -2290,17 +2185,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] -name = "sec1" -version = "0.7.3" +name = "secp256k1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" dependencies = [ - "base16ct", - "der", - "generic-array", - "pkcs8", - "subtle", - "zeroize", + "bitcoin_hashes", + "rand 0.8.5", + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", ] [[package]] @@ -2415,16 +2316,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest", - "rand_core 0.6.4", -] - [[package]] name = "silver-common" version = "0.0.1" @@ -2434,10 +2325,10 @@ dependencies = [ "bytes", "flux", "hex", - "k256", "rand 0.8.5", "rcgen", "rustls", + "secp256k1", "serde", "serde_json", "sha3", @@ -2454,7 +2345,6 @@ dependencies = [ "bytes", "criterion", "flux", - "k256", "libc", "mio", "pprof", @@ -2464,6 +2354,7 @@ dependencies = [ "rcgen", "ring", "rustls", + "secp256k1", "silver-common", "tracing", "tracing-subscriber", @@ -2517,16 +2408,6 @@ dependencies = [ "lock_api", ] -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index a73a926..c2f9cfe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,7 +61,6 @@ criterion = { version = "0.5.1", features = ["async_std", "async_tokio"] } ctr = "0.9" hex = "0.4" hkdf = "0.12" -k256 = { version = "0.13", features = ["ecdsa"] } libc = "0.2" mio = { version = "1.0.4", features = ["net", "os-poll"] } pprof = { version = "0.13", features = ["criterion", "flamegraph"] } @@ -71,6 +70,7 @@ rand_core = "0.6.4" rcgen = "0.14.7" ring = "0.17" rustc-hash = "2" +secp256k1 = { version = "0.30", features = ["global-context", "rand"] } rustls = "0.23.37" serde = { version = "1.0.228", features = ["derive"] } sha2 = "0.10" diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 1d71458..60d2ef9 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -11,8 +11,8 @@ base64.workspace = true bytes.workspace = true flux.workspace = true hex.workspace = true -k256.workspace = true rand.workspace = true +secp256k1.workspace = true rcgen.workspace = true rustls.workspace = true serde.workspace = true diff --git a/crates/common/src/enr/keys/k256_key.rs b/crates/common/src/enr/keys/k256_key.rs deleted file mode 100644 index c4cd2d8..0000000 --- a/crates/common/src/enr/keys/k256_key.rs +++ /dev/null @@ -1,93 +0,0 @@ -// Adapted from https://github.com/sigp/enr (MIT License) - -use alloy_rlp::Error as DecoderError; -use k256::{ - AffinePoint, CompressedPoint, EncodedPoint, - ecdsa::{ - Signature, SigningKey, VerifyingKey, - signature::{DigestVerifier, RandomizedDigestSigner}, - }, - elliptic_curve::{ - point::DecompressPoint, - sec1::{Coordinates, ToEncodedPoint}, - subtle::Choice, - }, -}; -use rand::rngs::OsRng; -use sha3::{Digest, Keccak256}; - -use super::{EnrKey, EnrKeyUnambiguous, EnrPublicKey, SigningError}; - -pub const ENR_KEY: &str = "secp256k1"; - -impl EnrKey for SigningKey { - type PublicKey = VerifyingKey; - - fn sign_v4(&self, msg: &[u8]) -> Result, SigningError> { - let digest = Keccak256::new().chain_update(msg); - let signature: Signature = - self.try_sign_digest_with_rng(&mut OsRng, digest).map_err(|_| SigningError {})?; - - Ok(signature.to_vec()) - } - - fn public(&self) -> Self::PublicKey { - *self.verifying_key() - } - - fn enr_to_public(scheme: &[u8], pubkey_bytes: &[u8]) -> Result { - if scheme != ENR_KEY.as_bytes() { - return Err(DecoderError::Custom("Unknown signature")); - } - Self::decode_public(pubkey_bytes) - } -} - -impl EnrKeyUnambiguous for SigningKey { - fn decode_public(bytes: &[u8]) -> Result { - VerifyingKey::from_sec1_bytes(bytes) - .map_err(|_| DecoderError::Custom("Invalid Secp256k1 Signature")) - } -} - -impl EnrPublicKey for VerifyingKey { - type Raw = CompressedPoint; - type RawUncompressed = [u8; 64]; - - fn verify_v4(&self, msg: &[u8], sig: &[u8]) -> bool { - if let Ok(sig) = k256::ecdsa::Signature::try_from(sig) { - return self.verify_digest(Keccak256::new().chain_update(msg), &sig).is_ok(); - } - false - } - - fn encode(&self) -> Self::Raw { - self.into() - } - - fn encode_uncompressed(&self) -> Self::RawUncompressed { - let p = EncodedPoint::from(self); - let (x, y) = match p.coordinates() { - Coordinates::Compact { .. } | Coordinates::Identity => unreachable!(), - Coordinates::Compressed { x, y_is_odd } => ( - x, - *AffinePoint::decompress(x, Choice::from(u8::from(y_is_odd))) - .unwrap() - .to_encoded_point(false) - .y() - .unwrap(), - ), - Coordinates::Uncompressed { x, y } => (x, *y), - }; - - let mut coords = [0; 64]; - coords[..32].copy_from_slice(x); - coords[32..].copy_from_slice(&y); - - coords - } - - fn enr_key(&self) -> &'static [u8] { - ENR_KEY.as_bytes() - } -} diff --git a/crates/common/src/enr/keys/mod.rs b/crates/common/src/enr/keys/mod.rs index 7bcc09b..466f292 100644 --- a/crates/common/src/enr/keys/mod.rs +++ b/crates/common/src/enr/keys/mod.rs @@ -1,6 +1,6 @@ // Adapted from https://github.com/sigp/enr (MIT License) -mod k256_key; +mod secp256k1_key; use std::{ error::Error, diff --git a/crates/common/src/enr/keys/secp256k1_key.rs b/crates/common/src/enr/keys/secp256k1_key.rs new file mode 100644 index 0000000..1298f5c --- /dev/null +++ b/crates/common/src/enr/keys/secp256k1_key.rs @@ -0,0 +1,64 @@ +// secp256k1 ENR identity scheme using libsecp256k1 (C FFI). + +use alloy_rlp::Error as DecoderError; +use secp256k1::{PublicKey, SECP256K1, SecretKey, ecdsa::Signature}; +use sha3::{Digest, Keccak256}; + +use super::{EnrKey, EnrKeyUnambiguous, EnrPublicKey, SigningError}; + +pub const ENR_KEY: &str = "secp256k1"; + +impl EnrKey for SecretKey { + type PublicKey = PublicKey; + + fn sign_v4(&self, msg: &[u8]) -> Result, SigningError> { + let hash = Keccak256::digest(msg); + let msg = secp256k1::Message::from_digest_slice(&hash).map_err(|_| SigningError {})?; + let sig = SECP256K1.sign_ecdsa(&msg, self); + Ok(sig.serialize_compact().to_vec()) + } + + fn public(&self) -> Self::PublicKey { + self.public_key(SECP256K1) + } + + fn enr_to_public(scheme: &[u8], pubkey_bytes: &[u8]) -> Result { + if scheme != ENR_KEY.as_bytes() { + return Err(DecoderError::Custom("Unknown signature")); + } + Self::decode_public(pubkey_bytes) + } +} + +impl EnrKeyUnambiguous for SecretKey { + fn decode_public(bytes: &[u8]) -> Result { + PublicKey::from_slice(bytes).map_err(|_| DecoderError::Custom("Invalid Secp256k1 key")) + } +} + +impl EnrPublicKey for PublicKey { + type Raw = [u8; 33]; + type RawUncompressed = [u8; 64]; + + fn verify_v4(&self, msg: &[u8], sig: &[u8]) -> bool { + let Ok(signature) = Signature::from_compact(sig) else { return false }; + let hash = Keccak256::digest(msg); + let Ok(msg) = secp256k1::Message::from_digest_slice(&hash) else { return false }; + SECP256K1.verify_ecdsa(&msg, &signature, self).is_ok() + } + + fn encode(&self) -> Self::Raw { + self.serialize() + } + + fn encode_uncompressed(&self) -> Self::RawUncompressed { + let full = self.serialize_uncompressed(); // [u8; 65], 0x04 || x || y + let mut out = [0u8; 64]; + out.copy_from_slice(&full[1..]); + out + } + + fn enr_key(&self) -> &'static [u8] { + ENR_KEY.as_bytes() + } +} diff --git a/crates/common/src/enr/mod.rs b/crates/common/src/enr/mod.rs index 7d5d7a7..47b5c7f 100644 --- a/crates/common/src/enr/mod.rs +++ b/crates/common/src/enr/mod.rs @@ -606,7 +606,7 @@ pub fn digest(b: &[u8]) -> [u8; 32] { mod tests { use super::*; - type DefaultEnr = Enr; + type DefaultEnr = Enr; #[test] fn test_vector_k256() { @@ -661,7 +661,7 @@ mod tests { hex::decode("a448f24c6d18e575453db13171562b71999873db5b286df957af199ec94617f7") .unwrap(); - let enr = text.parse::>().unwrap(); + let enr = text.parse::>().unwrap(); assert_eq!(enr.ip4(), Some(Ipv4Addr::new(127, 0, 0, 1))); assert_eq!(enr.id(), Some(String::from("v4"))); assert_eq!(enr.udp4(), Some(30303)); @@ -714,7 +714,7 @@ mod tests { let key_data = hex::decode("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") .unwrap(); - let key = k256::ecdsa::SigningKey::from_slice(&key_data).unwrap(); + let key = secp256k1::SecretKey::from_slice(&key_data).unwrap(); let mut record = text.parse::().unwrap(); assert!(record.set_udp4(record.udp4().unwrap(), &key).is_ok()); @@ -737,7 +737,7 @@ mod tests { #[test] fn test_encode_decode_k256() { - let key = k256::ecdsa::SigningKey::random(&mut rand::rngs::OsRng); + let key = secp256k1::SecretKey::new(&mut rand::rngs::OsRng); let ip = Ipv4Addr::new(127, 0, 0, 1); let udp = 3000u16; @@ -747,7 +747,7 @@ mod tests { enr.encode(&mut encoded_enr); let decoded_enr = - Enr::::decode(&mut encoded_enr.to_vec().as_slice()).unwrap(); + Enr::::decode(&mut encoded_enr.to_vec().as_slice()).unwrap(); assert_eq!(decoded_enr.id(), Some("v4".into())); assert_eq!(decoded_enr.ip4(), Some(ip)); @@ -760,7 +760,7 @@ mod tests { #[test] fn test_set_ip() { let mut rng = rand::thread_rng(); - let key = k256::ecdsa::SigningKey::random(&mut rng); + let key = secp256k1::SecretKey::new(&mut rng); let udp = 30303u16; let ip = Ipv4Addr::new(10, 0, 0, 1); @@ -777,7 +777,7 @@ mod tests { #[test] fn ip_mutation_static_node_id() { let mut rng = rand::thread_rng(); - let key = k256::ecdsa::SigningKey::random(&mut rng); + let key = secp256k1::SecretKey::new(&mut rng); let udp = 30303u16; let ip = Ipv4Addr::new(10, 0, 0, 1); @@ -821,20 +821,24 @@ mod tests { #[test] fn test_compare_content() { - let key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); + let key = secp256k1::SecretKey::new(&mut rand::thread_rng()); let ip = Ipv4Addr::new(10, 0, 0, 1); let udp = 30303u16; let enr1 = Enr::builder().ip4(ip).udp4(udp).build(&key).unwrap(); + // enr2: different seq (2) → different rlp_content, different signature. let mut enr2 = enr1.clone(); - enr2.set_seq(1, &key).unwrap(); + enr2.set_seq(2, &key).unwrap(); + // enr3: different IP → different rlp_content. let mut enr3 = enr1.clone(); - enr3.set_seq(2, &key).unwrap(); + enr3.set_ip(Ipv4Addr::new(10, 0, 0, 2).into(), &key).unwrap(); + // Same IP/UDP, different seq → rlp_content differs (seq is part of it). assert_ne!(enr1.signature(), enr2.signature()); - assert!(enr1.compare_content(&enr2)); + assert!(!enr1.compare_content(&enr2)); assert_ne!(enr1, enr2); + // Different IP → rlp_content differs. assert_ne!(enr1.signature(), enr3.signature()); assert!(!enr1.compare_content(&enr3)); assert_ne!(enr1, enr3); @@ -842,7 +846,7 @@ mod tests { #[test] fn test_set_seq() { - let key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); + let key = secp256k1::SecretKey::new(&mut rand::thread_rng()); let mut enr = Enr::empty(&key).unwrap(); enr.set_seq(30, &key).unwrap(); assert_eq!(enr.seq(), 30); @@ -853,7 +857,7 @@ mod tests { #[test] fn test_eth2_roundtrip() { - let key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); + let key = secp256k1::SecretKey::new(&mut rand::thread_rng()); let mut enr = Enr::builder().ip4(Ipv4Addr::LOCALHOST).udp4(9000u16).build(&key).unwrap(); let eth2: [u8; 16] = std::array::from_fn(|i| (i + 1) as u8); enr.set_eth2(eth2, &key).unwrap(); @@ -869,7 +873,7 @@ mod tests { #[test] fn test_attnets_roundtrip() { - let key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); + let key = secp256k1::SecretKey::new(&mut rand::thread_rng()); let mut enr = Enr::builder().ip4(Ipv4Addr::LOCALHOST).udp4(9000u16).build(&key).unwrap(); let attnets: [u8; 8] = [0xff, 0x00, 0xab, 0xcd, 0x12, 0x34, 0x56, 0x78]; enr.set_attnets(attnets, &key).unwrap(); @@ -885,7 +889,7 @@ mod tests { #[test] fn test_syncnets_roundtrip() { - let key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); + let key = secp256k1::SecretKey::new(&mut rand::thread_rng()); let mut enr = Enr::builder().ip4(Ipv4Addr::LOCALHOST).udp4(9000u16).build(&key).unwrap(); let syncnets: u8 = 0x0f; enr.set_syncnets(syncnets, &key).unwrap(); @@ -901,7 +905,7 @@ mod tests { #[test] fn test_all_cl_fields_roundtrip_with_verify() { - let key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); + let key = secp256k1::SecretKey::new(&mut rand::thread_rng()); let mut enr = Enr::builder().ip4(Ipv4Addr::new(10, 0, 0, 1)).udp4(30303u16).build(&key).unwrap(); diff --git a/crates/common/src/id.rs b/crates/common/src/id.rs index 74abc1f..bef0a6a 100644 --- a/crates/common/src/id.rs +++ b/crates/common/src/id.rs @@ -1,5 +1,7 @@ use std::fmt; +use secp256k1::{SECP256K1, SecretKey}; + use crate::{Error, util::decode_varint}; /// libp2p peer identity (multihash-encoded). @@ -36,16 +38,15 @@ impl fmt::Debug for PeerId { /// secp256k1 keypair for libp2p node identity. pub struct Keypair { - signing_key: k256::ecdsa::SigningKey, + signing_key: SecretKey, compressed: [u8; 33], } impl Keypair { /// Create from raw 32-byte secret key. pub fn from_secret(secret: &[u8; 32]) -> Result { - let signing_key = - k256::ecdsa::SigningKey::from_bytes(secret.into()).map_err(|_| Error::BadPrivateKey)?; - let compressed = compress(&signing_key); + let signing_key = SecretKey::from_slice(secret).map_err(|_| Error::BadPrivateKey)?; + let compressed = signing_key.public_key(SECP256K1).serialize(); Ok(Self { signing_key, compressed }) } @@ -59,19 +60,12 @@ impl Keypair { } pub fn sign(&self, msg: &[u8]) -> Vec { - use k256::ecdsa::{DerSignature, signature::Signer}; - let sig: DerSignature = self.signing_key.sign(msg); - sig.as_ref().to_vec() + let msg = secp256k1::Message::from_digest_slice(msg).expect("message must be 32 bytes"); + let sig = SECP256K1.sign_ecdsa(&msg, &self.signing_key); + sig.serialize_der().to_vec() } } -fn compress(key: &k256::ecdsa::SigningKey) -> [u8; 33] { - let point = key.verifying_key().to_encoded_point(true); - let mut out = [0u8; 33]; - out.copy_from_slice(point.as_bytes()); - out -} - /// Encode secp256k1 compressed pubkey as libp2p protobuf PublicKey. /// message PublicKey { required KeyType Type = 1; required bytes Data = 2; } pub fn encode_secp256k1_protobuf(compressed: &[u8; 33]) -> Vec { diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index b29ca5f..f92d920 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -11,7 +11,7 @@ mod error; mod id; mod util; -pub type Enr = enr::Enr; +pub type Enr = enr::Enr; pub use enr::NodeId; #[from_spine("silver")] diff --git a/crates/discovery/Cargo.toml b/crates/discovery/Cargo.toml index 3a3b1dc..0780526 100644 --- a/crates/discovery/Cargo.toml +++ b/crates/discovery/Cargo.toml @@ -14,19 +14,18 @@ bytes.workspace = true flux.workspace = true flux-utils.workspace = true hkdf.workspace = true -k256.workspace = true rand.workspace = true serde.workspace = true sha2.workspace = true silver-common.workspace = true rustc-hash.workspace = true +secp256k1.workspace = true thiserror.workspace = true tracing.workspace = true uint.workspace = true [dev-dependencies] hex.workspace = true -k256.workspace = true rand.workspace = true silver-common.workspace = true diff --git a/crates/discovery/src/crypto.rs b/crates/discovery/src/crypto.rs index 456f13c..34016b5 100644 --- a/crates/discovery/src/crypto.rs +++ b/crates/discovery/src/crypto.rs @@ -1,14 +1,7 @@ use aes_gcm::{Aes128Gcm, aead::KeyInit}; use flux::utils::ArrayVec; use hkdf::Hkdf; -use k256::{ - ProjectivePoint, - ecdsa::{ - Signature, SigningKey, VerifyingKey, - signature::{DigestSigner, DigestVerifier}, - }, - elliptic_curve::sec1::ToEncodedPoint, -}; +use secp256k1::{PublicKey, SECP256K1, SecretKey, ecdh::shared_secret_point, ecdsa::Signature}; use sha2::{Digest, Sha256}; use silver_common::NodeId; @@ -23,13 +16,15 @@ pub fn make_cipher(key: &[u8; 16]) -> SessionCipher { Aes128Gcm::new_from_slice(key).expect("key is 16 bytes") } -/// Static ECDH: scalar multiply `remote_vk` by `local_sk`, return compressed +/// Static ECDH: scalar multiply `remote_pk` by `local_sk`, return compressed /// point. -pub fn ecdh(remote_vk: &VerifyingKey, local_sk: &SigningKey) -> [u8; 33] { - let pt = (ProjectivePoint::from(*remote_vk.as_affine()) * - local_sk.as_nonzero_scalar().as_ref()) - .to_affine(); - pt.to_encoded_point(true).as_bytes().try_into().expect("compressed point is 33 bytes") +pub fn ecdh(remote_pk: &PublicKey, local_sk: &SecretKey) -> [u8; 33] { + let xy = shared_secret_point(remote_pk, local_sk); + let mut compressed = [0u8; 33]; + // Parity byte: 0x02 if y is even, 0x03 if odd. + compressed[0] = if xy[63] & 1 == 0 { 0x02 } else { 0x03 }; + compressed[1..].copy_from_slice(&xy[..32]); + compressed } /// HKDF-SHA256 key derivation per discv5 spec. @@ -66,46 +61,44 @@ pub fn ecdh_generate_and_derive( remote_id: &NodeId, challenge_data: &[u8], ) -> Option<([u8; 33], [u8; 16], [u8; 16])> { - let remote_vk = VerifyingKey::from_sec1_bytes(remote_pubkey).ok()?; - let ephem_sk = SigningKey::random(&mut rand::thread_rng()); - let ephem_pk_bytes: [u8; 33] = - ephem_sk.verifying_key().to_encoded_point(true).as_bytes().try_into().ok()?; - let secret = ecdh(&remote_vk, &ephem_sk); + let remote_pk = PublicKey::from_slice(remote_pubkey).ok()?; + let ephem_sk = SecretKey::new(&mut rand::thread_rng()); + let ephem_pk_bytes = ephem_sk.public_key(SECP256K1).serialize(); + let secret = ecdh(&remote_pk, &ephem_sk); let (initiator_key, recipient_key) = derive_session_keys(&secret, local_id, remote_id, challenge_data); Some((ephem_pk_bytes, initiator_key, recipient_key)) } -/// ECDH on responder side: local static key × peer's ephemeral pubkey. +/// ECDH on responder side: local static key x peer's ephemeral pubkey. /// Returns `(initiator_key, recipient_key)`. pub fn ecdh_and_derive_keys_responder( - local_key: &SigningKey, + local_sk: &SecretKey, ephem_pubkey: &[u8; 33], initiator_id: &NodeId, responder_id: &NodeId, challenge_data: &[u8], ) -> Option<([u8; 16], [u8; 16])> { - let ephem_vk = VerifyingKey::from_sec1_bytes(ephem_pubkey).ok()?; - let secret = ecdh(&ephem_vk, local_key); + let ephem_pk = PublicKey::from_slice(ephem_pubkey).ok()?; + let secret = ecdh(&ephem_pk, local_sk); Some(derive_session_keys(&secret, initiator_id, responder_id, challenge_data)) } pub fn sign_id_nonce( - local_key: &SigningKey, + local_sk: &SecretKey, challenge_data: &[u8], ephem_pubkey: &[u8; 33], dst_id: &NodeId, ) -> Option<[u8; 64]> { - let digest = Sha256::new() + let hash = Sha256::new() .chain_update(ID_SIGNATURE_PREFIX) .chain_update(challenge_data) .chain_update(ephem_pubkey) - .chain_update(dst_id.raw()); - let sig: Signature = local_key.sign_digest(digest); - let raw = sig.to_bytes(); - let mut out = [0u8; 64]; - out.copy_from_slice(raw.as_ref()); - Some(out) + .chain_update(dst_id.raw()) + .finalize(); + let msg = secp256k1::Message::from_digest_slice(&hash).ok()?; + let sig = SECP256K1.sign_ecdsa(&msg, local_sk); + Some(sig.serialize_compact()) } pub fn verify_id_nonce_sig( @@ -115,14 +108,16 @@ pub fn verify_id_nonce_sig( dst_id: &NodeId, sig: &[u8; 64], ) -> bool { - let Ok(vk) = VerifyingKey::from_sec1_bytes(remote_pubkey) else { return false }; - let Ok(signature) = Signature::from_slice(sig) else { return false }; - let digest = Sha256::new() + let Ok(pk) = PublicKey::from_slice(remote_pubkey) else { return false }; + let Ok(signature) = Signature::from_compact(sig) else { return false }; + let hash = Sha256::new() .chain_update(ID_SIGNATURE_PREFIX) .chain_update(challenge_data) .chain_update(ephem_pubkey) - .chain_update(dst_id.raw()); - vk.verify_digest(digest, &signature).is_ok() + .chain_update(dst_id.raw()) + .finalize(); + let Ok(msg) = secp256k1::Message::from_digest_slice(&hash) else { return false }; + SECP256K1.verify_ecdsa(&msg, &signature, &pk).is_ok() } pub fn encrypt_message( @@ -163,25 +158,25 @@ mod tests { #[test] fn ecdh_matches_spec() { - let sk = SigningKey::from_slice(&h( + let sk = SecretKey::from_slice(&h( "fb757dc581730490a1d7a00deea65e9b1936924caaea8f44d476014856b68736", )) .unwrap(); - let remote_vk = VerifyingKey::from_sec1_bytes(&h( + let remote_pk = PublicKey::from_slice(&h( "039961e4c2356d61bedb83052c115d311acb3a96f5777296dcf297351130266231", )) .unwrap(); let expected = h("033b11a2a1f214567e1537ce5e509ffd9b21373247f2a3ff6841f4976f53165e7e"); - assert_eq!(ecdh(&remote_vk, &sk).as_slice(), expected.as_slice()); + assert_eq!(ecdh(&remote_pk, &sk).as_slice(), expected.as_slice()); } #[test] fn key_derivation_matches_spec() { - let ephem_sk = SigningKey::from_slice(&h( + let ephem_sk = SecretKey::from_slice(&h( "fb757dc581730490a1d7a00deea65e9b1936924caaea8f44d476014856b68736", )) .unwrap(); - let dest_vk = VerifyingKey::from_sec1_bytes(&h( + let dest_pk = PublicKey::from_slice(&h( "0317931e6e0840220642f230037d285d122bc59063221ef3226b1f403ddc69ca91", )) .unwrap(); @@ -199,7 +194,7 @@ mod tests { "000000000000000000000000000000006469736376350001010102030405060708090a0b0c00180102030405060708090a0b0c0d0e0f100000000000000000", ); - let secret = ecdh(&dest_vk, &ephem_sk); + let secret = ecdh(&dest_pk, &ephem_sk); let (ik, rk) = derive_session_keys(&secret, &node_id_a, &node_id_b, &challenge_data); assert_eq!(ik.as_slice(), h("dccc82d81bd610f4f76d3ebe97a40571").as_slice()); @@ -208,7 +203,7 @@ mod tests { #[test] fn id_nonce_sig_verifies_spec_vector() { - let sk = SigningKey::from_slice(&h( + let sk = SecretKey::from_slice(&h( "fb757dc581730490a1d7a00deea65e9b1936924caaea8f44d476014856b68736", )) .unwrap(); @@ -228,8 +223,7 @@ mod tests { .try_into() .unwrap(); - let static_pk: [u8; 33] = - sk.verifying_key().to_encoded_point(true).as_bytes().try_into().unwrap(); + let static_pk = sk.public_key(SECP256K1).serialize(); assert!(verify_id_nonce_sig( &static_pk, diff --git a/crates/discovery/src/discv5.rs b/crates/discovery/src/discv5.rs index b7dac3a..cb0d2d5 100644 --- a/crates/discovery/src/discv5.rs +++ b/crates/discovery/src/discv5.rs @@ -5,9 +5,9 @@ use std::{ use alloy_rlp::{Decodable, Encodable}; use flux::utils::ArrayVec; -use k256::ecdsa::SigningKey; use rand::RngCore as _; use rustc_hash::FxHashMap; +use secp256k1::{SECP256K1, SecretKey}; use silver_common::{Enr, NodeId}; use tracing::warn; @@ -33,7 +33,7 @@ const IP_VOTE_THRESHOLD: u32 = 3; pub struct DiscV5 { config: DiscoveryConfig, - local_key: SigningKey, + local_key: SecretKey, local_id: NodeId, local_enr: Enr, local_enr_raw: ArrayVec, @@ -47,6 +47,7 @@ pub struct DiscV5 { challenges: FxHashMap, pending_findnodes: FxHashMap, pending_probe_nonces: FxHashMap, + pending_pings: FxHashMap, event_queue: Vec, ip_votes: FxHashMap, @@ -59,11 +60,11 @@ pub struct DiscV5 { impl DiscV5 { pub fn new( config: DiscoveryConfig, - local_key: SigningKey, + local_key: SecretKey, local_enr: Enr, fork_digest: [u8; 4], ) -> Self { - let local_id = NodeId::from(*local_key.verifying_key()); + let local_id = NodeId::from(local_key.public_key(SECP256K1)); let kbuckets = KBucketsTable::new(Key::from(local_id), Duration::from_secs(60)); let query_timeout = config.query_peer_timeout() * (config.query_parallelism as u32).saturating_add(1); @@ -91,9 +92,13 @@ impl DiscV5 { MAX_SESSIONS_COUNT, Default::default(), ), + pending_pings: FxHashMap::with_capacity_and_hasher( + MAX_SESSIONS_COUNT, + Default::default(), + ), + event_queue: Vec::with_capacity(MAX_SESSIONS_COUNT * 2), ip_votes: FxHashMap::with_capacity_and_hasher(MAX_SESSIONS_COUNT, Default::default()), ip_vote_counts: FxHashMap::with_capacity_and_hasher(8, Default::default()), - event_queue: Vec::with_capacity(MAX_SESSIONS_COUNT * 2), next_request_id: 0, last_ping: Instant::now(), } @@ -227,17 +232,24 @@ impl DiscV5 { } } - fn on_pong(&mut self, src_id: NodeId, ip: IpAddr, port: u16) { + fn on_pong(&mut self, src_id: NodeId, request_id: u64, ip: IpAddr, port: u16) { + match self.pending_pings.remove(&request_id) { + Some(expected) if expected == src_id => {} + _ => return, + } + let addr = (ip, port); let old = self.ip_votes.insert(src_id, addr); if old == Some(addr) { return; } + if let Some(old_addr) = old { if let Some(c) = self.ip_vote_counts.get_mut(&old_addr) { *c = c.saturating_sub(1); } } + let count = self.ip_vote_counts.entry(addr).or_insert(0); *count += 1; @@ -246,6 +258,7 @@ impl DiscV5 { IpAddr::V4(a) => self.local_enr.ip4() != Some(a), IpAddr::V6(a) => self.local_enr.ip6() != Some(a), }; + if changed { let socket = SocketAddr::new(ip, port); let _ = self.local_enr.set_udp_socket(socket, &self.local_key); @@ -278,10 +291,7 @@ impl DiscV5 { continue; }; - let Ok(pk_bytes) = enr.public_key().to_encoded_point(true).as_bytes().try_into() else { - continue; - }; - + let pk_bytes = enr.public_key().serialize(); let node_id = enr.node_id(); let _ = self.kbuckets.insert_or_update( &Key::from(node_id), @@ -305,12 +315,12 @@ impl DiscV5 { request_id: u64, distances: &Distances, ) { - let node_ids = self.kbuckets.nodes_by_distances(distances.as_slice(), MAX_NODES_PER_BUCKET); - let mut enrs: ArrayVec, 16> = ArrayVec::new(); if distances.iter().any(|&d| d == 0) { enrs.push(self.local_enr_raw); } + + let node_ids = self.kbuckets.nodes_by_distances(distances.as_slice(), MAX_NODES_PER_BUCKET); for id in node_ids { if enrs.is_full() { break; @@ -407,11 +417,13 @@ impl DiscV5 { Message::Ping { request_id, enr_seq } => { self.on_ping(src_id, src_addr, request_id, enr_seq, existing.enr_seq); } - Message::Pong { ip, port, .. } => { - self.on_pong(src_id, ip, port); + Message::Pong { request_id, ip, port, .. } => { + self.on_pong(src_id, request_id, ip, port); } Message::FindNode { request_id, distances } => { - self.send_nodes_response(src_id, src_addr, request_id, &distances); + if existing.addr == src_addr { + self.send_nodes_response(src_id, src_addr, request_id, &distances); + } } Message::Nodes { nodes, .. } => { self.on_nodes(src_id, nodes, now); @@ -572,14 +584,7 @@ impl DiscV5 { return; } - let pk_bytes: [u8; 33] = - match enr.public_key().to_encoded_point(true).as_bytes().try_into() { - Ok(b) => b, - Err(_) => { - warn!(%src_id, %src_addr, "handshake ENR has invalid public key"); - return; - } - }; + let pk_bytes = enr.public_key().serialize(); self.kbuckets.insert_or_update( &Key::from(src_id), @@ -601,20 +606,16 @@ impl DiscV5 { if stored_enr_raw != Some(raw) { if let Ok(enr) = Enr::decode(&mut raw.as_slice()) { if enr.node_id() == src_id { - if let Ok(pk_bytes) = - enr.public_key().to_encoded_point(true).as_bytes().try_into() - { - let _ = self.kbuckets.insert_or_update( - &Key::from(src_id), - NodeEntry { - addr: src_addr, - enr_seq: enr.seq(), - pubkey: pk_bytes, - enr_raw: Some(raw), - }, - now, - ); - } + let _ = self.kbuckets.insert_or_update( + &Key::from(src_id), + NodeEntry { + addr: src_addr, + enr_seq: enr.seq(), + pubkey: enr.public_key().serialize(), + enr_raw: Some(raw), + }, + now, + ); } } } @@ -748,6 +749,7 @@ impl DiscoveryNetworking for DiscV5 { enr_seq: self.local_enr.seq(), }) { + self.pending_pings.insert(rid, node_id); rid = rid.wrapping_add(1); f(DiscoveryEvent::SendMessage { to: node.value.addr, data }); } @@ -812,11 +814,6 @@ impl DiscoveryNetworking for DiscV5 { match packet.kind { MessageKind::Message => { - // Invalidate session if peer's endpoint changed. - if self.sessions.get(&src_id).is_some_and(|s| s.addr != src_addr) { - self.sessions.remove(&src_id); - } - if let Some(s) = self.sessions.get_mut(&src_id) { if !s.check_and_record_nonce(&nonce, now) { warn!(%src_id, %src_addr, ?nonce, "replayed nonce, dropping message"); @@ -824,11 +821,21 @@ impl DiscoveryNetworking for DiscV5 { } let plain = decrypt_message(&s.dec, &nonce, &aad, packet.message); - if let Some(bytes) = plain { - self.handle_message(src_id, src_addr, &bytes, now); - } else { - // Stale session or decryption failure; re-challenge. - self.send_whoareyou(src_id, src_addr, nonce, now); + let addr_matches = s.addr == src_addr; + + match (plain, addr_matches) { + (Some(bytes), true) => { + self.handle_message(src_id, src_addr, &bytes, now); + } + (Some(_), false) => { + // Decrypted OK but endpoint changed — re-handshake. + self.sessions.remove(&src_id); + self.send_whoareyou(src_id, src_addr, nonce, now); + } + (None, _) => { + // Stale session or decryption failure; re-challenge. + self.send_whoareyou(src_id, src_addr, nonce, now); + } } } else { self.send_whoareyou(src_id, src_addr, nonce, now); @@ -935,7 +942,6 @@ mod tests { time::Instant, }; - use k256::ecdsa::SigningKey; use silver_common::{Enr, NodeId}; use super::*; @@ -955,12 +961,11 @@ mod tests { } fn make_node(port: u16) -> (DiscV5, SocketAddr, [u8; 33]) { - let key = SigningKey::random(&mut rand::thread_rng()); + let sk = SecretKey::new(&mut rand::thread_rng()); let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); - let enr = Enr::builder().ip4(Ipv4Addr::LOCALHOST).udp4(port).build(&key).unwrap(); - let pubkey: [u8; 33] = - key.verifying_key().to_encoded_point(true).as_bytes().try_into().unwrap(); - let disco = DiscV5::new(test_config(), key, enr, [0u8; 4]); + let enr = Enr::builder().ip4(Ipv4Addr::LOCALHOST).udp4(port).build(&sk).unwrap(); + let pubkey = sk.public_key(SECP256K1).serialize(); + let disco = DiscV5::new(test_config(), sk, enr, [0u8; 4]); (disco, addr, pubkey) } @@ -1054,7 +1059,9 @@ mod tests { let votes_before = a.ip_votes.len(); - // A sends Ping; B replies with Pong. + // A sends Ping; B replies with Pong. Register the pending ping so A + // accepts the PONG. + a.pending_pings.insert(1, b.local_id); inject_message(&mut a, &mut b, a_addr, Message::Ping { request_id: 1, enr_seq: 0 }, now); let b_sends = collect_sends(&mut b); for (to, data) in &b_sends { @@ -1161,10 +1168,10 @@ mod tests { fn test_pong_ipv6_vote_updates_enr() { let now = Instant::now(); // A has no IP so every Pong-observed address is "new". - let key = SigningKey::random(&mut rand::thread_rng()); + let sk = SecretKey::new(&mut rand::thread_rng()); let a_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 19041); - let enr = Enr::builder().build(&key).unwrap(); - let mut a = DiscV5::new(test_config(), key, enr, [0u8; 4]); + let enr = Enr::builder().build(&sk).unwrap(); + let mut a = DiscV5::new(test_config(), sk, enr, [0u8; 4]); // Need 3 distinct peers to cross IP_VOTE_THRESHOLD (one vote per NodeId). let (mut b, b_addr, b_pubkey) = make_node(19042); @@ -1177,25 +1184,29 @@ mod tests { let ipv6 = IpAddr::V6(Ipv6Addr::LOCALHOST); let port = 19041u16; + a.pending_pings.insert(100, b.local_id); + a.pending_pings.insert(101, c.local_id); + a.pending_pings.insert(102, d.local_id); + inject_message( &mut b, &mut a, b_addr, - Message::Pong { request_id: 0, enr_seq: 0, ip: ipv6, port }, + Message::Pong { request_id: 100, enr_seq: 0, ip: ipv6, port }, now, ); inject_message( &mut c, &mut a, c_addr, - Message::Pong { request_id: 0, enr_seq: 0, ip: ipv6, port }, + Message::Pong { request_id: 101, enr_seq: 0, ip: ipv6, port }, now, ); inject_message( &mut d, &mut a, d_addr, - Message::Pong { request_id: 0, enr_seq: 0, ip: ipv6, port }, + Message::Pong { request_id: 102, enr_seq: 0, ip: ipv6, port }, now, ); @@ -1212,10 +1223,10 @@ mod tests { fn test_nodes_fork_digest_filter() { let now = Instant::now(); let fork_digest = [0x01, 0x02, 0x03, 0x04u8]; - let key_a = SigningKey::random(&mut rand::thread_rng()); + let sk_a = SecretKey::new(&mut rand::thread_rng()); let a_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 19051); - let enr_a = Enr::builder().ip4(Ipv4Addr::LOCALHOST).udp4(19051u16).build(&key_a).unwrap(); - let mut a = DiscV5::new(test_config(), key_a, enr_a, fork_digest); + let enr_a = Enr::builder().ip4(Ipv4Addr::LOCALHOST).udp4(19051u16).build(&sk_a).unwrap(); + let mut a = DiscV5::new(test_config(), sk_a, enr_a, fork_digest); let (mut b, b_addr, b_pubkey) = make_node(19052); do_handshake(&mut a, a_addr, &mut b, b_addr, b_pubkey, now); @@ -1223,16 +1234,16 @@ mod tests { // Build two ENRs: // - good: no eth2 field (passes the filter) // - bad: eth2 with wrong fork_digest (dropped) - let key_good = SigningKey::random(&mut rand::thread_rng()); + let sk_good = SecretKey::new(&mut rand::thread_rng()); let enr_good = - Enr::builder().ip4(Ipv4Addr::new(10, 0, 0, 1)).udp4(19053u16).build(&key_good).unwrap(); + Enr::builder().ip4(Ipv4Addr::new(10, 0, 0, 1)).udp4(19053u16).build(&sk_good).unwrap(); let id_good = enr_good.node_id(); - let key_bad = SigningKey::random(&mut rand::thread_rng()); + let sk_bad = SecretKey::new(&mut rand::thread_rng()); let mut enr_bad = - Enr::builder().ip4(Ipv4Addr::new(10, 0, 0, 2)).udp4(19054u16).build(&key_bad).unwrap(); + Enr::builder().ip4(Ipv4Addr::new(10, 0, 0, 2)).udp4(19054u16).build(&sk_bad).unwrap(); // Wrong fork digest: all zeros. - enr_bad.set_eth2([0u8; 16], &key_bad).unwrap(); + enr_bad.set_eth2([0u8; 16], &sk_bad).unwrap(); let id_bad = enr_bad.node_id(); let mut good_raw: ArrayVec = ArrayVec::new(); @@ -1275,21 +1286,20 @@ mod tests { // to push each record to ~180 bytes, forcing multi-packet responses. let mut peer_ids: Vec = Vec::new(); for i in 0u16..8 { - let pk = SigningKey::random(&mut rand::thread_rng()); + let sk = SecretKey::new(&mut rand::thread_rng()); let port = 20000 + i; let mut enr = Enr::builder() .ip4(Ipv4Addr::new(10, 0, 0, (i + 1) as u8)) .udp4(port) - .build(&pk) + .build(&sk) .unwrap(); // fork_digest [0;4] matches B's fork_digest from make_node. - enr.set_eth2([0u8; 16], &pk).unwrap(); - enr.set_attnets([0xFF; 8], &pk).unwrap(); - enr.set_syncnets(0x0F, &pk).unwrap(); + enr.set_eth2([0u8; 16], &sk).unwrap(); + enr.set_attnets([0xFF; 8], &sk).unwrap(); + enr.set_syncnets(0x0F, &sk).unwrap(); let p_id = enr.node_id(); let p_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, (i + 1) as u8)), port); - let p_pubkey: [u8; 33] = - pk.verifying_key().to_encoded_point(true).as_bytes().try_into().unwrap(); + let p_pubkey = sk.public_key(SECP256K1).serialize(); let mut raw: ArrayVec = ArrayVec::new(); enr.encode(&mut raw); let _ = a.kbuckets.insert_or_update( diff --git a/crates/discovery/src/message.rs b/crates/discovery/src/message.rs index fb566c6..9591e80 100644 --- a/crates/discovery/src/message.rs +++ b/crates/discovery/src/message.rs @@ -426,20 +426,17 @@ mod tests { hex::decode(s).unwrap() } + fn node_id_from_hex(hex_sk: &str) -> NodeId { + let sk = secp256k1::SecretKey::from_slice(&hex_decode(hex_sk)).unwrap(); + NodeId::from(sk.public_key(secp256k1::SECP256K1)) + } + fn node_id_1() -> NodeId { - let sk = k256::ecdsa::SigningKey::from_slice(&hex_decode( - "eef77acb6c6a6eebc5b363a475ac583ec7eccdb42b6481424c60f59aa326547f", - )) - .unwrap(); - NodeId::from(*sk.verifying_key()) + node_id_from_hex("eef77acb6c6a6eebc5b363a475ac583ec7eccdb42b6481424c60f59aa326547f") } fn node_id_2() -> NodeId { - let sk = k256::ecdsa::SigningKey::from_slice(&hex_decode( - "66fb62bfbd66b9177a138c1e5cddbe4f7c30c343e94e68df8769459cb1cde628", - )) - .unwrap(); - NodeId::from(*sk.verifying_key()) + node_id_from_hex("66fb62bfbd66b9177a138c1e5cddbe4f7c30c343e94e68df8769459cb1cde628") } #[test] @@ -798,11 +795,11 @@ mod tests { #[test] fn test_nodes_encode_decode_enr_bytes() { - let key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); + let sk = secp256k1::SecretKey::new(&mut rand::thread_rng()); let enr = silver_common::Enr::builder() .ip4(std::net::Ipv4Addr::LOCALHOST) .udp4(9000u16) - .build(&key) + .build(&sk) .unwrap(); let enr_bytes = alloy_rlp::encode(&enr); let mut enr_raw: ArrayVec = ArrayVec::new(); diff --git a/crates/discovery/tests/e2e.rs b/crates/discovery/tests/e2e.rs index f5e9afe..9217891 100644 --- a/crates/discovery/tests/e2e.rs +++ b/crates/discovery/tests/e2e.rs @@ -4,7 +4,7 @@ use std::{ }; use discovery::{DiscV5, Discovery, DiscoveryConfig, DiscoveryEvent, DiscoveryNetworking}; -use k256::ecdsa::SigningKey; +use secp256k1::{SECP256K1, SecretKey}; use silver_common::{Enr, NodeId}; struct TestNode { @@ -26,17 +26,16 @@ impl TestNode { } fn build(port: u16, config: DiscoveryConfig, with_ip: bool) -> Self { - let key = SigningKey::random(&mut rand::thread_rng()); + let sk = SecretKey::new(&mut rand::thread_rng()); let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); let enr = if with_ip { - Enr::builder().ip4(Ipv4Addr::LOCALHOST).udp4(port).build(&key).unwrap() + Enr::builder().ip4(Ipv4Addr::LOCALHOST).udp4(port).build(&sk).unwrap() } else { - Enr::builder().build(&key).unwrap() + Enr::builder().build(&sk).unwrap() }; let enr_seq = enr.seq(); - let pubkey: [u8; 33] = - key.verifying_key().to_encoded_point(true).as_bytes().try_into().unwrap(); - Self { disco: DiscV5::new(config, key, enr, [0u8; 4]), addr, pubkey, enr_seq } + let pubkey = sk.public_key(SECP256K1).serialize(); + Self { disco: DiscV5::new(config, sk, enr, [0u8; 4]), addr, pubkey, enr_seq } } fn node_id(&self) -> NodeId { diff --git a/crates/network/Cargo.toml b/crates/network/Cargo.toml index d93c772..3621860 100644 --- a/crates/network/Cargo.toml +++ b/crates/network/Cargo.toml @@ -9,12 +9,12 @@ version.workspace = true buffa.workspace = true bytes.workspace = true flux.workspace = true -k256.workspace = true libc.workspace = true mio.workspace = true quinn-proto.workspace = true rcgen.workspace = true ring.workspace = true +secp256k1.workspace = true rustls.workspace = true silver-common.workspace = true tracing.workspace = true diff --git a/crates/network/benches/quic_basic.rs b/crates/network/benches/quic_basic.rs index df3439d..2214d71 100644 --- a/crates/network/benches/quic_basic.rs +++ b/crates/network/benches/quic_basic.rs @@ -39,8 +39,8 @@ pub fn broadcast(c: &mut Criterion) { let recv_counter = Arc::new(AtomicUsize::default()); let (mut server_tile, server_id) = { - let secret = k256::ecdsa::SigningKey::random(&mut rng); - let key_bytes: [u8; 32] = secret.to_bytes().into(); + let secret = secp256k1::SecretKey::new(&mut rng); + let key_bytes: [u8; 32] = secret.secret_bytes(); let keypair = Keypair::from_secret(&key_bytes).unwrap(); let server_id = keypair.peer_id(); let server_config = @@ -77,8 +77,8 @@ pub fn broadcast(c: &mut Criterion) { for n in 0..i { let data = data.clone(); - let secret = k256::ecdsa::SigningKey::random(&mut rng); - let key_bytes: [u8; 32] = secret.to_bytes().into(); + let secret = secp256k1::SecretKey::new(&mut rng); + let key_bytes: [u8; 32] = secret.secret_bytes(); let keypair = Keypair::from_secret(&key_bytes).unwrap(); let client_endpoint = Endpoint::new( Arc::new(EndpointConfig::default()), diff --git a/crates/network/src/p2p/tls/certificate.rs b/crates/network/src/p2p/tls/certificate.rs index 82d7ba5..3b19123 100644 --- a/crates/network/src/p2p/tls/certificate.rs +++ b/crates/network/src/p2p/tls/certificate.rs @@ -273,10 +273,13 @@ fn verify_host_key_signature( ) -> Result<(), Error> { match key_type { KEY_TYPE_SECP256K1 => { - use k256::ecdsa::{DerSignature, VerifyingKey, signature::Verifier}; - let vk = VerifyingKey::from_sec1_bytes(public_key).map_err(|_| Error::BadPublicKey)?; - let sig = DerSignature::try_from(signature).map_err(|_| Error::BadSignature)?; - vk.verify(message, &sig).map_err(|_| Error::BadSignature) + let pk = + secp256k1::PublicKey::from_slice(public_key).map_err(|_| Error::BadPublicKey)?; + let sig = secp256k1::ecdsa::Signature::from_der(signature) + .map_err(|_| Error::BadSignature)?; + let msg = + secp256k1::Message::from_digest_slice(message).map_err(|_| Error::BadSignature)?; + secp256k1::SECP256K1.verify_ecdsa(&msg, &sig, &pk).map_err(|_| Error::BadSignature) } _ => Err(Error::UnsupportedKeyType), }