diff --git a/Cargo.lock b/Cargo.lock index 069406465fc..e4571ebb1b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4901,6 +4901,7 @@ dependencies = [ "nym-mixnet-contract-common 0.6.0", "nym-network-defaults 0.1.0", "nym-node-requests 0.1.0", + "nym-noise-keys", "nym-serde-helpers 0.1.0", "nym-ticketbooks-merkle 0.1.0", "rand_chacha 0.3.1", @@ -6243,8 +6244,11 @@ version = "0.1.0" dependencies = [ "dashmap", "futures", + "nym-crypto 0.4.0", + "nym-noise", "nym-sphinx", "nym-task", + "rand 0.8.5", "tokio", "tokio-stream", "tokio-util", @@ -6510,6 +6514,8 @@ dependencies = [ "nym-network-requester", "nym-node-metrics", "nym-node-requests 0.1.0", + "nym-noise", + "nym-noise-keys", "nym-nonexhaustive-delayqueue", "nym-pemstore 0.3.0", "nym-sphinx-acknowledgements", @@ -6574,6 +6580,7 @@ dependencies = [ "nym-crypto 0.4.0", "nym-exit-policy 0.1.0", "nym-http-api-client 0.1.0", + "nym-noise-keys", "nym-wireguard-types 0.1.0", "rand_chacha 0.3.1", "schemars", @@ -6729,6 +6736,37 @@ dependencies = [ "wasmtimer", ] +[[package]] +name = "nym-noise" +version = "0.1.0" +dependencies = [ + "anyhow", + "arc-swap", + "bytes", + "futures", + "nym-crypto 0.4.0", + "nym-noise-keys", + "pin-project", + "rand_chacha 0.3.1", + "sha2 0.10.9", + "snow", + "strum 0.26.3", + "thiserror 2.0.12", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "nym-noise-keys" +version = "0.1.0" +dependencies = [ + "nym-crypto 0.4.0", + "schemars", + "serde", + "utoipa", +] + [[package]] name = "nym-nonexhaustive-delayqueue" version = "0.1.0" @@ -9761,6 +9799,22 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "snow" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "850948bee068e713b8ab860fe1adc4d109676ab4c3b621fd8147f06b261f2f85" +dependencies = [ + "aes-gcm", + "blake2 0.10.6", + "chacha20poly1305", + "curve25519-dalek", + "rand_core 0.6.4", + "rustc_version 0.4.1", + "sha2 0.10.9", + "subtle 2.6.1", +] + [[package]] name = "socket2" version = "0.5.9" diff --git a/Cargo.toml b/Cargo.toml index 40b56b0a67b..0bfecce05a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,6 +66,8 @@ members = [ "common/nym-id", "common/nym-metrics", "common/nym_offline_compact_ecash", + "common/nymnoise", + "common/nymnoise/keys", "common/nymsphinx", "common/nymsphinx/acknowledgements", "common/nymsphinx/addressing", @@ -310,6 +312,7 @@ serde_with = "3.9.0" serde_yaml = "0.9.25" sha2 = "0.10.9" si-scale = "0.2.3" +snow = "0.9.6" sphinx-packet = "=0.6.0" sqlx = "0.8.6" strum = "0.26" diff --git a/common/authenticator-requests/src/v1/registration.rs b/common/authenticator-requests/src/v1/registration.rs index da44cf97bf4..03cfc30c228 100644 --- a/common/authenticator-requests/src/v1/registration.rs +++ b/common/authenticator-requests/src/v1/registration.rs @@ -108,7 +108,7 @@ impl GatewayClient { #[cfg(feature = "verify")] pub fn verify(&self, gateway_key: &PrivateKey, nonce: u64) -> Result<(), Error> { // use gateways key as a ref to an x25519_dalek key - let dh = (gateway_key.as_ref()).diffie_hellman(&self.pub_key); + let dh = gateway_key.inner().diffie_hellman(&self.pub_key); // TODO: change that to use our nym_crypto::hmac module instead #[allow(clippy::expect_used)] diff --git a/common/authenticator-requests/src/v2/registration.rs b/common/authenticator-requests/src/v2/registration.rs index cdc9d2180c9..436fde5346a 100644 --- a/common/authenticator-requests/src/v2/registration.rs +++ b/common/authenticator-requests/src/v2/registration.rs @@ -117,7 +117,7 @@ impl GatewayClient { #[cfg(feature = "verify")] pub fn verify(&self, gateway_key: &PrivateKey, nonce: u64) -> Result<(), Error> { // use gateways key as a ref to an x25519_dalek key - let dh = (gateway_key.as_ref()).diffie_hellman(&self.pub_key); + let dh = gateway_key.inner().diffie_hellman(&self.pub_key); // TODO: change that to use our nym_crypto::hmac module instead #[allow(clippy::expect_used)] diff --git a/common/authenticator-requests/src/v3/registration.rs b/common/authenticator-requests/src/v3/registration.rs index 8d51a970f16..21a50882ca9 100644 --- a/common/authenticator-requests/src/v3/registration.rs +++ b/common/authenticator-requests/src/v3/registration.rs @@ -117,7 +117,7 @@ impl GatewayClient { #[cfg(feature = "verify")] pub fn verify(&self, gateway_key: &PrivateKey, nonce: u64) -> Result<(), Error> { // use gateways key as a ref to an x25519_dalek key - let dh = (gateway_key.as_ref()).diffie_hellman(&self.pub_key); + let dh = gateway_key.inner().diffie_hellman(&self.pub_key); // TODO: change that to use our nym_crypto::hmac module instead #[allow(clippy::expect_used)] diff --git a/common/authenticator-requests/src/v4/registration.rs b/common/authenticator-requests/src/v4/registration.rs index 09359a1a68f..28aadf9c6de 100644 --- a/common/authenticator-requests/src/v4/registration.rs +++ b/common/authenticator-requests/src/v4/registration.rs @@ -169,7 +169,7 @@ impl GatewayClient { #[cfg(feature = "verify")] pub fn verify(&self, gateway_key: &PrivateKey, nonce: u64) -> Result<(), Error> { // use gateways key as a ref to an x25519_dalek key - let dh = (gateway_key.as_ref()).diffie_hellman(&self.pub_key); + let dh = gateway_key.inner().diffie_hellman(&self.pub_key); // TODO: change that to use our nym_crypto::hmac module instead #[allow(clippy::expect_used)] diff --git a/common/authenticator-requests/src/v5/registration.rs b/common/authenticator-requests/src/v5/registration.rs index 09359a1a68f..28aadf9c6de 100644 --- a/common/authenticator-requests/src/v5/registration.rs +++ b/common/authenticator-requests/src/v5/registration.rs @@ -169,7 +169,7 @@ impl GatewayClient { #[cfg(feature = "verify")] pub fn verify(&self, gateway_key: &PrivateKey, nonce: u64) -> Result<(), Error> { // use gateways key as a ref to an x25519_dalek key - let dh = (gateway_key.as_ref()).diffie_hellman(&self.pub_key); + let dh = gateway_key.inner().diffie_hellman(&self.pub_key); // TODO: change that to use our nym_crypto::hmac module instead #[allow(clippy::expect_used)] diff --git a/common/client-core/Cargo.toml b/common/client-core/Cargo.toml index b9efc9f46f8..5d4f0bbfb7f 100644 --- a/common/client-core/Cargo.toml +++ b/common/client-core/Cargo.toml @@ -44,7 +44,6 @@ nym-sphinx = { path = "../nymsphinx" } nym-statistics-common = { path = "../statistics" } nym-pemstore = { path = "../pemstore" } nym-topology = { path = "../topology", features = ["persistence"] } -nym-mixnet-client = { path = "../client-libs/mixnet-client", default-features = false } nym-validator-client = { path = "../client-libs/validator-client", default-features = false } nym-task = { path = "../task" } nym-credentials-interface = { path = "../credentials-interface" } @@ -57,6 +56,9 @@ nym-client-core-surb-storage = { path = "./surb-storage" } nym-client-core-gateways-storage = { path = "./gateways-storage" } nym-ecash-time = { path = "../ecash-time" } +[target."cfg(not(target_arch = \"wasm32\"))".dependencies] +nym-mixnet-client = { path = "../client-libs/mixnet-client", default-features = false } + ### For serving prometheus metrics [target."cfg(not(target_arch = \"wasm32\"))".dependencies.hyper] workspace = true diff --git a/common/client-libs/mixnet-client/Cargo.toml b/common/client-libs/mixnet-client/Cargo.toml index b240aab5131..2a8a39383b6 100644 --- a/common/client-libs/mixnet-client/Cargo.toml +++ b/common/client-libs/mixnet-client/Cargo.toml @@ -16,9 +16,14 @@ tokio-util = { workspace = true, features = ["codec"], optional = true } tokio-stream = { workspace = true } # internal +nym-noise = { path = "../../nymnoise" } nym-sphinx = { path = "../../nymsphinx" } nym-task = { path = "../../task", optional = true } [features] default = ["client"] -client = ["tokio-util", "nym-task", "tokio/net", "tokio/rt"] \ No newline at end of file +client = ["tokio-util", "nym-task", "tokio/net", "tokio/rt"] + +[dev-dependencies] +nym-crypto = { path = "../../crypto" } +rand = { workspace = true } diff --git a/common/client-libs/mixnet-client/src/client.rs b/common/client-libs/mixnet-client/src/client.rs index 1582c15bdaa..779acaa0297 100644 --- a/common/client-libs/mixnet-client/src/client.rs +++ b/common/client-libs/mixnet-client/src/client.rs @@ -3,6 +3,8 @@ use dashmap::DashMap; use futures::StreamExt; +use nym_noise::config::NoiseConfig; +use nym_noise::upgrade_noise_initiator; use nym_sphinx::forwarding::packet::MixPacket; use nym_sphinx::framing::codec::NymCodec; use nym_sphinx::framing::packet::FramedNymPacket; @@ -52,6 +54,7 @@ pub trait SendWithoutResponse { pub struct Client { active_connections: ActiveConnections, + noise_config: NoiseConfig, connections_count: Arc, config: Config, } @@ -97,6 +100,7 @@ impl ConnectionSender { struct ManagedConnection { address: SocketAddr, + noise_config: NoiseConfig, message_receiver: ReceiverStream, connection_timeout: Duration, current_reconnection: Arc, @@ -105,12 +109,14 @@ struct ManagedConnection { impl ManagedConnection { fn new( address: SocketAddr, + noise_config: NoiseConfig, message_receiver: mpsc::Receiver, connection_timeout: Duration, current_reconnection: Arc, ) -> Self { ManagedConnection { address, + noise_config, message_receiver: ReceiverStream::new(message_receiver), connection_timeout, current_reconnection, @@ -125,9 +131,21 @@ impl ManagedConnection { Ok(stream_res) => match stream_res { Ok(stream) => { debug!("Managed to establish connection to {}", self.address); - // if we managed to connect, reset the reconnection count (whatever it might have been) + + let noise_stream = + match upgrade_noise_initiator(stream, &self.noise_config).await { + Ok(noise_stream) => noise_stream, + Err(err) => { + error!("Failed to perform Noise handshake with {address} - {err}"); + // we failed to finish the noise handshake - increase reconnection attempt + self.current_reconnection.fetch_add(1, Ordering::SeqCst); + return; + } + }; + // if we managed to connect AND do the noise handshake, reset the reconnection count (whatever it might have been) self.current_reconnection.store(0, Ordering::Release); - Framed::new(stream, NymCodec) + debug!("Noise initiator handshake completed for {:?}", address); + Framed::new(noise_stream, NymCodec) } Err(err) => { debug!("failed to establish connection to {address} (err: {err})",); @@ -160,9 +178,14 @@ impl ManagedConnection { } impl Client { - pub fn new(config: Config, connections_count: Arc) -> Client { + pub fn new( + config: Config, + noise_config: NoiseConfig, + connections_count: Arc, + ) -> Client { Client { active_connections: Default::default(), + noise_config, connections_count, config, } @@ -217,6 +240,7 @@ impl Client { let initial_connection_timeout = self.config.initial_connection_timeout; let connections_count = self.connections_count.clone(); + let noise_config = self.noise_config.clone(); tokio::spawn(async move { // before executing the manager, wait for what was specified, if anything if let Some(backoff) = backoff { @@ -227,6 +251,7 @@ impl Client { connections_count.fetch_add(1, Ordering::SeqCst); ManagedConnection::new( address, + noise_config, receiver, initial_connection_timeout, current_reconnection_attempt, @@ -291,8 +316,12 @@ impl SendWithoutResponse for Client { #[cfg(test)] mod tests { use super::*; + use nym_crypto::asymmetric::x25519; + use nym_noise::config::NoiseNetworkView; + use rand::rngs::OsRng; fn dummy_client() -> Client { + let mut rng = OsRng; //for test only, so we don't care if rng source isn't crypto grade Client::new( Config { initial_reconnection_backoff: Duration::from_millis(10_000), @@ -300,6 +329,11 @@ mod tests { initial_connection_timeout: Duration::from_millis(1_500), maximum_connection_buffer_size: 128, }, + NoiseConfig::new( + Arc::new(x25519::KeyPair::new(&mut rng)), + NoiseNetworkView::new_empty(), + Duration::from_millis(1_500), + ), Default::default(), ) } diff --git a/common/client-libs/validator-client/src/client.rs b/common/client-libs/validator-client/src/client.rs index 32cb080d854..363296f15de 100644 --- a/common/client-libs/validator-client/src/client.rs +++ b/common/client-libs/validator-client/src/client.rs @@ -26,7 +26,7 @@ use nym_api_requests::models::{ }; use nym_api_requests::models::{LegacyDescribedGateway, MixNodeBondAnnotated}; use nym_api_requests::nym_nodes::{ - NodesByAddressesResponse, SkimmedNode, SkimmedNodesWithMetadata, + NodesByAddressesResponse, SemiSkimmedNodesWithMetadata, SkimmedNode, SkimmedNodesWithMetadata, }; use nym_coconut_dkg_common::types::EpochId; use nym_http_api_client::UserAgent; @@ -530,6 +530,43 @@ impl NymApiClient { collect_paged_skimmed_v2!(self, get_basic_nodes_v2) } + /// retrieve expanded information for all bonded nodes on the network + pub async fn get_all_expanded_nodes( + &self, + ) -> Result { + // Unroll the first iteration to get the metadata + let mut page = 0; + + let res = self + .nym_api + .get_expanded_nodes(false, Some(page), None) + .await?; + let mut nodes = res.nodes.data; + let metadata = res.metadata; + + if res.nodes.pagination.total == nodes.len() { + return Ok(SemiSkimmedNodesWithMetadata::new(nodes, metadata)); + } + + page += 1; + + loop { + let mut res = self + .nym_api + .get_expanded_nodes(false, Some(page), None) + .await?; + + nodes.append(&mut res.nodes.data); + if nodes.len() < res.nodes.pagination.total { + page += 1 + } else { + break; + } + } + + Ok(SemiSkimmedNodesWithMetadata::new(nodes, metadata)) + } + pub async fn health(&self) -> Result { Ok(self.nym_api.health().await?) } diff --git a/common/client-libs/validator-client/src/nym_api/mod.rs b/common/client-libs/validator-client/src/nym_api/mod.rs index 846712b5def..8cdaadf7632 100644 --- a/common/client-libs/validator-client/src/nym_api/mod.rs +++ b/common/client-libs/validator-client/src/nym_api/mod.rs @@ -35,7 +35,7 @@ pub use nym_api_requests::{ MixnodeStatusResponse, MixnodeUptimeHistoryResponse, RewardEstimationResponse, StakeSaturationResponse, UptimeResponse, }, - nym_nodes::{CachedNodesResponse, SkimmedNode}, + nym_nodes::{CachedNodesResponse, SemiSkimmedNode, SkimmedNode}, NymNetworkDetailsResponse, }; use nym_contracts_common::IdentityKey; @@ -643,6 +643,39 @@ pub trait NymApiClientExt: ApiClient { .await } + #[instrument(level = "debug", skip(self))] + async fn get_expanded_nodes( + &self, + no_legacy: bool, + page: Option, + per_page: Option, + ) -> Result, NymAPIError> { + let mut params = Vec::new(); + + if no_legacy { + params.push(("no_legacy", "true".to_string())) + } + + if let Some(page) = page { + params.push(("page", page.to_string())) + } + + if let Some(per_page) = per_page { + params.push(("per_page", per_page.to_string())) + } + + self.get_json( + &[ + routes::V2_API_VERSION, + "unstable", + routes::NYM_NODES_ROUTES, + "semi-skimmed", + ], + ¶ms, + ) + .await + } + #[deprecated] #[instrument(level = "debug", skip(self))] async fn get_active_mixnodes(&self) -> Result, NymAPIError> { diff --git a/common/crypto/src/asymmetric/x25519/mod.rs b/common/crypto/src/asymmetric/x25519/mod.rs index 4b580f289b0..9b73adb910c 100644 --- a/common/crypto/src/asymmetric/x25519/mod.rs +++ b/common/crypto/src/asymmetric/x25519/mod.rs @@ -218,6 +218,12 @@ impl From for x25519_dalek::PublicKey { } } +impl AsRef<[u8]> for PublicKey { + fn as_ref(&self) -> &[u8] { + self.0.as_ref() + } +} + #[derive(Zeroize, ZeroizeOnDrop)] pub struct PrivateKey(x25519_dalek::StaticSecret); @@ -248,6 +254,10 @@ impl PrivateKey { PrivateKey(x25519_secret) } + pub fn inner(&self) -> &x25519_dalek::StaticSecret { + &self.0 + } + pub fn public_key(&self) -> PublicKey { self.into() } @@ -256,6 +266,10 @@ impl PrivateKey { self.0.to_bytes() } + pub fn as_bytes(&self) -> &[u8; PRIVATE_KEY_SIZE] { + self.0.as_bytes() + } + pub fn from_bytes(b: &[u8]) -> Result { if b.len() != PRIVATE_KEY_SIZE { return Err(KeyRecoveryError::InvalidSizePrivateKey { @@ -335,6 +349,12 @@ impl AsRef for PrivateKey { } } +impl AsRef<[u8]> for PrivateKey { + fn as_ref(&self) -> &[u8] { + self.0.as_ref() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/common/nymnoise/Cargo.toml b/common/nymnoise/Cargo.toml new file mode 100644 index 00000000000..eead4a60f5e --- /dev/null +++ b/common/nymnoise/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "nym-noise" +version = "0.1.0" +authors = ["Simon Wicky "] +edition = "2021" +license.workspace = true + +[dependencies] +arc-swap = { workspace = true } +bytes = { workspace = true } +futures = { workspace = true } +tracing = { workspace = true } +pin-project = { workspace = true } +sha2 = { workspace = true } +snow = { workspace = true } +strum = { workspace = true, features = ["derive"] } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["net", "io-util", "time"] } +tokio-util = { workspace = true, features = ["codec"] } + +# internal +nym-crypto = { path = "../crypto" } +nym-noise-keys = { path = "keys" } + +[dev-dependencies] +anyhow = { workspace = true } +tokio = { workspace = true, features = ["full"] } +rand_chacha = { workspace = true } +nym-crypto = { path = "../crypto", features = ["rand"] } + + +[lints] +workspace = true diff --git a/common/nymnoise/keys/Cargo.toml b/common/nymnoise/keys/Cargo.toml new file mode 100644 index 00000000000..94080a004b1 --- /dev/null +++ b/common/nymnoise/keys/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "nym-noise-keys" +version = "0.1.0" +authors = ["Simon Wicky "] +edition = "2021" +license.workspace = true + +[dependencies] +schemars = { workspace = true, features = ["preserve_order"] } +serde = { workspace = true, features = ["derive"] } +utoipa = { workspace = true } + +# internal +nym-crypto = { path = "../../crypto", features = ["asymmetric", "serde"] } + +[lints] +workspace = true \ No newline at end of file diff --git a/common/nymnoise/keys/src/lib.rs b/common/nymnoise/keys/src/lib.rs new file mode 100644 index 00000000000..b4cd70148bb --- /dev/null +++ b/common/nymnoise/keys/src/lib.rs @@ -0,0 +1,43 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use nym_crypto::asymmetric::x25519; +use nym_crypto::asymmetric::x25519::serde_helpers::bs58_x25519_pubkey; +use serde::{Deserialize, Serialize}; + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(from = "u8", into = "u8")] +pub enum NoiseVersion { + V1, + Unknown(u8), //Implies a newer version we don't know +} + +impl From for NoiseVersion { + fn from(value: u8) -> Self { + match value { + 1 => NoiseVersion::V1, + other => NoiseVersion::Unknown(other), + } + } +} + +impl From for u8 { + fn from(version: NoiseVersion) -> Self { + match version { + NoiseVersion::V1 => 1, + NoiseVersion::Unknown(other) => other, + } + } +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, utoipa::ToSchema)] +pub struct VersionedNoiseKey { + #[schemars(with = "u8")] + #[schema(value_type = u8)] + pub supported_version: NoiseVersion, + + #[schemars(with = "String")] + #[serde(with = "bs58_x25519_pubkey")] + #[schema(value_type = String)] + pub x25519_pubkey: x25519::PublicKey, +} diff --git a/common/nymnoise/src/config.rs b/common/nymnoise/src/config.rs new file mode 100644 index 00000000000..94031b2cf6f --- /dev/null +++ b/common/nymnoise/src/config.rs @@ -0,0 +1,171 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use std::{ + collections::HashMap, + net::{IpAddr, SocketAddr}, + sync::Arc, + time::Duration, +}; + +use arc_swap::ArcSwap; +use nym_crypto::asymmetric::x25519; +use nym_noise_keys::{NoiseVersion, VersionedNoiseKey}; +use snow::params::NoiseParams; + +use strum::{EnumIter, FromRepr}; + +#[derive(Default, Debug, Clone, Copy, EnumIter, FromRepr, Eq, PartialEq)] +#[repr(u8)] +#[non_exhaustive] +pub enum NoisePattern { + #[default] + XKpsk3 = 1, + IKpsk2 = 2, +} + +impl NoisePattern { + pub(crate) const fn as_str(&self) -> &'static str { + match self { + Self::XKpsk3 => "Noise_XKpsk3_25519_AESGCM_SHA256", + Self::IKpsk2 => "Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s", //Wireguard handshake (not exactly though) + } + } + + // SAFETY: we have tests to ensure that hardcoded pattern are correct + #[allow(clippy::unwrap_used)] + pub(crate) fn psk_position(&self) -> u8 { + //automatic parsing, works for correct pattern, more convenient + match self.as_str().find("psk") { + Some(n) => { + let psk_index = n + 3; + let psk_char = self.as_str().chars().nth(psk_index).unwrap(); + psk_char.to_string().parse().unwrap() + } + None => 0, + } + } + + // SAFETY : we have tests to ensure that hardcoded pattern are correct + #[allow(clippy::unwrap_used)] + pub(crate) fn as_noise_params(&self) -> NoiseParams { + self.as_str().parse().unwrap() + } +} + +#[derive(Debug, Default)] +struct SocketAddrToKey { + inner: ArcSwap>, +} + +// SW NOTE : Only for phased upgrade. To remove once we decide all nodes have to support Noise +#[derive(Debug, Default)] +struct IpAddrToVersion { + inner: ArcSwap>, +} + +#[derive(Debug, Clone, Default)] +pub struct NoiseNetworkView { + keys: Arc, + support: Arc, +} + +impl NoiseNetworkView { + pub fn new_empty() -> Self { + NoiseNetworkView { + keys: Default::default(), + support: Default::default(), + } + } + + pub fn swap_view(&self, new: HashMap) { + let noise_support = new + .iter() + .map(|(s_addr, key)| (s_addr.ip(), key.supported_version)) + .collect::>(); + self.keys.inner.store(Arc::new(new)); + self.support.inner.store(Arc::new(noise_support)); + } +} + +#[derive(Clone)] +pub struct NoiseConfig { + network: NoiseNetworkView, + + pub(crate) local_key: Arc, + pub(crate) pattern: NoisePattern, + pub(crate) timeout: Duration, + + pub(crate) unsafe_disabled: bool, // allows for nodes to not attempt to do a noise handshake, VERY UNSAFE, FOR DEBUG PURPOSE ONLY +} + +impl NoiseConfig { + pub fn new( + noise_key: Arc, + network: NoiseNetworkView, + timeout: Duration, + ) -> Self { + NoiseConfig { + network, + local_key: noise_key, + pattern: Default::default(), + timeout, + unsafe_disabled: false, + } + } + + #[must_use] + pub fn with_noise_pattern(mut self, pattern: NoisePattern) -> Self { + self.pattern = pattern; + self + } + + #[must_use] + pub fn with_unsafe_disabled(mut self, disabled: bool) -> Self { + self.unsafe_disabled = disabled; + self + } + + pub(crate) fn get_noise_key(&self, s_address: &SocketAddr) -> Option { + self.network.keys.inner.load().get(s_address).copied() + } + + // Only for phased update + //SW This can lead to some troubles if two nodes shares the same IP and one support Noise but not the other. This in only for the progressive update though and there is no workaround + pub(crate) fn get_noise_support(&self, ip_addr: IpAddr) -> Option { + let plain_ip_support = self.network.support.inner.load().get(&ip_addr).copied(); + + // SW default bind address being [::]:1789, it can happen that a responder sees the ipv6-mapped address of the initiator, this check for that + let canonical_ip = &ip_addr.to_canonical(); + let canonical_ip_support = self.network.support.inner.load().get(canonical_ip).copied(); + plain_ip_support.or(canonical_ip_support) + } +} + +#[cfg(test)] +mod tests { + use snow::params::NoiseParams; + + use super::NoisePattern; + use std::str::FromStr; + use strum::IntoEnumIterator; + + // The goal of these is to make sure every NoisePatterns are correct and unwrap can be used on them + + #[test] + fn noise_patterns_are_valid() { + for pattern in NoisePattern::iter() { + assert!(NoiseParams::from_str(pattern.as_str()).is_ok()) + } + } + + #[test] + fn noise_patterns_psk_position_is_valid() { + for pattern in NoisePattern::iter() { + match pattern { + NoisePattern::XKpsk3 => assert_eq!(pattern.psk_position(), 3), + NoisePattern::IKpsk2 => assert_eq!(pattern.psk_position(), 2), + } + } + } +} diff --git a/common/nymnoise/src/connection.rs b/common/nymnoise/src/connection.rs new file mode 100644 index 00000000000..81daa9bc986 --- /dev/null +++ b/common/nymnoise/src/connection.rs @@ -0,0 +1,67 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use std::io; + +use pin_project::pin_project; +use tokio::io::{AsyncRead, AsyncWrite}; + +use crate::stream::NoiseStream; + +//SW once plain TCP support is dropped, this whole enum can be dropped, and we can only propagate NoiseStream +#[pin_project(project = ConnectionProj)] +pub enum Connection { + Raw(#[pin] C), + Noise(#[pin] Box>), +} + +impl AsyncRead for Connection +where + C: AsyncRead + AsyncWrite + Unpin, +{ + fn poll_read( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + match self.project() { + ConnectionProj::Noise(stream) => stream.poll_read(cx, buf), + ConnectionProj::Raw(stream) => stream.poll_read(cx, buf), + } + } +} + +impl AsyncWrite for Connection +where + C: AsyncWrite + AsyncRead + Unpin, +{ + fn poll_write( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + match self.project() { + ConnectionProj::Noise(stream) => stream.poll_write(cx, buf), + ConnectionProj::Raw(stream) => stream.poll_write(cx, buf), + } + } + fn poll_flush( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + match self.project() { + ConnectionProj::Noise(stream) => stream.poll_flush(cx), + ConnectionProj::Raw(stream) => stream.poll_flush(cx), + } + } + + fn poll_shutdown( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + match self.project() { + ConnectionProj::Noise(stream) => stream.poll_shutdown(cx), + ConnectionProj::Raw(stream) => stream.poll_shutdown(cx), + } + } +} diff --git a/common/nymnoise/src/error.rs b/common/nymnoise/src/error.rs new file mode 100644 index 00000000000..675461e983c --- /dev/null +++ b/common/nymnoise/src/error.rs @@ -0,0 +1,91 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use nym_noise_keys::NoiseVersion; +use snow::Error; +use std::io; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum NoiseError { + #[error("encountered a Noise decryption error")] + DecryptionError, + + #[error("encountered a Noise Protocol error: {0}")] + ProtocolError(Error), + + #[error("encountered an IO error: {0}")] + IoError(#[from] io::Error), + + #[error("Incorrect state")] + IncorrectStateError, + + #[error("Handshake did not complete")] + HandshakeError, + + #[error("unknown noise version (encoded value: {encoded})")] + UnknownVersion { encoded: u8 }, + + #[error("unknown noise pattern (encoded value: {encoded})")] + UnknownPattern { encoded: u8 }, + + #[error("unknown noise message type (encoded value: {encoded})")] + UnknownMessageType { encoded: u8 }, + + #[error("failed to generate psk for requested version {noise_version}")] + PskGenerationFailure { noise_version: u8 }, + + #[error("noise initiator attempted to use version v{noise_version} of the protocol - we don't know how to handle it")] + UnknownVersionHandshake { noise_version: u8 }, + + #[error("noise initiator attempted to use an unexpected noise pattern. we're configured for {configured} while it requested {received}")] + UnexpectedNoisePattern { + configured: &'static str, + received: &'static str, + }, + + #[error("handshake version has unexpectedly changed. initial was {initial:?} and received {received:?}")] + UnexpectedHandshakeVersion { + initial: NoiseVersion, + received: NoiseVersion, + }, + + #[error("data packet version has unexpectedly changed. initial was {initial:?} and received {received:?}")] + UnexpectedDataVersion { + initial: NoiseVersion, + received: NoiseVersion, + }, + + #[error("received a non-handshake message during noise handshake")] + NonHandshakeMessageReceived, + + #[error("received a non-data message post noise handshake")] + NonDataMessageReceived, + + #[error("handshake message exceeded maximum size (got {size} bytes)")] + HandshakeTooBig { size: usize }, + + #[error("noise message exceeded maximum size (got {size} bytes)")] + DataTooBig { size: usize }, + + #[error("Handshake timeout")] + HandshakeTimeout(#[from] tokio::time::error::Elapsed), +} + +impl NoiseError { + pub(crate) fn naive_to_io_error(self) -> std::io::Error { + match self { + NoiseError::IoError(err) => err, + other => std::io::Error::other(other), + } + } +} + +impl From for NoiseError { + fn from(err: Error) -> Self { + match err { + Error::Decrypt => NoiseError::DecryptionError, + err => NoiseError::ProtocolError(err), + } + } +} diff --git a/common/nymnoise/src/lib.rs b/common/nymnoise/src/lib.rs new file mode 100644 index 00000000000..5d3ff953cb9 --- /dev/null +++ b/common/nymnoise/src/lib.rs @@ -0,0 +1,118 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use nym_noise_keys::NoiseVersion; +use snow::error::Prerequisite; +use snow::Error; +use tokio::net::TcpStream; +use tracing::{error, warn}; + +pub mod config; +pub mod connection; +pub mod error; +pub mod stream; + +use crate::config::NoiseConfig; +use crate::connection::Connection; +use crate::error::NoiseError; +use crate::stream::NoiseStreamBuilder; + +const NOISE_PSK_PREFIX: &[u8] = b"NYMTECH_NOISE_dQw4w9WgXcQ"; + +pub const LATEST_NOISE_VERSION: NoiseVersion = NoiseVersion::V1; + +// TODO: this should be behind some trait because presumably, depending on the version, +// other arguments would be needed +mod psk_gen { + use crate::error::NoiseError; + use crate::stream::Psk; + use crate::NOISE_PSK_PREFIX; + use nym_crypto::asymmetric::x25519; + use nym_noise_keys::NoiseVersion; + use sha2::{Digest, Sha256}; + + pub(crate) fn generate_psk( + responder_pub_key: x25519::PublicKey, + version: NoiseVersion, + ) -> Result { + match version { + NoiseVersion::V1 => Ok(generate_psk_v1(responder_pub_key)), + NoiseVersion::Unknown(noise_version) => { + Err(NoiseError::PskGenerationFailure { noise_version }) + } + } + } + + fn generate_psk_v1(responder_pub_key: x25519::PublicKey) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(NOISE_PSK_PREFIX); + hasher.update(responder_pub_key.to_bytes()); + hasher.finalize().into() + } +} + +pub async fn upgrade_noise_initiator( + conn: TcpStream, + config: &NoiseConfig, +) -> Result, NoiseError> { + if config.unsafe_disabled { + warn!("Noise is disabled in the config. Not attempting any handshake"); + return Ok(Connection::Raw(conn)); + } + + //Get init material + let responder_addr = conn.peer_addr().map_err(|err| { + error!("Unable to extract peer address from connection - {err}"); + Error::Prereq(Prerequisite::RemotePublicKey) + })?; + + let Some(key) = config.get_noise_key(&responder_addr) else { + warn!("{responder_addr} can't speak Noise yet, falling back to TCP"); + return Ok(Connection::Raw(conn)); + }; + + let handshake_version = match key.supported_version { + NoiseVersion::V1 => NoiseVersion::V1, + + // We're talking to a more recent node, but we can't adapt. Let's try to do our best and if it fails, it fails. + // If that node sees we're older, it will try to adapt too. + NoiseVersion::Unknown(version) => { + warn!("{responder_addr} is announcing an v{version} version of Noise that we don't know how to parse, we will attempt to downgrade to our current highest supported version"); + LATEST_NOISE_VERSION + } + }; + + NoiseStreamBuilder::new(conn) + .perform_initiator_handshake(config, handshake_version, key.x25519_pubkey) + .await + .map(|stream| Connection::Noise(Box::new(stream))) +} +pub async fn upgrade_noise_responder( + conn: TcpStream, + config: &NoiseConfig, +) -> Result, NoiseError> { + if config.unsafe_disabled { + warn!("Noise is disabled in the config. Not attempting any handshake"); + return Ok(Connection::Raw(conn)); + } + + //Get init material + let initiator_addr = match conn.peer_addr() { + Ok(addr) => addr, + Err(err) => { + error!("Unable to extract peer address from connection - {err}"); + return Err(Error::Prereq(Prerequisite::RemotePublicKey).into()); + } + }; + + // if responder doesn't announce noise support, we fallback to tcp + if config.get_noise_support(initiator_addr.ip()).is_none() { + warn!("{initiator_addr} can't speak Noise yet, falling back to TCP",); + return Ok(Connection::Raw(conn)); + }; + + NoiseStreamBuilder::new(conn) + .perform_responder_handshake(config) + .await + .map(|stream| Connection::Noise(Box::new(stream))) +} diff --git a/common/nymnoise/src/stream/codec.rs b/common/nymnoise/src/stream/codec.rs new file mode 100644 index 00000000000..6c075b56a56 --- /dev/null +++ b/common/nymnoise/src/stream/codec.rs @@ -0,0 +1,92 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::error::NoiseError; +use crate::stream::framing::{NymNoiseFrame, NymNoiseHeader}; +use bytes::{BufMut, BytesMut}; +use tokio_util::codec::{Decoder, Encoder}; + +#[derive(Debug, Clone, Copy)] +enum DecodeState { + Header, + Payload(NymNoiseHeader), +} + +pub struct NymNoiseCodec { + state: DecodeState, +} + +impl NymNoiseCodec { + pub fn new() -> Self { + NymNoiseCodec { + state: DecodeState::Header, + } + } + + fn decode_header(&self, src: &mut BytesMut) -> Result, NoiseError> { + if src.len() < NymNoiseHeader::SIZE { + // Not enough data + return Ok(None); + } + + // note: successful call to 'decode' advances the buffer by NymNoiseHeader::SIZE + let Some(header) = NymNoiseHeader::decode(src)? else { + return Ok(None); + }; + + Ok(Some(header)) + } + + fn decode_data(&self, n: usize, src: &mut BytesMut) -> Option { + // At this point, the buffer has already had the required capacity + // reserved. All there is to do is read. + if src.len() < n { + return None; + } + + Some(src.split_to(n)) + } +} + +impl Decoder for NymNoiseCodec { + type Item = NymNoiseFrame; + type Error = NoiseError; + + fn decode(&mut self, src: &mut BytesMut) -> Result, Self::Error> { + let header = match self.state { + DecodeState::Header => match self.decode_header(src)? { + None => return Ok(None), + Some(header) => { + self.state = DecodeState::Payload(header); + header + } + }, + DecodeState::Payload(header) => header, + }; + + let Some(data) = self.decode_data(header.data_len as usize, src) else { + return Ok(None); + }; + + // Update the decode state + self.state = DecodeState::Header; + + // make sure the buffer has enough space to read the next header + src.reserve(NymNoiseHeader::SIZE); + + Ok(Some(NymNoiseFrame { + header, + data: data.freeze(), + })) + } +} + +impl Encoder for NymNoiseCodec { + type Error = NoiseError; + + fn encode(&mut self, frame: NymNoiseFrame, dst: &mut BytesMut) -> Result<(), Self::Error> { + frame.header.encode(dst); + dst.put_slice(frame.data.as_ref()); + Ok(()) + } +} diff --git a/common/nymnoise/src/stream/framing.rs b/common/nymnoise/src/stream/framing.rs new file mode 100644 index 00000000000..c0ebcf09171 --- /dev/null +++ b/common/nymnoise/src/stream/framing.rs @@ -0,0 +1,161 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::config::NoisePattern; +use crate::error::NoiseError; +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use nym_noise_keys::NoiseVersion; +use strum::FromRepr; + +#[derive(Debug)] +pub struct NymNoiseFrame { + pub header: NymNoiseHeader, + pub data: Bytes, +} + +impl NymNoiseFrame { + pub fn new_handshake_frame( + data: Bytes, + version: NoiseVersion, + pattern: NoisePattern, + ) -> Result { + if data.len() > u16::MAX as usize { + return Err(NoiseError::HandshakeTooBig { size: data.len() }); + } + + Ok(NymNoiseFrame { + header: NymNoiseHeader { + version, + noise_pattern: pattern, + message_type: NymNoiseMessageType::Handshake, + data_len: data.len() as u16, + }, + data, + }) + } + + pub fn new_data_frame( + data: Bytes, + version: NoiseVersion, + pattern: NoisePattern, + ) -> Result { + if data.len() > u16::MAX as usize { + return Err(NoiseError::HandshakeTooBig { size: data.len() }); + } + + Ok(NymNoiseFrame { + header: NymNoiseHeader { + version, + noise_pattern: pattern, + message_type: NymNoiseMessageType::Data, + data_len: data.len() as u16, + }, + data, + }) + } + + pub fn version(&self) -> NoiseVersion { + self.header.version + } + + pub fn is_handshake_message(&self) -> bool { + self.header.is_handshake_message() + } + + pub fn is_data_message(&self) -> bool { + self.header.is_data_message() + } + + pub fn noise_pattern(&self) -> NoisePattern { + self.header.noise_pattern + } +} + +#[derive(Debug, Copy, Clone, FromRepr)] +#[repr(u8)] +#[non_exhaustive] +pub enum NymNoiseMessageType { + Handshake = 0, + Data = 1, +} + +#[derive(Debug, Clone, Copy)] +pub struct NymNoiseHeader { + pub version: NoiseVersion, + pub noise_pattern: NoisePattern, + pub message_type: NymNoiseMessageType, + pub data_len: u16, +} + +impl NymNoiseHeader { + pub(crate) const SIZE: usize = 8; + + pub fn is_handshake_message(&self) -> bool { + matches!(self.message_type, NymNoiseMessageType::Handshake) + } + + pub fn is_data_message(&self) -> bool { + matches!(self.message_type, NymNoiseMessageType::Data) + } + + // 0 1 2 3 4 5 6 7 8 + // +-+-+-+-+-+-+-+-+ + // |V|P|T|Len| Res.| + // +-+-+-+-+-+-+-+-+ + pub(crate) fn encode(&self, dst: &mut BytesMut) { + dst.reserve(Self::SIZE); + + // byte 0 + dst.put_u8(self.version.into()); + + // byte 1 + dst.put_u8(self.noise_pattern as u8); + + // byte 2 + dst.put_u8(self.message_type as u8); + + // byte 3-4 + dst.put_u16(self.data_len); + + // byte 5-7 (RESERVED): + dst.extend_from_slice(&[0u8; 3]) + } + + pub(crate) fn decode(src: &mut BytesMut) -> Result, NoiseError> { + if src.len() < Self::SIZE { + // can't do anything if we don't have enough bytes - but reserve enough for the next call + src.reserve(Self::SIZE); + return Ok(None); + } + + let version = src.get_u8(); + let pattern = src.get_u8(); + let message_type = src.get_u8(); + let data_len = src.get_u16(); + + // reserved + src.advance(3); + + let version = NoiseVersion::from(version); + + // here, based on versions, we could do vary the further parsing + // match version { + // NoiseVersion::V1 => {} + // NoiseVersion::Unknown(_) => {} + // } + + let noise_pattern = NoisePattern::from_repr(pattern) + .ok_or(NoiseError::UnknownPattern { encoded: pattern })?; + let message_type = + NymNoiseMessageType::from_repr(message_type).ok_or(NoiseError::UnknownMessageType { + encoded: message_type, + })?; + + Ok(Some(NymNoiseHeader { + version, + noise_pattern, + message_type, + data_len, + })) + } +} diff --git a/common/nymnoise/src/stream/mod.rs b/common/nymnoise/src/stream/mod.rs new file mode 100644 index 00000000000..048f50157c7 --- /dev/null +++ b/common/nymnoise/src/stream/mod.rs @@ -0,0 +1,583 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::config::{NoiseConfig, NoisePattern}; +use crate::error::NoiseError; +use crate::psk_gen::generate_psk; +use crate::stream::codec::NymNoiseCodec; +use crate::stream::framing::NymNoiseFrame; +use bytes::{Bytes, BytesMut}; +use futures::{Sink, SinkExt, Stream, StreamExt}; +use nym_crypto::asymmetric::x25519; +use nym_noise_keys::NoiseVersion; +use snow::{Builder, HandshakeState, TransportState}; +use std::io; +use std::pin::Pin; +use std::task::Poll; +use std::{cmp::min, task::ready}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio_util::codec::Framed; + +mod codec; +mod framing; + +const TAGLEN: usize = 16; +const HANDSHAKE_MAX_LEN: usize = 1024; // using this constant to limit the handshake's buffer size + +pub(crate) type Psk = [u8; 32]; + +pub(crate) struct NoiseStreamBuilder { + inner_stream: Framed, +} + +impl NoiseStreamBuilder { + pub(crate) fn new(inner_stream: C) -> Self + where + C: AsyncRead + AsyncWrite, + { + NoiseStreamBuilder { + inner_stream: Framed::new(inner_stream, NymNoiseCodec::new()), + } + } + + async fn perform_initiator_handshake_inner( + self, + pattern: NoisePattern, + local_private_key: impl AsRef<[u8]>, + remote_pub_key: impl AsRef<[u8]>, + psk: Psk, + version: NoiseVersion, + ) -> Result, NoiseError> + where + C: AsyncRead + AsyncWrite + Unpin, + { + let handshake = Builder::new(pattern.as_noise_params()) + .local_private_key(local_private_key.as_ref()) + .remote_public_key(remote_pub_key.as_ref()) + .psk(pattern.psk_position(), &psk) + .build_initiator()?; + + self.perform_handshake(handshake, version, pattern).await + } + + pub(crate) async fn perform_initiator_handshake( + self, + config: &NoiseConfig, + version: NoiseVersion, + remote_pub_key: x25519::PublicKey, + ) -> Result, NoiseError> + where + C: AsyncRead + AsyncWrite + Unpin, + { + let psk = generate_psk(remote_pub_key, version)?; + + let timeout = config.timeout; + tokio::time::timeout( + timeout, + self.perform_initiator_handshake_inner( + config.pattern, + config.local_key.private_key(), + remote_pub_key, + psk, + version, + ), + ) + .await? + } + + async fn perform_responder_handshake_inner( + mut self, + noise_pattern: NoisePattern, + local_private_key: impl AsRef<[u8]>, + local_pub_key: x25519::PublicKey, + ) -> Result, NoiseError> + where + C: AsyncRead + AsyncWrite + Unpin, + { + // 1. we read the first message from the initiator to establish noise version and pattern + // and determine if we can continue with the handshake + let initial_frame = self + .inner_stream + .next() + .await + .ok_or(NoiseError::IoError(io::ErrorKind::BrokenPipe.into()))??; + + if !initial_frame.is_handshake_message() { + return Err(NoiseError::NonHandshakeMessageReceived); + } + + let pattern = initial_frame.noise_pattern(); + + // I can imagine we should be able to handle multiple patterns here, but I guess there's a reason a value is set in the config + // but refactoring this shouldn't be too difficult + if pattern != noise_pattern { + return Err(NoiseError::UnexpectedNoisePattern { + configured: noise_pattern.as_str(), + received: pattern.as_str(), + }); + } + + // 2. generate psk and handshake state + let psk = generate_psk(local_pub_key, initial_frame.header.version)?; + + let mut handshake = Builder::new(pattern.as_noise_params()) + .local_private_key(local_private_key.as_ref()) + .psk(pattern.psk_position(), &psk) + .build_responder()?; + + // update handshake state with initial frame + let mut buf = BytesMut::zeroed(HANDSHAKE_MAX_LEN); + handshake.read_message(&initial_frame.data, &mut buf)?; + + // 3. run handshake to completion + self.perform_handshake(handshake, initial_frame.version(), pattern) + .await + } + + pub(crate) async fn perform_responder_handshake( + self, + config: &NoiseConfig, + ) -> Result, NoiseError> + where + C: AsyncRead + AsyncWrite + Unpin, + { + let timeout = config.timeout; + tokio::time::timeout( + timeout, + self.perform_responder_handshake_inner( + config.pattern, + config.local_key.private_key(), + *config.local_key.public_key(), + ), + ) + .await? + } + + async fn send_handshake_msg( + &mut self, + handshake: &mut HandshakeState, + version: NoiseVersion, + pattern: NoisePattern, + ) -> Result<(), NoiseError> + where + C: AsyncRead + AsyncWrite + Unpin, + { + let mut buf = BytesMut::zeroed(HANDSHAKE_MAX_LEN); // we're in the handshake, we can afford a smaller buffer + let len = handshake.write_message(&[], &mut buf)?; + buf.truncate(len); + + let frame = NymNoiseFrame::new_handshake_frame(buf.freeze(), version, pattern)?; + self.inner_stream.send(frame).await?; + Ok(()) + } + + async fn recv_handshake_msg( + &mut self, + handshake: &mut HandshakeState, + version: NoiseVersion, + pattern: NoisePattern, + ) -> Result<(), NoiseError> + where + C: AsyncRead + AsyncWrite + Unpin, + { + match self.inner_stream.next().await { + Some(Ok(frame)) => { + // validate the frame + if !frame.is_handshake_message() { + return Err(NoiseError::NonHandshakeMessageReceived); + } + if frame.version() != version { + return Err(NoiseError::UnexpectedHandshakeVersion { + initial: version, + received: frame.version(), + }); + } + if frame.noise_pattern() != pattern { + return Err(NoiseError::UnexpectedNoisePattern { + configured: pattern.as_str(), + received: frame.noise_pattern().as_str(), + }); + } + + let mut buf = BytesMut::zeroed(HANDSHAKE_MAX_LEN); // we're in the handshake, we can afford a smaller buffer + handshake.read_message(&frame.data, &mut buf)?; + Ok(()) + } + Some(Err(err)) => Err(err), + None => Err(NoiseError::HandshakeError), + } + } + + async fn perform_handshake( + mut self, + mut handshake_state: HandshakeState, + version: NoiseVersion, + pattern: NoisePattern, + ) -> Result, NoiseError> + where + C: AsyncRead + AsyncWrite + Unpin, + { + while !handshake_state.is_handshake_finished() { + if handshake_state.is_my_turn() { + self.send_handshake_msg(&mut handshake_state, version, pattern) + .await?; + } else { + self.recv_handshake_msg(&mut handshake_state, version, pattern) + .await?; + } + } + + let transport = handshake_state.into_transport_mode()?; + Ok(NoiseStream { + inner_stream: self.inner_stream, + negotiated_pattern: pattern, + negotiated_version: version, + transport, + dec_buffer: Default::default(), + }) + } +} + +/// Wrapper around a TcpStream +pub struct NoiseStream { + inner_stream: Framed, + + negotiated_pattern: NoisePattern, + negotiated_version: NoiseVersion, + + transport: TransportState, + dec_buffer: BytesMut, +} + +impl NoiseStream { + fn validate_data_frame(&self, frame: NymNoiseFrame) -> Result { + if !frame.is_data_message() { + return Err(NoiseError::NonDataMessageReceived); + } + // validate the frame + if !frame.is_data_message() { + return Err(NoiseError::NonDataMessageReceived); + } + if frame.version() != self.negotiated_version { + return Err(NoiseError::UnexpectedDataVersion { + initial: self.negotiated_version, + received: frame.version(), + }); + } + if frame.noise_pattern() != self.negotiated_pattern { + return Err(NoiseError::UnexpectedNoisePattern { + configured: self.negotiated_pattern.as_str(), + received: frame.noise_pattern().as_str(), + }); + }; + + Ok(frame.data) + } + + fn poll_data_frame( + &mut self, + cx: &mut std::task::Context<'_>, + ) -> Poll>> + where + C: AsyncRead + AsyncWrite + Unpin, + { + match ready!(Pin::new(&mut self.inner_stream).poll_next(cx)) { + None => Poll::Ready(None), + Some(Err(err)) => Poll::Ready(Some(Err(err.naive_to_io_error()))), + Some(Ok(frame)) => match self.validate_data_frame(frame) { + Err(err) => Poll::Ready(Some(Err(err.naive_to_io_error()))), + Ok(data) => Poll::Ready(Some(Ok(data))), + }, + } + } +} + +impl AsyncRead for NoiseStream +where + C: AsyncRead + AsyncWrite + Unpin, +{ + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let pending = match self.poll_data_frame(cx) { + Poll::Pending => { + //no new data, a return value of Poll::Pending means the waking is already scheduled + //Nothing new to decrypt, only check if we can return something from dec_storage, happens after + true + } + + Poll::Ready(Some(Ok(noise_msg))) => { + // We have a new noise msg + let mut dec_msg = BytesMut::zeroed(noise_msg.len() - TAGLEN); + + let len = match self.transport.read_message(&noise_msg, &mut dec_msg) { + Ok(len) => len, + Err(_) => return Poll::Ready(Err(io::ErrorKind::InvalidInput.into())), + }; + + self.dec_buffer.extend(&dec_msg[..len]); + + false + } + + Poll::Ready(Some(Err(err))) => return Poll::Ready(Err(err)), + + Poll::Ready(None) => { + //Stream is done, we might still have data in the buffer though, happens afterward + false + } + }; + + // Checking if there is something to return from the buffer + let read_len = min(buf.remaining(), self.dec_buffer.len()); + if read_len > 0 { + buf.put_slice(&self.dec_buffer.split_to(read_len)); + return Poll::Ready(Ok(())); + } + + // buf.remaining == 0 or nothing in the buffer, we must return the value we had from the inner_stream + if pending { + //If we end up here, it means the previous poll_next was pending as well, hence waking is already scheduled + Poll::Pending + } else { + Poll::Ready(Ok(())) + } + } +} + +impl AsyncWrite for NoiseStream +where + C: AsyncWrite + Unpin, +{ + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> Poll> { + // returns on Poll::Pending and Poll:Ready(Err) + ready!(Pin::new(&mut self.inner_stream).poll_ready(cx)) + .map_err(|err| err.naive_to_io_error())?; + + // we can send at most u16::MAX bytes in a frame, but we also have to include the tag when encoding + let msg_len = min(u16::MAX as usize - TAGLEN, buf.len()); + + // Ready to send, encrypting message + let mut noise_buf = BytesMut::zeroed(msg_len + TAGLEN); + + let Ok(len) = self + .transport + .write_message(&buf[..msg_len], &mut noise_buf) + else { + return Poll::Ready(Err(io::ErrorKind::InvalidInput.into())); + }; + noise_buf.truncate(len); + + let frame = NymNoiseFrame::new_data_frame( + noise_buf.freeze(), + self.negotiated_version, + self.negotiated_pattern, + ) + .map_err(|err| err.naive_to_io_error())?; + + // Tokio uses the same `start_send ` in their SinkWriter implementation. https://docs.rs/tokio-util/latest/src/tokio_util/io/sink_writer.rs.html#104 + match Pin::new(&mut self.inner_stream).start_send(frame) { + Ok(()) => Poll::Ready(Ok(msg_len)), + Err(e) => Poll::Ready(Err(e.naive_to_io_error())), + } + } + + fn poll_flush( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> Poll> { + Pin::new(&mut self.inner_stream) + .poll_flush(cx) + .map_err(|err| err.naive_to_io_error()) + } + + fn poll_shutdown( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> Poll> { + Pin::new(&mut self.inner_stream) + .poll_close(cx) + .map_err(|err| err.naive_to_io_error()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nym_crypto::asymmetric::x25519; + use rand_chacha::rand_core::SeedableRng; + use std::io::Error; + use std::mem; + use std::sync::Arc; + use std::task::{Context, Waker}; + use std::time::Duration; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::join; + use tokio::sync::Mutex; + use tokio::time::timeout; + + fn mock_streams() -> (MockStream, MockStream) { + let ch1 = Arc::new(Mutex::new(Default::default())); + let ch2 = Arc::new(Mutex::new(Default::default())); + + ( + MockStream { + inner: MockStreamInner { + tx: ch1.clone(), + rx: ch2.clone(), + }, + }, + MockStream { + inner: MockStreamInner { tx: ch2, rx: ch1 }, + }, + ) + } + + struct MockStream { + inner: MockStreamInner, + } + + #[allow(dead_code)] + impl MockStream { + fn unchecked_tx_data(&self) -> Vec { + self.inner.tx.try_lock().unwrap().data.clone() + } + + fn unchecked_rx_data(&self) -> Vec { + self.inner.rx.try_lock().unwrap().data.clone() + } + } + + struct MockStreamInner { + tx: Arc>, + rx: Arc>, + } + + #[derive(Default)] + struct DataWrapper { + data: Vec, + waker: Option, + } + + impl AsyncRead for MockStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let mut inner = self.inner.rx.try_lock().unwrap(); + let data = mem::take(&mut inner.data); + if data.is_empty() { + inner.waker = Some(cx.waker().clone()); + return Poll::Pending; + } + + if let Some(waker) = inner.waker.take() { + waker.wake(); + } + + buf.put_slice(&data); + Poll::Ready(Ok(())) + } + } + + impl AsyncWrite for MockStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let mut inner = self.inner.tx.try_lock().unwrap(); + let len = buf.len(); + + if !inner.data.is_empty() { + assert!(inner.waker.is_none()); + inner.waker = Some(cx.waker().clone()); + return Poll::Pending; + } + + inner.data.extend_from_slice(buf); + if let Some(waker) = inner.waker.take() { + waker.wake(); + } + Poll::Ready(Ok(len)) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + } + + #[tokio::test] + async fn noise_handshake() -> anyhow::Result<()> { + let dummy_seed = [42u8; 32]; + let mut rng = rand_chacha::ChaCha20Rng::from_seed(dummy_seed); + + let initiator_keys = Arc::new(x25519::KeyPair::new(&mut rng)); + let responder_keys = Arc::new(x25519::KeyPair::new(&mut rng)); + + let (initiator_stream, responder_stream) = mock_streams(); + + let psk = generate_psk(*responder_keys.public_key(), NoiseVersion::V1)?; + let pattern = NoisePattern::default(); + + let stream_initiator = NoiseStreamBuilder::new(initiator_stream) + .perform_initiator_handshake_inner( + pattern, + initiator_keys.private_key().to_bytes(), + responder_keys.public_key().to_bytes(), + psk, + NoiseVersion::V1, + ); + + let stream_responder = NoiseStreamBuilder::new(responder_stream) + .perform_responder_handshake_inner( + pattern, + responder_keys.private_key().to_bytes(), + *responder_keys.public_key(), + ); + + let initiator_fut = + tokio::spawn( + async move { timeout(Duration::from_millis(200), stream_initiator).await }, + ); + let responder_fut = + tokio::spawn( + async move { timeout(Duration::from_millis(200), stream_responder).await }, + ); + + let (initiator, responder) = join!(initiator_fut, responder_fut); + + let mut initiator = initiator???; + let mut responder = responder???; + + let msg = b"hello there"; + // if noise was successful we should be able to write a proper message across + timeout(Duration::from_millis(200), initiator.write_all(msg)).await??; + + initiator.inner_stream.flush().await?; + + let inner_buf = initiator.inner_stream.get_ref().unchecked_tx_data(); + + let mut buf = [0u8; 11]; + timeout(Duration::from_millis(200), responder.read(&mut buf)).await??; + + assert_eq!(&buf[..], msg); + + // the inner content is different from the actual msg since it was encrypted + assert_ne!(inner_buf, buf); + assert_ne!(inner_buf.len(), msg.len()); + + Ok(()) + } +} diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 20ab2abf41a..404d5b287e3 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -266,6 +266,20 @@ dependencies = [ "thiserror 1.0.64", ] +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.12", +] + [[package]] name = "cfg-if" version = "1.0.0" @@ -1249,7 +1263,7 @@ dependencies = [ name = "nym-network-defaults" version = "0.1.0" dependencies = [ - "cargo_metadata", + "cargo_metadata 0.19.2", "regex", ] @@ -2019,7 +2033,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e27d6bdd219887a9eadd19e1c34f32e47fa332301184935c6d9bca26f3cca525" dependencies = [ "anyhow", - "cargo_metadata", + "cargo_metadata 0.18.1", "cfg-if", "regex", "rustc_version", diff --git a/nym-api/enter_db.sh b/nym-api/enter_db.sh deleted file mode 100755 index 84094e5ec8a..00000000000 --- a/nym-api/enter_db.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/sh -sqlite3 -init settings.sql /Users/jedrzej/workspace/nym/target/debug/build/nym-api-84610644ac15a598/out/nym-api-example.sqlite \ No newline at end of file diff --git a/nym-api/nym-api-requests/Cargo.toml b/nym-api/nym-api-requests/Cargo.toml index a8bf382ffcc..c0eb51d4d06 100644 --- a/nym-api/nym-api-requests/Cargo.toml +++ b/nym-api/nym-api-requests/Cargo.toml @@ -38,6 +38,7 @@ nym-compact-ecash = { path = "../../common/nym_offline_compact_ecash" } nym-contracts-common = { path = "../../common/cosmwasm-smart-contracts/contracts-common", features = ["naive_float"] } nym-mixnet-contract-common = { path = "../../common/cosmwasm-smart-contracts/mixnet-contract", features = ["utoipa"] } nym-node-requests = { path = "../../nym-node/nym-node-requests", default-features = false, features = ["openapi"] } +nym-noise-keys = { path = "../../common/nymnoise/keys"} nym-network-defaults = { path = "../../common/network-defaults" } nym-ticketbooks-merkle = { path = "../../common/ticketbooks-merkle" } diff --git a/nym-api/nym-api-requests/src/models.rs b/nym-api/nym-api-requests/src/models.rs index 96b5fa49491..a9c5c2ccd46 100644 --- a/nym-api/nym-api-requests/src/models.rs +++ b/nym-api/nym-api-requests/src/models.rs @@ -8,15 +8,13 @@ use crate::helpers::PlaceholderJsonSchemaImpl; use crate::legacy::{ LegacyGatewayBondWithId, LegacyMixNodeBondWithLayer, LegacyMixNodeDetailsWithLayer, }; +use crate::nym_nodes::SemiSkimmedNode; use crate::nym_nodes::{BasicEntryInformation, NodeRole, SkimmedNode}; use crate::pagination::PaginatedResponse; use cosmwasm_std::{Addr, Coin, Decimal, Uint128}; use nym_contracts_common::NaiveFloat; use nym_crypto::asymmetric::ed25519::{self, serde_helpers::bs58_ed25519_pubkey}; -use nym_crypto::asymmetric::x25519::{ - self, - serde_helpers::{bs58_x25519_pubkey, option_bs58_x25519_pubkey}, -}; +use nym_crypto::asymmetric::x25519::{self, serde_helpers::bs58_x25519_pubkey}; use nym_mixnet_contract_common::nym_node::Role; use nym_mixnet_contract_common::reward_params::{Performance, RewardingParams}; use nym_mixnet_contract_common::rewarding::RewardEstimate; @@ -28,6 +26,7 @@ use nym_node_requests::api::v1::authenticator::models::Authenticator; use nym_node_requests::api::v1::gateway::models::Wireguard; use nym_node_requests::api::v1::ip_packet_router::models::IpPacketRouter; use nym_node_requests::api::v1::node::models::{AuxiliaryDetails, NodeRoles}; +use nym_noise_keys::VersionedNoiseKey; use schemars::gen::SchemaGenerator; use schemars::schema::{InstanceType, Schema, SchemaObject}; use schemars::JsonSchema; @@ -471,6 +470,17 @@ impl MixNodeBondAnnotated { performance: self.node_performance.last_24h, }) } + + pub fn try_to_semi_skimmed_node( + &self, + role: NodeRole, + ) -> Result { + let skimmed_node = self.try_to_skimmed_node(role)?; + Ok(SemiSkimmedNode { + basic: skimmed_node, + x25519_noise_versioned_key: None, // legacy node won't ever support Noise + }) + } } #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] @@ -536,6 +546,17 @@ impl GatewayBondAnnotated { performance: self.node_performance.last_24h, }) } + + pub fn try_to_semi_skimmed_node( + &self, + role: NodeRole, + ) -> Result { + let skimmed_node = self.try_to_skimmed_node(role)?; + Ok(SemiSkimmedNode { + basic: skimmed_node, + x25519_noise_versioned_key: None, // legacy node won't ever support Noise + }) + } } #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] @@ -878,10 +899,7 @@ pub struct HostKeys { pub pre_announced_x25519_sphinx_key: Option, #[serde(default)] - #[serde(with = "option_bs58_x25519_pubkey")] - #[schemars(with = "Option")] - #[schema(value_type = String)] - pub x25519_noise: Option, + pub x25519_versioned_noise: Option, } #[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] @@ -910,7 +928,7 @@ impl From for HostKeys { x25519: value.x25519_sphinx, current_x25519_sphinx_key: value.primary_x25519_sphinx_key.into(), pre_announced_x25519_sphinx_key: value.pre_announced_x25519_sphinx_key.map(Into::into), - x25519_noise: value.x25519_noise, + x25519_versioned_noise: value.x25519_versioned_noise, } } } @@ -1087,6 +1105,24 @@ impl NymNodeDescription { performance, } } + + pub fn to_semi_skimmed_node( + &self, + current_rotation_id: u32, + role: NodeRole, + performance: Performance, + ) -> SemiSkimmedNode { + let skimmed_node = self.to_skimmed_node(current_rotation_id, role, performance); + + SemiSkimmedNode { + basic: skimmed_node, + x25519_noise_versioned_key: self + .description + .host_information + .keys + .x25519_versioned_noise, + } + } } #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] @@ -1372,10 +1408,7 @@ pub struct NetworkMonitorRunDetailsResponse { #[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] pub struct NoiseDetails { - #[schemars(with = "String")] - #[serde(with = "bs58_x25519_pubkey")] - #[schema(value_type = String)] - pub x25119_pubkey: x25519::PublicKey, + pub key: VersionedNoiseKey, pub mixnet_port: u16, diff --git a/nym-api/nym-api-requests/src/nym_nodes.rs b/nym-api/nym-api-requests/src/nym_nodes.rs index 8e47ab45131..6a19eb76d93 100644 --- a/nym-api/nym-api-requests/src/nym_nodes.rs +++ b/nym-api/nym-api-requests/src/nym_nodes.rs @@ -9,6 +9,7 @@ use nym_crypto::asymmetric::{ed25519, x25519}; use nym_mixnet_contract_common::nym_node::Role; use nym_mixnet_contract_common::reward_params::Performance; use nym_mixnet_contract_common::{EpochId, Interval, NodeId}; +use nym_noise_keys::VersionedNoiseKey; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::net::IpAddr; @@ -27,6 +28,18 @@ impl SkimmedNodesWithMetadata { } } +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, utoipa::ToSchema)] +pub struct SemiSkimmedNodesWithMetadata { + pub nodes: Vec, + pub metadata: NodesResponseMetadata, +} + +impl SemiSkimmedNodesWithMetadata { + pub fn new(nodes: Vec, metadata: NodesResponseMetadata) -> Self { + SemiSkimmedNodesWithMetadata { nodes, metadata } + } +} + #[derive( Clone, Copy, Debug, Serialize, Deserialize, schemars::JsonSchema, utoipa::ToSchema, PartialEq, )] @@ -260,7 +273,8 @@ impl SkimmedNode { #[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] pub struct SemiSkimmedNode { pub basic: SkimmedNode, - pub x25519_noise_pubkey: String, + + pub x25519_noise_versioned_key: Option, // pub location: } diff --git a/nym-api/src/nym_nodes/handlers/mod.rs b/nym-api/src/nym_nodes/handlers/mod.rs index ed871b096c3..6fed23d30ad 100644 --- a/nym-api/src/nym_nodes/handlers/mod.rs +++ b/nym-api/src/nym_nodes/handlers/mod.rs @@ -160,11 +160,11 @@ async fn nodes_noise( n.description .host_information .keys - .x25519_noise + .x25519_versioned_noise .map(|noise_key| (noise_key, n)) }) .map(|(noise_key, node)| NoiseDetails { - x25119_pubkey: noise_key, + key: noise_key, mixnet_port: node.description.mix_port(), ip_addresses: node.description.host_information.ip_address.clone(), }) diff --git a/nym-api/src/unstable_routes/helpers.rs b/nym-api/src/unstable_routes/helpers.rs index 4627ebce94e..216ffacf4b8 100644 --- a/nym-api/src/unstable_routes/helpers.rs +++ b/nym-api/src/unstable_routes/helpers.rs @@ -4,7 +4,7 @@ use nym_api_requests::models::{ GatewayBondAnnotated, MalformedNodeBond, MixNodeBondAnnotated, OffsetDateTimeJsonSchemaWrapper, }; -use nym_api_requests::nym_nodes::{NodeRole, SkimmedNode}; +use nym_api_requests::nym_nodes::{NodeRole, SemiSkimmedNode, SkimmedNode}; use nym_mixnet_contract_common::reward_params::Performance; use time::OffsetDateTime; @@ -14,6 +14,11 @@ pub(crate) trait LegacyAnnotation { fn identity(&self) -> &str; fn try_to_skimmed_node(&self, role: NodeRole) -> Result; + + fn try_to_semi_skimmed_node( + &self, + role: NodeRole, + ) -> Result; } impl LegacyAnnotation for MixNodeBondAnnotated { @@ -28,6 +33,13 @@ impl LegacyAnnotation for MixNodeBondAnnotated { fn try_to_skimmed_node(&self, role: NodeRole) -> Result { self.try_to_skimmed_node(role) } + + fn try_to_semi_skimmed_node( + &self, + role: NodeRole, + ) -> Result { + self.try_to_semi_skimmed_node(role) + } } impl LegacyAnnotation for GatewayBondAnnotated { @@ -42,6 +54,13 @@ impl LegacyAnnotation for GatewayBondAnnotated { fn try_to_skimmed_node(&self, role: NodeRole) -> Result { self.try_to_skimmed_node(role) } + + fn try_to_semi_skimmed_node( + &self, + role: NodeRole, + ) -> Result { + self.try_to_semi_skimmed_node(role) + } } pub(crate) fn refreshed_at( diff --git a/nym-api/src/unstable_routes/v2/nym_nodes/mod.rs b/nym-api/src/unstable_routes/v2/nym_nodes/mod.rs index 725fa16b2a5..e3cc67ef84d 100644 --- a/nym-api/src/unstable_routes/v2/nym_nodes/mod.rs +++ b/nym-api/src/unstable_routes/v2/nym_nodes/mod.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use crate::support::http::state::AppState; +use crate::unstable_routes::v2::nym_nodes::semi_skimmed::nodes_expanded; use crate::unstable_routes::v2::nym_nodes::skimmed::{ entry_gateways_basic_all, exit_gateways_basic_all, mixnodes_basic_active, mixnodes_basic_all, nodes_basic_all, @@ -11,6 +12,7 @@ use axum::Router; use tower_http::compression::CompressionLayer; pub(crate) mod helpers; +pub(crate) mod semi_skimmed; pub(crate) mod skimmed; #[allow(deprecated)] @@ -29,5 +31,9 @@ pub(crate) fn routes() -> Router { .route("/entry-gateways", get(entry_gateways_basic_all)) .route("/exit-gateways", get(exit_gateways_basic_all)), ) + .nest( + "/semi-skimmed", + Router::new().route("/", get(nodes_expanded)), + ) .layer(CompressionLayer::new()) } diff --git a/nym-api/src/unstable_routes/v2/nym_nodes/semi_skimmed/mod.rs b/nym-api/src/unstable_routes/v2/nym_nodes/semi_skimmed/mod.rs new file mode 100644 index 00000000000..69cc2dea45d --- /dev/null +++ b/nym-api/src/unstable_routes/v2/nym_nodes/semi_skimmed/mod.rs @@ -0,0 +1,177 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::node_describe_cache::cache::DescribedNodes; +use crate::node_status_api::models::AxumResult; +use crate::support::http::state::AppState; +use crate::unstable_routes::helpers::{refreshed_at, LegacyAnnotation}; +use crate::unstable_routes::v2::nym_nodes::helpers::NodesParamsWithRole; +use axum::extract::{Query, State}; +use nym_api_requests::models::{ + NodeAnnotation, NymNodeDescription, OffsetDateTimeJsonSchemaWrapper, +}; +use nym_api_requests::nym_nodes::{NodeRole, PaginatedCachedNodesResponseV2, SemiSkimmedNode}; +use nym_api_requests::pagination::PaginatedResponse; +use nym_http_api_common::FormattedResponse; +use nym_mixnet_contract_common::NodeId; +use nym_topology::CachedEpochRewardedSet; +use std::collections::HashMap; +use tracing::trace; +use utoipa::ToSchema; + +pub type PaginatedSemiSkimmedNodes = + AxumResult>>; + +//SW TODO : this is copied from skimmed nodes, surely we can do better than that +fn build_nym_nodes_response<'a, NI>( + rewarded_set: &CachedEpochRewardedSet, + nym_nodes_subset: NI, + annotations: &HashMap, + current_key_rotation: u32, + active_only: bool, +) -> Vec +where + NI: Iterator + 'a, +{ + let mut nodes = Vec::new(); + for nym_node in nym_nodes_subset { + let node_id = nym_node.node_id; + + let role: NodeRole = rewarded_set.role(node_id).into(); + + // if the role is inactive, see if our filter allows it + if active_only && role.is_inactive() { + continue; + } + + // honestly, not sure under what exact circumstances this value could be missing, + // but in that case just use 0 performance + let annotation = annotations.get(&node_id).copied().unwrap_or_default(); + + nodes.push(nym_node.to_semi_skimmed_node( + current_key_rotation, + role, + annotation.last_24h_performance, + )); + } + nodes +} + +//SW TODO : this is copied from skimmed nodes, surely we can do better than that +/// Given all relevant caches, add appropriate legacy nodes to the part of the response +fn add_legacy( + nodes: &mut Vec, + rewarded_set: &CachedEpochRewardedSet, + describe_cache: &DescribedNodes, + annotated_legacy_nodes: &HashMap, + current_key_rotation: u32, +) where + LN: LegacyAnnotation, +{ + for (node_id, legacy) in annotated_legacy_nodes.iter() { + let role: NodeRole = rewarded_set.role(*node_id).into(); + + // if we have self-described info, prefer it over contract data + if let Some(described) = describe_cache.get_node(node_id) { + nodes.push(described.to_semi_skimmed_node( + current_key_rotation, + role, + legacy.performance(), + )) + } else { + match legacy.try_to_semi_skimmed_node(role) { + Ok(node) => nodes.push(node), + Err(err) => { + let id = legacy.identity(); + trace!("node {id} is malformed: {err}") + } + } + } + } +} + +#[allow(dead_code)] // not dead, used in OpenAPI docs +#[derive(ToSchema)] +#[schema(title = "PaginatedCachedNodesExpandedResponseSchema")] +pub struct PaginatedCachedNodesExpandedResponseSchema { + pub refreshed_at: OffsetDateTimeJsonSchemaWrapper, + #[schema(value_type = SemiSkimmedNode)] + pub nodes: PaginatedResponse, +} + +/// Return all Nym Nodes and optionally legacy mixnodes/gateways (if `no-legacy` flag is not used) +/// that are currently bonded. +#[utoipa::path( + tag = "Unstable Nym Nodes", + get, + params(NodesParamsWithRole), + path = "", + context_path = "/v2/unstable/nym-nodes/semi-skimmed", + responses( + (status = 200, content( + (PaginatedCachedNodesExpandedResponseSchema = "application/json"), + (PaginatedCachedNodesExpandedResponseSchema = "application/yaml"), + (PaginatedCachedNodesExpandedResponseSchema = "application/bincode") + )) + ) +)] +pub(super) async fn nodes_expanded( + state: State, + query_params: Query, +) -> PaginatedSemiSkimmedNodes { + // 1. grab all relevant described nym-nodes + let rewarded_set = state.rewarded_set().await?; + + let describe_cache = state.describe_nodes_cache_data().await?; + let all_nym_nodes = describe_cache.all_nym_nodes(); + let annotations = state.node_annotations().await?; + let legacy_mixnodes = state.legacy_mixnode_annotations().await?; + let legacy_gateways = state.legacy_gateways_annotations().await?; + + let contract_cache = state.nym_contract_cache(); + let current_key_rotation = contract_cache.current_key_rotation_id().await?; + let interval = contract_cache.current_interval().await?; + + let mut nodes = build_nym_nodes_response( + &rewarded_set, + all_nym_nodes, + &annotations, + current_key_rotation, + false, + ); + + // add legacy gateways to the response + add_legacy( + &mut nodes, + &rewarded_set, + &describe_cache, + &legacy_gateways, + current_key_rotation, + ); + + // add legacy mixnodes to the response + add_legacy( + &mut nodes, + &rewarded_set, + &describe_cache, + &legacy_mixnodes, + current_key_rotation, + ); + + // min of all caches + let refreshed_at = refreshed_at([ + rewarded_set.timestamp(), + annotations.timestamp(), + describe_cache.timestamp(), + legacy_mixnodes.timestamp(), + legacy_gateways.timestamp(), + ]); + + let output = query_params.output.unwrap_or_default(); + Ok(output.to_response(PaginatedCachedNodesResponseV2::new_full( + interval.current_epoch_absolute_id(), + current_key_rotation, + refreshed_at, + nodes, + ))) +} diff --git a/nym-node/Cargo.toml b/nym-node/Cargo.toml index 5d7bbeffbd8..1cf70ae5dad 100644 --- a/nym-node/Cargo.toml +++ b/nym-node/Cargo.toml @@ -55,6 +55,8 @@ nym-config = { path = "../common/config" } nym-crypto = { path = "../common/crypto", features = ["asymmetric", "rand"] } nym-nonexhaustive-delayqueue = { path = "../common/nonexhaustive-delayqueue" } nym-mixnet-client = { path = "../common/client-libs/mixnet-client" } +nym-noise = { path = "../common/nymnoise" } +nym-noise-keys = { path = "../common/nymnoise/keys" } nym-pemstore = { path = "../common/pemstore" } nym-sphinx-acknowledgements = { path = "../common/nymsphinx/acknowledgements" } nym-sphinx-addressing = { path = "../common/nymsphinx/addressing" } diff --git a/nym-node/nym-node-requests/Cargo.toml b/nym-node/nym-node-requests/Cargo.toml index 9e889439305..0d1628f34b0 100644 --- a/nym-node/nym-node-requests/Cargo.toml +++ b/nym-node/nym-node-requests/Cargo.toml @@ -26,6 +26,7 @@ nym-crypto = { path = "../../common/crypto", features = [ "serde", ] } nym-exit-policy = { path = "../../common/exit-policy" } +nym-noise-keys = { path = "../../common/nymnoise/keys" } nym-wireguard-types = { path = "../../common/wireguard-types", default-features = false } # feature-specific dependencies: diff --git a/nym-node/nym-node-requests/src/api/mod.rs b/nym-node/nym-node-requests/src/api/mod.rs index 5dac9e83045..9c3e0a67d23 100644 --- a/nym-node/nym-node-requests/src/api/mod.rs +++ b/nym-node/nym-node-requests/src/api/mod.rs @@ -1,4 +1,4 @@ -// Copyright 2023 - Nym Technologies SA +// Copyright 2023-2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 use crate::api::v1::node::models::{ @@ -41,6 +41,7 @@ impl SignedData { T: Serialize, { let plaintext = serde_json::to_string(&data)?; + let signature = key.sign(plaintext).to_base58_string(); Ok(SignedData { data, signature }) } @@ -67,6 +68,17 @@ impl SignedHostInformation { return true; } + // TODO: @JS: to remove downgrade support in future release(s) + + let legacy_v3 = SignedData { + data: LegacyHostInformationV3::from(self.data.clone()), + signature: self.signature.clone(), + }; + + if legacy_v3.verify(&self.keys.ed25519_identity) { + return true; + } + // attempt to verify legacy signatures let legacy_v3 = SignedData { data: LegacyHostInformationV3::from(self.data.clone()), @@ -116,9 +128,11 @@ impl Display for ErrorResponse { #[allow(deprecated)] #[cfg(test)] mod tests { + use super::*; use crate::api::v1::node::models::{HostKeys, SphinxKey}; use nym_crypto::asymmetric::{ed25519, x25519}; + use nym_noise_keys::{NoiseVersion, VersionedNoiseKey}; use rand_chacha::rand_core::SeedableRng; #[test] @@ -127,7 +141,10 @@ mod tests { let ed22519 = ed25519::KeyPair::new(&mut rng); let x25519_sphinx = x25519::KeyPair::new(&mut rng); let x25519_sphinx2 = x25519::KeyPair::new(&mut rng); - let x25519_noise = x25519::KeyPair::new(&mut rng); + let x25519_versioned_noise = VersionedNoiseKey { + supported_version: NoiseVersion::V1, + x25519_pubkey: *x25519::KeyPair::new(&mut rng).public_key(), + }; let current_rotation_id = 1234; @@ -142,8 +159,8 @@ mod tests { rotation_id: current_rotation_id, public_key: *x25519_sphinx.public_key(), }, - x25519_noise: None, pre_announced_x25519_sphinx_key: None, + x25519_versioned_noise: None, }, }; @@ -162,7 +179,7 @@ mod tests { public_key: *x25519_sphinx.public_key(), }, pre_announced_x25519_sphinx_key: None, - x25519_noise: Some(*x25519_noise.public_key()), + x25519_versioned_noise: Some(x25519_versioned_noise), }, }; @@ -186,7 +203,7 @@ mod tests { rotation_id: current_rotation_id + 1, public_key: *x25519_sphinx2.public_key(), }), - x25519_noise: None, + x25519_versioned_noise: None, }, }; @@ -208,7 +225,7 @@ mod tests { rotation_id: current_rotation_id + 1, public_key: *x25519_sphinx2.public_key(), }), - x25519_noise: Some(*x25519_noise.public_key()), + x25519_versioned_noise: Some(x25519_versioned_noise), }, }; @@ -225,13 +242,13 @@ mod tests { let x25519_sphinx = x25519::KeyPair::new(&mut rng); let x25519_noise = x25519::KeyPair::new(&mut rng); - let legacy_info = crate::api::v1::node::models::LegacyHostInformationV3 { + let legacy_info_no_noise = crate::api::v1::node::models::LegacyHostInformationV3 { ip_address: vec!["1.1.1.1".parse().unwrap()], hostname: Some("foomp.com".to_string()), keys: crate::api::v1::node::models::LegacyHostKeysV3 { ed25519_identity: *ed22519.public_key(), x25519_sphinx: *x25519_sphinx.public_key(), - x25519_noise: Some(*x25519_noise.public_key()), + x25519_noise: None, }, }; @@ -247,12 +264,12 @@ mod tests { public_key: *x25519_sphinx.public_key(), }, pre_announced_x25519_sphinx_key: None, - x25519_noise: Some(*x25519_noise.public_key()), + x25519_versioned_noise: None, }, }; // signature on legacy data - let signature = SignedData::new(legacy_info, ed22519.private_key()) + let signature = SignedData::new(legacy_info_no_noise, ed22519.private_key()) .unwrap() .signature; @@ -263,7 +280,53 @@ mod tests { }; assert!(!current_struct.verify(ed22519.public_key())); - assert!(current_struct.verify_host_information()) + assert!(current_struct.verify_host_information()); + + // //technically this variant should never happen + let legacy_info_noise = crate::api::v1::node::models::LegacyHostInformationV3 { + ip_address: vec!["1.1.1.1".parse().unwrap()], + hostname: Some("foomp.com".to_string()), + keys: crate::api::v1::node::models::LegacyHostKeysV3 { + ed25519_identity: *ed22519.public_key(), + x25519_sphinx: *x25519_sphinx.public_key(), + x25519_noise: Some(*x25519_noise.public_key()), + }, + }; + + // note the usage of u32::max rotation id (as that's what the legacy data would be deserialised into) + let current_struct_noise = crate::api::v1::node::models::HostInformation { + ip_address: vec!["1.1.1.1".parse().unwrap()], + hostname: Some("foomp.com".to_string()), + keys: HostKeys { + ed25519_identity: *ed22519.public_key(), + x25519_sphinx: *x25519_sphinx.public_key(), + primary_x25519_sphinx_key: SphinxKey { + rotation_id: u32::MAX, + public_key: *x25519_sphinx.public_key(), + }, + pre_announced_x25519_sphinx_key: None, + x25519_versioned_noise: Some(VersionedNoiseKey { + supported_version: NoiseVersion::V1, + x25519_pubkey: legacy_info_noise.keys.x25519_noise.unwrap(), + }), + }, + }; + + // signature on legacy data + + let signature_noise = SignedData::new(legacy_info_noise, ed22519.private_key()) + .unwrap() + .signature; + + // signed blob with the 'current' structure + + let current_struct_noise = SignedData { + data: current_struct_noise, + signature: signature_noise, + }; + + assert!(!current_struct_noise.verify(ed22519.public_key())); + assert!(current_struct_noise.verify_host_information()) } #[test] @@ -305,7 +368,7 @@ mod tests { public_key: *x25519_sphinx.public_key(), }, pre_announced_x25519_sphinx_key: None, - x25519_noise: None, + x25519_versioned_noise: None, }, }; @@ -321,7 +384,10 @@ mod tests { public_key: *x25519_sphinx.public_key(), }, pre_announced_x25519_sphinx_key: None, - x25519_noise: Some(legacy_info_noise.keys.x25519_noise.parse().unwrap()), + x25519_versioned_noise: Some(VersionedNoiseKey { + supported_version: NoiseVersion::V1, + x25519_pubkey: legacy_info_noise.keys.x25519_noise.parse().unwrap(), + }), }, }; @@ -379,7 +445,7 @@ mod tests { public_key: *x25519_sphinx.public_key(), }, pre_announced_x25519_sphinx_key: None, - x25519_noise: None, + x25519_versioned_noise: None, }, }; diff --git a/nym-node/nym-node-requests/src/api/v1/node/models.rs b/nym-node/nym-node-requests/src/api/v1/node/models.rs index 9d63a10380e..2d0a9fe16f7 100644 --- a/nym-node/nym-node-requests/src/api/v1/node/models.rs +++ b/nym-node/nym-node-requests/src/api/v1/node/models.rs @@ -4,9 +4,9 @@ use celes::Country; use nym_crypto::asymmetric::ed25519::{self, serde_helpers::bs58_ed25519_pubkey}; use nym_crypto::asymmetric::x25519::{ - self, - serde_helpers::{bs58_x25519_pubkey, option_bs58_x25519_pubkey}, + self, serde_helpers::bs58_x25519_pubkey, serde_helpers::option_bs58_x25519_pubkey, }; +use nym_noise_keys::VersionedNoiseKey; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::net::IpAddr; @@ -145,9 +145,8 @@ pub struct HostKeys { pub pre_announced_x25519_sphinx_key: Option, /// Base58-encoded x25519 public key of this node used for the noise protocol. - #[schemars(with = "Option")] - #[cfg_attr(feature = "openapi", schema(value_type = Option))] - pub x25519_noise: Option, + #[serde(default)] + pub x25519_versioned_noise: Option, } // we need the intermediate struct to help us with the new explicit sphinx key fields @@ -167,7 +166,7 @@ impl From for HostKeys { x25519_sphinx: value.x25519_sphinx, primary_x25519_sphinx_key, pre_announced_x25519_sphinx_key: value.pre_announced_x25519_sphinx_key, - x25519_noise: value.x25519_noise, + x25519_versioned_noise: value.x25519_versioned_noise, } } } @@ -194,8 +193,7 @@ struct HostKeysDeHelper { /// Base58-encoded x25519 public key of this node used for the noise protocol. #[serde(default)] - #[serde(with = "option_bs58_x25519_pubkey")] - pub x25519_noise: Option, + pub x25519_versioned_noise: Option, } #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] @@ -224,18 +222,14 @@ impl SphinxKey { #[derive(Serialize)] pub struct LegacyHostKeysV3 { - /// Base58-encoded ed25519 public key of this node. Currently, it corresponds to either mixnode's or gateway's identity. #[serde(alias = "ed25519")] #[serde(with = "bs58_ed25519_pubkey")] pub ed25519_identity: ed25519::PublicKey, - /// Base58-encoded x25519 public key of this node used for sphinx/outfox packet creation. - /// Currently, it corresponds to either mixnode's or gateway's key. #[serde(alias = "x25519")] #[serde(with = "bs58_x25519_pubkey")] pub x25519_sphinx: x25519::PublicKey, - /// Base58-encoded x25519 public key of this node used for the noise protocol. #[serde(default)] #[serde(with = "option_bs58_x25519_pubkey")] pub x25519_noise: Option, @@ -259,7 +253,7 @@ impl From for LegacyHostKeysV3 { LegacyHostKeysV3 { ed25519_identity: value.ed25519_identity, x25519_sphinx: value.primary_x25519_sphinx_key.public_key, - x25519_noise: value.x25519_noise, + x25519_noise: value.x25519_versioned_noise.map(|k| k.x25519_pubkey), } } } diff --git a/nym-node/src/config/mod.rs b/nym-node/src/config/mod.rs index 9074f5697bd..eca92898729 100644 --- a/nym-node/src/config/mod.rs +++ b/nym-node/src/config/mod.rs @@ -765,8 +765,7 @@ impl Default for MixnetDebug { packet_forwarding_maximum_backoff: Self::DEFAULT_PACKET_FORWARDING_MAXIMUM_BACKOFF, initial_connection_timeout: Self::DEFAULT_INITIAL_CONNECTION_TIMEOUT, maximum_connection_buffer_size: Self::DEFAULT_MAXIMUM_CONNECTION_BUFFER_SIZE, - // to be changed by @SW once the implementation is there - unsafe_disable_noise: true, + unsafe_disable_noise: false, } } } diff --git a/nym-node/src/node/http/router/api/v1/node/host_information.rs b/nym-node/src/node/http/router/api/v1/node/host_information.rs index 32a8c9ea6ec..7bb036ea1f2 100644 --- a/nym-node/src/node/http/router/api/v1/node/host_information.rs +++ b/nym-node/src/node/http/router/api/v1/node/host_information.rs @@ -55,7 +55,7 @@ pub(crate) async fn host_information( rotation_id: primary_key.rotation_id(), public_key: primary_pubkey, }, - x25519_noise: state.static_information.x25519_noise_key, + x25519_versioned_noise: state.static_information.x25519_versioned_noise_key, pre_announced_x25519_sphinx_key: pre_announced, }, }; diff --git a/nym-node/src/node/http/state/mod.rs b/nym-node/src/node/http/state/mod.rs index 7d0100e7ded..9cd3348cb6b 100644 --- a/nym-node/src/node/http/state/mod.rs +++ b/nym-node/src/node/http/state/mod.rs @@ -4,8 +4,9 @@ use crate::node::http::state::load::CachedNodeLoad; use crate::node::http::state::metrics::MetricsAppState; use crate::node::key_rotation::active_keys::ActiveSphinxKeys; -use nym_crypto::asymmetric::{ed25519, x25519}; +use nym_crypto::asymmetric::ed25519; use nym_node_metrics::NymNodeMetrics; +use nym_noise_keys::VersionedNoiseKey; use nym_verloc::measurements::SharedVerlocStats; use std::net::IpAddr; use std::sync::Arc; @@ -17,7 +18,7 @@ pub mod metrics; pub(crate) struct StaticNodeInformation { pub(crate) ed25519_identity_keys: Arc, - pub(crate) x25519_noise_key: Option, + pub(crate) x25519_versioned_noise_key: Option, pub(crate) ip_addresses: Vec, pub(crate) hostname: Option, } diff --git a/nym-node/src/node/mixnet/handler.rs b/nym-node/src/node/mixnet/handler.rs index b355f097c4f..aca227455a5 100644 --- a/nym-node/src/node/mixnet/handler.rs +++ b/nym-node/src/node/mixnet/handler.rs @@ -3,6 +3,8 @@ use crate::node::mixnet::shared::SharedData; use futures::StreamExt; +use nym_noise::connection::Connection; +use nym_noise::upgrade_noise_responder; use nym_sphinx_forwarding::packet::MixPacket; use nym_sphinx_framing::codec::NymCodec; use nym_sphinx_framing::packet::FramedNymPacket; @@ -72,7 +74,6 @@ impl PendingReplayCheckPackets { pub(crate) struct ConnectionHandler { shared: SharedData, - mixnet_connection: Framed, remote_address: SocketAddr, // packets pending for replay detection @@ -89,11 +90,7 @@ impl Drop for ConnectionHandler { } impl ConnectionHandler { - pub(crate) fn new( - shared: &SharedData, - tcp_stream: TcpStream, - remote_address: SocketAddr, - ) -> Self { + pub(crate) fn new(shared: &SharedData, remote_address: SocketAddr) -> Self { let shutdown = shared.shutdown.child_token(remote_address.to_string()); shared.metrics.network.new_active_ingress_mixnet_client(); @@ -104,11 +101,11 @@ impl ConnectionHandler { replay_protection_filter: shared.replay_protection_filter.clone(), mixnet_forwarder: shared.mixnet_forwarder.clone(), final_hop: shared.final_hop.clone(), + noise_config: shared.noise_config.clone(), metrics: shared.metrics.clone(), shutdown, }, remote_address, - mixnet_connection: Framed::new(tcp_stream, NymCodec), pending_packets: PendingReplayCheckPackets::new(), } } @@ -466,7 +463,29 @@ impl ConnectionHandler { remote = %self.remote_address ) )] - pub(crate) async fn handle_stream(&mut self) { + pub(crate) async fn handle_connection(&mut self, socket: TcpStream) { + let noise_stream = match upgrade_noise_responder(socket, &self.shared.noise_config).await { + Ok(noise_stream) => noise_stream, + Err(err) => { + error!( + "Failed to perform Noise handshake with {:?} - {err}", + self.remote_address + ); + return; + } + }; + debug!( + "Noise responder handshake completed for {:?}", + self.remote_address + ); + self.handle_stream(Framed::new(noise_stream, NymCodec)) + .await + } + + pub(crate) async fn handle_stream( + &mut self, + mut mixnet_connection: Framed, NymCodec>, + ) { loop { tokio::select! { biased; @@ -474,7 +493,7 @@ impl ConnectionHandler { trace!("connection handler: received shutdown"); break } - maybe_framed_nym_packet = self.mixnet_connection.next() => { + maybe_framed_nym_packet = mixnet_connection.next() => { match maybe_framed_nym_packet { Some(Ok(packet)) => self.handle_received_nym_packet(packet).await, Some(Err(err)) => { diff --git a/nym-node/src/node/mixnet/shared/mod.rs b/nym-node/src/node/mixnet/shared/mod.rs index 37318505a77..7dee952d44f 100644 --- a/nym-node/src/node/mixnet/shared/mod.rs +++ b/nym-node/src/node/mixnet/shared/mod.rs @@ -10,6 +10,7 @@ use nym_gateway::node::GatewayStorageError; use nym_mixnet_client::forwarder::{MixForwardingSender, PacketToForward}; use nym_node_metrics::mixnet::PacketKind; use nym_node_metrics::NymNodeMetrics; +use nym_noise::config::NoiseConfig; use nym_sphinx_forwarding::packet::MixPacket; use nym_sphinx_framing::processing::{ MixPacketVersion, MixProcessingResult, MixProcessingResultData, PacketProcessingError, @@ -74,6 +75,9 @@ pub(crate) struct SharedData { // data specific to the final hop (gateway) processing pub(super) final_hop: SharedFinalHopData, + // for establishing a Noise connection + pub(super) noise_config: NoiseConfig, + pub(super) metrics: NymNodeMetrics, pub(super) shutdown: ShutdownToken, } @@ -86,12 +90,14 @@ fn convert_to_metrics_version(processed: MixPacketVersion) -> PacketKind { } impl SharedData { + #[allow(clippy::too_many_arguments)] pub(crate) fn new( processing_config: ProcessingConfig, sphinx_keys: ActiveSphinxKeys, replay_protection_filter: ReplayProtectionBloomfilters, mixnet_forwarder: MixForwardingSender, final_hop: SharedFinalHopData, + noise_config: NoiseConfig, metrics: NymNodeMetrics, shutdown: ShutdownToken, ) -> Self { @@ -101,6 +107,7 @@ impl SharedData { replay_protection_filter, mixnet_forwarder, final_hop, + noise_config, metrics, shutdown, } @@ -163,8 +170,9 @@ impl SharedData { match accepted { Ok((socket, remote_addr)) => { debug!("accepted incoming mixnet connection from: {remote_addr}"); - let mut handler = ConnectionHandler::new(self, socket, remote_addr); - let join_handle = tokio::spawn(async move { handler.handle_stream().await }); + let mut handler = ConnectionHandler::new(self, remote_addr); + let join_handle = + tokio::spawn(async move { handler.handle_connection(socket).await }); self.log_connected_clients(); Some(join_handle) } diff --git a/nym-node/src/node/mod.rs b/nym-node/src/node/mod.rs index 08b6f413950..6349b564135 100644 --- a/nym-node/src/node/mod.rs +++ b/nym-node/src/node/mod.rs @@ -50,6 +50,8 @@ use nym_network_requester::{ use nym_node_metrics::events::MetricEventsSender; use nym_node_metrics::NymNodeMetrics; use nym_node_requests::api::v1::node::models::{AnnouncePorts, NodeDescription}; +use nym_noise::config::{NoiseConfig, NoiseNetworkView}; +use nym_noise_keys::VersionedNoiseKey; use nym_sphinx_acknowledgements::AckKey; use nym_sphinx_addressing::Recipient; use nym_task::{ShutdownManager, ShutdownToken, TaskClient}; @@ -800,16 +802,19 @@ impl NymNode { config.api.v1_config.node.roles.ip_packet_router_enabled = true; } - let x25519_noise_key = if self.config.mixnet.debug.unsafe_disable_noise { + let x25519_versioned_noise_key = if self.config.mixnet.debug.unsafe_disable_noise { None } else { - Some(*self.x25519_noise_keys.public_key()) + Some(VersionedNoiseKey { + supported_version: nym_noise::LATEST_NOISE_VERSION, + x25519_pubkey: *self.x25519_noise_keys.public_key(), + }) }; let app_state = AppState::new( StaticNodeInformation { ed25519_identity_keys: self.ed25519_identity_keys.clone(), - x25519_noise_key, + x25519_versioned_noise_key, ip_addresses: self.config.host.public_ips.clone(), hostname: self.config.host.hostname.clone(), }, @@ -1031,6 +1036,7 @@ impl NymNode { active_clients_store: &ActiveClientsStore, replay_protection_bloomfilter: ReplayProtectionBloomfilters, routing_filter: F, + noise_config: NoiseConfig, shutdown: ShutdownToken, ) -> Result<(MixForwardingSender, ActiveConnections), NymNodeError> where @@ -1054,6 +1060,7 @@ impl NymNode { ); let mixnet_client = nym_mixnet_client::Client::new( mixnet_client_config, + noise_config.clone(), self.metrics .network .active_egress_mixnet_connections_counter(), @@ -1080,6 +1087,7 @@ impl NymNode { replay_protection_bloomfilter, mix_packet_sender.clone(), final_hop_data, + noise_config, self.metrics.clone(), shutdown, ); @@ -1089,10 +1097,18 @@ impl NymNode { } pub(crate) async fn run_minimal_mixnet_processing(self) -> Result<(), NymNodeError> { + let noise_config = nym_noise::config::NoiseConfig::new( + self.x25519_noise_keys.clone(), + NoiseNetworkView::new_empty(), + self.config.mixnet.debug.initial_connection_timeout, + ) + .with_unsafe_disabled(true); + self.start_mixnet_listener( &ActiveClientsStore::new(), ReplayProtectionBloomfilters::new_disabled(), OpenFilter, + noise_config, self.shutdown_manager.clone_token("mixnet-traffic"), ) .await?; @@ -1137,11 +1153,19 @@ impl NymNode { let bloomfilters_manager = self.setup_replay_detection().await?; + let noise_config = nym_noise::config::NoiseConfig::new( + self.x25519_noise_keys.clone(), + network_refresher.noise_view(), + self.config.mixnet.debug.initial_connection_timeout, + ) + .with_unsafe_disabled(self.config.mixnet.debug.unsafe_disable_noise); + let (mix_packet_sender, active_egress_mixnet_connections) = self .start_mixnet_listener( &active_clients_store, bloomfilters_manager.bloomfilters(), network_refresher.routing_filter(), + noise_config, self.shutdown_manager.clone_token("mixnet-traffic"), ) .await?; diff --git a/nym-node/src/node/shared_network.rs b/nym-node/src/node/shared_network.rs index 0197b27018b..2026ec11646 100644 --- a/nym-node/src/node/shared_network.rs +++ b/nym-node/src/node/shared_network.rs @@ -8,6 +8,7 @@ use async_trait::async_trait; use nym_crypto::asymmetric::ed25519; use nym_gateway::node::UserAgent; use nym_node_metrics::prometheus_wrapper::{PrometheusMetric, PROMETHEUS_METRICS}; +use nym_noise::config::NoiseNetworkView; use nym_task::ShutdownToken; use nym_topology::node::RoutingNode; use nym_topology::{ @@ -16,10 +17,10 @@ use nym_topology::{ }; use nym_validator_client::nym_api::NymApiClientExt; use nym_validator_client::nym_nodes::{ - NodesByAddressesResponse, SkimmedNode, SkimmedNodesWithMetadata, + NodesByAddressesResponse, SemiSkimmedNode, SemiSkimmedNodesWithMetadata, }; use nym_validator_client::{NymApiClient, ValidatorClientError}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::net::{IpAddr, SocketAddr}; use std::ops::Deref; use std::sync::Arc; @@ -63,10 +64,12 @@ impl NodesQuerier { res } - async fn current_nymnodes(&mut self) -> Result { + async fn current_nymnodes( + &mut self, + ) -> Result { let res = self .client - .get_all_basic_nodes_with_metadata() + .get_all_expanded_nodes() .await .inspect_err(|err| error!("failed to get network nodes: {err}")); @@ -150,13 +153,19 @@ impl TopologyProvider for CachedTopologyProvider { network_guard.rewarded_set.clone(), Vec::new(), ) - .with_additional_nodes(network_guard.network_nodes.iter().filter(|node| { - if node.supported_roles.mixnode { - node.performance.round_to_integer() >= self.min_mix_performance - } else { - true - } - })); + .with_additional_nodes( + network_guard + .network_nodes + .iter() + .map(|node| &node.basic) + .filter(|node| { + if node.supported_roles.mixnode { + node.performance.round_to_integer() >= self.min_mix_performance + } else { + true + } + }), + ); if !topology.has_node(self.gateway_node.identity_key) { debug!("{self_node} didn't exist in topology. inserting it.",); @@ -188,7 +197,7 @@ impl CachedNetwork { struct CachedNetworkInner { rewarded_set: EpochRewardedSet, topology_metadata: NymTopologyMetadata, - network_nodes: Vec, + network_nodes: Vec, } pub struct NetworkRefresher { @@ -199,6 +208,7 @@ pub struct NetworkRefresher { network: CachedNetwork, routing_filter: NetworkRoutingFilter, + noise_view: NoiseNetworkView, } impl NetworkRefresher { @@ -226,6 +236,7 @@ impl NetworkRefresher { shutdown_token, network: CachedNetwork::new_empty(), routing_filter: NetworkRoutingFilter::new_empty(testnet), + noise_view: NoiseNetworkView::new_empty(), }; this.obtain_initial_network().await?; @@ -282,7 +293,7 @@ impl NetworkRefresher { // collect all known/allowed nodes information let known_nodes = nodes .iter() - .flat_map(|n| n.ip_addresses.iter()) + .flat_map(|n| n.basic.ip_addresses.iter()) .copied() .collect::>(); @@ -305,6 +316,22 @@ impl NetworkRefresher { self.routing_filter.resolved.swap_denied(current_denied); self.routing_filter.pending.clear().await; + //update noise Noise Nodes + let noise_nodes = nodes + .iter() + .filter(|n| n.x25519_noise_versioned_key.is_some()) + .flat_map(|n| { + n.basic.ip_addresses.iter().map(|ip_addr| { + ( + SocketAddr::new(*ip_addr, n.basic.mix_port), + #[allow(clippy::unwrap_used)] + n.x25519_noise_versioned_key.unwrap(), // SAFETY : we filtered out nodes where this option can be None + ) + }) + }) + .collect::>(); + self.noise_view.swap_view(noise_nodes); + let mut network_guard = self.network.inner.write().await; network_guard.topology_metadata = NymTopologyMetadata::new(metadata.rotation_id, metadata.absolute_epoch_id); @@ -340,6 +367,10 @@ impl NetworkRefresher { self.network.clone() } + pub(crate) fn noise_view(&self) -> NoiseNetworkView { + self.noise_view.clone() + } + pub(crate) async fn run(&mut self) { let mut full_refresh_interval = interval(self.full_refresh_interval); full_refresh_interval.reset(); diff --git a/nym-node/src/throughput_tester/client.rs b/nym-node/src/throughput_tester/client.rs index 3410dd98149..8c06f9e77c3 100644 --- a/nym-node/src/throughput_tester/client.rs +++ b/nym-node/src/throughput_tester/client.rs @@ -177,7 +177,7 @@ impl ThroughputTestingClient { // by tagging the packet let shared_secret = private .as_ref() - .as_ref() + .inner() .diffie_hellman(&header.shared_secret); let payload_key = rederive_lioness_payload_key(shared_secret.as_bytes()); diff --git a/nym-wallet/Cargo.lock b/nym-wallet/Cargo.lock index 5df3682ea57..8feb2dec685 100644 --- a/nym-wallet/Cargo.lock +++ b/nym-wallet/Cargo.lock @@ -4032,6 +4032,7 @@ dependencies = [ "nym-mixnet-contract-common", "nym-network-defaults", "nym-node-requests", + "nym-noise-keys", "nym-serde-helpers", "nym-ticketbooks-merkle", "schemars", @@ -4272,7 +4273,7 @@ dependencies = [ name = "nym-network-defaults" version = "0.1.0" dependencies = [ - "cargo_metadata 0.18.1", + "cargo_metadata 0.19.2", "dotenvy", "log", "regex", @@ -4294,6 +4295,7 @@ dependencies = [ "nym-crypto", "nym-exit-policy", "nym-http-api-client", + "nym-noise-keys", "nym-wireguard-types", "schemars", "serde", @@ -4304,6 +4306,16 @@ dependencies = [ "utoipa", ] +[[package]] +name = "nym-noise-keys" +version = "0.1.0" +dependencies = [ + "nym-crypto", + "schemars", + "serde", + "utoipa", +] + [[package]] name = "nym-pemstore" version = "0.3.0"