feat: key rotation (#5777)

* wip

* wip: wrap node's sphinx key with a manager

* wip: choosing correct key for packet processing

* further propagation of key rotation information

* attaching key rotation information to reply surbs

* added basic key rotation information to mixnet contract

* wip: introducing cached queries for key rotation info from nym api

* unified nym-api contract cache refreshing

* finish packet decoding

* multi api client + retrieving rotation id

* rotating sphinx key files

* logic for migrating config file

* wip: putting new sphinx keys to self described endpoints

* processing loop of KeyRotationController

* fixed sphinx key loading

* rotating bloomfilters

* wired up KeyRotationController

* flushing bloomfilters to disk and loading

* most of nym-node changes

* post rebase fixes

* fixes due to backwards compatible hostkeys

* split http state.rs file

* dont use deprecated fields

* fixed backwards compatible deserialisation of host information

* split up node describe cache

* added a dedicated CacheRefresher listener to perform full refresh outside the set interval

* controlling announced sphinx keys within nym-api

* retrieving rotation id when pulling topology

* split nym-nodes http handlers

* v2 nym-api endpoints to retrieve nodes with additional metadata information

* bug fixes...

* additional bugfixes and guards against stuck epoch

* testnet manager: set first nym-api as the rewarder

* fixed host information deserialisation

* fixed panic during first key rotation

* post rebase fixes

* clippy

* more guards against stuck epochs

* added helper method to reset node's sphinx key

* instantiate mixnet contract with custom key rotation validity

* additional bugfixes and debugging nym-api deadlock

* passing shutdown to nym apis client

* remove dead test

* post rebasing fixes

* missing MixnetQueryClient variants

* remove usage of deprecated methods in sdk example

* fix: incorrect method signature

* post rebasing fixes

* attempt to retrieve key rotation id before doing any config migration work

* ignore tests relying on networking behaviour

* allow networking failures in certain tests
This commit is contained in:
Jędrzej Stuczyński
2025-06-03 12:22:51 +02:00
committed by GitHub
parent adbe0392ca
commit d8c84cc4d6
204 changed files with 9392 additions and 3819 deletions
+76 -26
View File
@@ -3,13 +3,42 @@
use crate::config::NodeModes;
use crate::error::{KeyIOFailure, NymNodeError};
use crate::node::key_rotation::key::{SphinxPrivateKey, SphinxPublicKey};
use crate::node::nym_apis_client::NymApisClient;
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_node_requests::api::v1::node::models::NodeDescription;
use nym_pemstore::traits::{PemStorableKey, PemStorableKeyPair};
use nym_pemstore::KeyPairPath;
use nym_task::ShutdownToken;
use nym_validator_client::nyxd::contract_traits::MixnetQueryClient;
use nym_validator_client::QueryHttpRpcNyxdClient;
use serde::Serialize;
use std::fmt::{Display, Formatter};
use std::path::Path;
use tracing::warn;
use url::Url;
#[derive(Debug, Serialize)]
pub(crate) struct DisplaySphinxKey {
public_key: String,
rotation_id: u32,
}
impl From<&SphinxPrivateKey> for DisplaySphinxKey {
fn from(value: &SphinxPrivateKey) -> Self {
let pubkey: SphinxPublicKey = value.into();
DisplaySphinxKey {
public_key: pubkey.inner.to_base58_string(),
rotation_id: pubkey.rotation_id,
}
}
}
impl Display for DisplaySphinxKey {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{} (rotation: {})", self.public_key, self.rotation_id)
}
}
#[derive(Debug, Serialize)]
pub(crate) struct DisplayDetails {
@@ -18,7 +47,8 @@ pub(crate) struct DisplayDetails {
pub(crate) description: NodeDescription,
pub(crate) ed25519_identity_key: String,
pub(crate) x25519_sphinx_key: String,
pub(crate) x25519_primary_sphinx_key: DisplaySphinxKey,
pub(crate) x25519_secondary_sphinx_key: Option<DisplaySphinxKey>,
pub(crate) x25519_noise_key: String,
pub(crate) x25519_wireguard_key: String,
@@ -39,7 +69,14 @@ impl Display for DisplayDetails {
)?;
writeln!(f, "details: '{}'", self.description.details)?;
writeln!(f, "ed25519 identity: {}", self.ed25519_identity_key)?;
writeln!(f, "x25519 sphinx: {}", self.x25519_sphinx_key)?;
writeln!(
f,
"x25519 primary sphinx: {}",
self.x25519_primary_sphinx_key
)?;
if let Some(secondary) = &self.x25519_secondary_sphinx_key {
writeln!(f, "x25519 primary sphinx: {secondary}")?;
}
writeln!(f, "x25519 noise: {}", self.x25519_noise_key)?;
writeln!(
f,
@@ -61,24 +98,24 @@ impl Display for DisplayDetails {
}
pub(crate) fn load_keypair<T: PemStorableKeyPair>(
paths: KeyPairPath,
paths: &KeyPairPath,
name: impl Into<String>,
) -> Result<T, KeyIOFailure> {
nym_pemstore::load_keypair(&paths).map_err(|err| KeyIOFailure::KeyPairLoadFailure {
nym_pemstore::load_keypair(paths).map_err(|err| KeyIOFailure::KeyPairLoadFailure {
keys: name.into(),
paths,
paths: paths.clone(),
err,
})
}
pub(crate) fn store_keypair<T: PemStorableKeyPair>(
keys: &T,
paths: KeyPairPath,
paths: &KeyPairPath,
name: impl Into<String>,
) -> Result<(), KeyIOFailure> {
nym_pemstore::store_keypair(keys, &paths).map_err(|err| KeyIOFailure::KeyPairStoreFailure {
nym_pemstore::store_keypair(keys, paths).map_err(|err| KeyIOFailure::KeyPairStoreFailure {
keys: name.into(),
paths,
paths: paths.clone(),
err,
})
}
@@ -108,7 +145,7 @@ where
}
pub(crate) fn load_ed25519_identity_keypair(
paths: KeyPairPath,
paths: &KeyPairPath,
) -> Result<ed25519::KeyPair, NymNodeError> {
Ok(load_keypair(paths, "ed25519-identity")?)
}
@@ -120,41 +157,54 @@ pub(crate) fn load_ed25519_identity_public_key<P: AsRef<Path>>(
Ok(load_key(path, "ed25519-identity-public-key")?)
}
pub(crate) fn load_x25519_sphinx_keypair(
paths: KeyPairPath,
) -> Result<x25519::KeyPair, NymNodeError> {
Ok(load_keypair(paths, "x25519-sphinx")?)
}
pub(crate) fn load_x25519_noise_keypair(
paths: KeyPairPath,
paths: &KeyPairPath,
) -> Result<x25519::KeyPair, NymNodeError> {
Ok(load_keypair(paths, "x25519-noise")?)
}
pub(crate) fn load_x25519_wireguard_keypair(
paths: KeyPairPath,
paths: &KeyPairPath,
) -> Result<x25519::KeyPair, NymNodeError> {
Ok(load_keypair(paths, "x25519-wireguard")?)
}
pub(crate) fn store_ed25519_identity_keypair(
keys: &ed25519::KeyPair,
paths: KeyPairPath,
paths: &KeyPairPath,
) -> Result<(), NymNodeError> {
Ok(store_keypair(keys, paths, "ed25519-identity")?)
}
pub(crate) fn store_x25519_sphinx_keypair(
keys: &x25519::KeyPair,
paths: KeyPairPath,
) -> Result<(), NymNodeError> {
Ok(store_keypair(keys, paths, "x25519-sphinx")?)
}
pub(crate) fn store_x25519_noise_keypair(
keys: &x25519::KeyPair,
paths: KeyPairPath,
paths: &KeyPairPath,
) -> Result<(), NymNodeError> {
Ok(store_keypair(keys, paths, "x25519-noise")?)
}
pub(crate) async fn get_current_rotation_id(
nym_apis: &[Url],
fallback_nyxd: &[Url],
) -> Result<u32, NymNodeError> {
let apis_client = NymApisClient::new(nym_apis, ShutdownToken::ephemeral())?;
if let Ok(rotation_info) = apis_client.get_key_rotation_info().await {
if rotation_info.is_epoch_stuck() {
return Err(NymNodeError::StuckEpoch);
}
let current_epoch = rotation_info.current_absolute_epoch_id;
return Ok(rotation_info
.key_rotation_state
.key_rotation_id(current_epoch));
}
warn!("failed to retrieve key rotation id from nym apis. falling back to contract query");
for nyxd_url in fallback_nyxd {
let client = QueryHttpRpcNyxdClient::connect_to_default_env(nyxd_url.as_str())?;
if let Ok(res) = client.get_key_rotation_id().await {
return Ok(res.rotation_id);
}
}
Err(NymNodeError::NymApisExhausted)
}
-34
View File
@@ -1,38 +1,4 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::config::Config;
use crate::error::NymNodeError;
use crate::node::http::api::api_requests;
use crate::node::http::error::NymNodeHttpError;
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_node_requests::api::SignedHostInformation;
pub mod system_info;
pub(crate) fn sign_host_details(
config: &Config,
x22519_sphinx: &x25519::PublicKey,
x25519_noise: &x25519::PublicKey,
ed22519_identity: &ed25519::KeyPair,
) -> Result<SignedHostInformation, NymNodeError> {
let x25519_noise = if config.mixnet.debug.unsafe_disable_noise {
None
} else {
Some(*x25519_noise)
};
let host_info = api_requests::v1::node::models::HostInformation {
ip_address: config.host.public_ips.clone(),
hostname: config.host.hostname.clone(),
keys: api_requests::v1::node::models::HostKeys {
ed25519_identity: *ed22519_identity.public_key(),
x25519_sphinx: *x22519_sphinx,
x25519_noise,
},
};
let signed_info = SignedHostInformation::new(host_info, ed22519_identity.private_key())
.map_err(NymNodeHttpError::from)?;
Ok(signed_info)
}
@@ -1,7 +1,9 @@
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use axum::extract::Query;
use crate::node::http::api::api_requests;
use crate::node::http::state::AppState;
use axum::extract::{Query, State};
use nym_http_api_common::{FormattedResponse, OutputParams};
use nym_node_requests::api::{v1::node::models::SignedHostInformation, SignedDataHostInfo};
@@ -20,11 +22,54 @@ use nym_node_requests::api::{v1::node::models::SignedHostInformation, SignedData
params(OutputParams)
)]
pub(crate) async fn host_information(
host_information: SignedHostInformation,
Query(output): Query<OutputParams>,
State(state): State<AppState>,
) -> HostInformationResponse {
let output = output.output.unwrap_or_default();
output.to_response(host_information)
let primary_key = state.x25519_sphinx_keys.primary();
let pre_announced = match state.x25519_sphinx_keys.secondary() {
None => None,
Some(secondary_key) => {
if secondary_key.rotation_id() == primary_key.rotation_id() + 1 {
Some(api_requests::v1::node::models::SphinxKey {
rotation_id: secondary_key.rotation_id(),
public_key: secondary_key.x25519_pubkey(),
})
} else {
None
}
}
};
let primary_pubkey = primary_key.x25519_pubkey();
#[allow(deprecated)]
let host_info = api_requests::v1::node::models::HostInformation {
ip_address: state.static_information.ip_addresses.clone(),
hostname: state.static_information.hostname.clone(),
keys: api_requests::v1::node::models::HostKeys {
ed25519_identity: *state.static_information.ed25519_identity_keys.public_key(),
x25519_sphinx: primary_pubkey,
primary_x25519_sphinx_key: api_requests::v1::node::models::SphinxKey {
rotation_id: primary_key.rotation_id(),
public_key: primary_pubkey,
},
x25519_noise: state.static_information.x25519_noise_key,
pre_announced_x25519_sphinx_key: pre_announced,
},
};
// SAFETY: the only way for this call to fail is if serialisation of HostInformation fails.
// however, that conversion is stable and infallible
#[allow(clippy::unwrap_used)]
let signed_info = SignedHostInformation::new(
host_info,
state.static_information.ed25519_identity_keys.private_key(),
)
.unwrap();
output.to_response(signed_info)
}
pub type HostInformationResponse = FormattedResponse<SignedHostInformation>;
@@ -7,6 +7,7 @@ use crate::node::http::api::v1::node::description::description;
use crate::node::http::api::v1::node::hardware::host_system;
use crate::node::http::api::v1::node::host_information::host_information;
use crate::node::http::api::v1::node::roles::roles;
use crate::node::http::state::AppState;
use axum::routing::get;
use axum::Router;
use nym_node_requests::api::v1::node::models;
@@ -22,14 +23,13 @@ pub mod roles;
#[derive(Debug, Clone)]
pub struct Config {
pub build_information: models::BinaryBuildInformationOwned,
pub host_information: models::SignedHostInformation,
pub system_info: Option<models::HostSystem>,
pub roles: models::NodeRoles,
pub description: models::NodeDescription,
pub auxiliary_details: models::AuxiliaryDetails,
}
pub(super) fn routes<S: Send + Sync + 'static + Clone>(config: Config) -> Router<S> {
pub(super) fn routes(config: Config) -> Router<AppState> {
Router::new()
.route(
v1::BUILD_INFO,
@@ -45,13 +45,7 @@ pub(super) fn routes<S: Send + Sync + 'static + Clone>(config: Config) -> Router
move |query| roles(node_roles, query)
}),
)
.route(
v1::HOST_INFO,
get({
let host_info = config.host_information;
move |query| host_information(host_info, query)
}),
)
.route(v1::HOST_INFO, get(host_information))
.route(
v1::SYSTEM_INFO,
get({
+2 -3
View File
@@ -16,7 +16,6 @@ use nym_node_requests::api::v1::mixnode::models::Mixnode;
use nym_node_requests::api::v1::network_requester::exit_policy::models::UsedExitPolicy;
use nym_node_requests::api::v1::network_requester::models::NetworkRequester;
use nym_node_requests::api::v1::node::models::{AuxiliaryDetails, HostSystem, NodeDescription};
use nym_node_requests::api::SignedHostInformation;
use nym_node_requests::routes;
use std::net::SocketAddr;
use std::path::Path;
@@ -34,14 +33,13 @@ pub struct HttpServerConfig {
}
impl HttpServerConfig {
pub fn new(host_information: SignedHostInformation) -> Self {
pub fn new() -> Self {
HttpServerConfig {
landing: Default::default(),
api: api::Config {
v1_config: api::v1::Config {
node: api::v1::node::Config {
build_information: bin_info_owned!(),
host_information,
system_info: None,
roles: Default::default(),
description: Default::default(),
@@ -118,6 +116,7 @@ impl HttpServerConfig {
self
}
#[must_use]
pub fn with_prometheus_bearer_token(mut self, bearer_token: Option<String>) -> Self {
self.api.v1_config.metrics.bearer_token = bearer_token.map(|b| Arc::new(Zeroizing::new(b)));
self
+23 -3
View File
@@ -1,20 +1,35 @@
// Copyright 2023-2024 - Nym Technologies SA <contact@nymtech.net>
// Copyright 2023-2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
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_node_metrics::NymNodeMetrics;
use nym_verloc::measurements::SharedVerlocStats;
use std::net::IpAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::time::Instant;
pub mod load;
pub mod metrics;
pub(crate) struct StaticNodeInformation {
pub(crate) ed25519_identity_keys: Arc<ed25519::KeyPair>,
pub(crate) x25519_noise_key: Option<x25519::PublicKey>,
pub(crate) ip_addresses: Vec<IpAddr>,
pub(crate) hostname: Option<String>,
}
#[derive(Clone)]
pub struct AppState {
pub(crate) struct AppState {
pub(crate) startup_time: Instant,
pub(crate) static_information: Arc<StaticNodeInformation>,
pub(crate) x25519_sphinx_keys: ActiveSphinxKeys,
pub(crate) cached_load: CachedNodeLoad,
pub(crate) metrics: MetricsAppState,
@@ -22,12 +37,17 @@ pub struct AppState {
impl AppState {
#[allow(clippy::new_without_default)]
pub fn new(
pub(crate) fn new(
static_information: StaticNodeInformation,
x25519_sphinx_keys: ActiveSphinxKeys,
metrics: NymNodeMetrics,
verloc: SharedVerlocStats,
load_cache_ttl: Duration,
) -> Self {
AppState {
static_information: Arc::new(static_information),
x25519_sphinx_keys,
// is it 100% accurate?
// no.
// does it have to be?
@@ -0,0 +1,160 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::node::key_rotation::key::SphinxPrivateKey;
use arc_swap::{ArcSwap, ArcSwapOption, Guard};
use std::ops::Deref;
use std::sync::Arc;
use tracing::error;
#[derive(Clone)]
pub(crate) struct ActiveSphinxKeys {
inner: Arc<ActiveSphinxKeysInner>,
}
struct ActiveSphinxKeysInner {
/// Key that's currently used as the default when processing packets with no explicit rotation information
primary_key: ArcSwap<SphinxPrivateKey>,
/// Optionally, a secondary key associated with this node. depending on the context it could either be
/// the pre-announced key for the following rotation or a key from the previous rotation for the overlap period
secondary_key: ArcSwapOption<SphinxPrivateKey>,
}
impl ActiveSphinxKeys {
pub(crate) fn new_fresh(primary: SphinxPrivateKey) -> Self {
ActiveSphinxKeys {
inner: Arc::new(ActiveSphinxKeysInner {
primary_key: ArcSwap::from_pointee(primary),
secondary_key: Default::default(),
}),
}
}
pub(crate) fn new_loaded(
primary: SphinxPrivateKey,
secondary: Option<SphinxPrivateKey>,
) -> Self {
ActiveSphinxKeys {
inner: Arc::new(ActiveSphinxKeysInner {
primary_key: ArcSwap::from_pointee(primary),
secondary_key: ArcSwapOption::from_pointee(secondary),
}),
}
}
pub(crate) fn even(&self) -> Option<SphinxKeyGuard> {
let primary = self.inner.primary_key.load();
if primary.is_even_rotation() {
return Some(SphinxKeyGuard::Primary(primary));
}
self.secondary()
}
pub(crate) fn odd(&self) -> Option<SphinxKeyGuard> {
let primary = self.inner.primary_key.load();
if !primary.is_even_rotation() {
return Some(SphinxKeyGuard::Primary(primary));
}
self.secondary()
}
pub(crate) fn primary(&self) -> SphinxKeyGuard {
SphinxKeyGuard::Primary(self.inner.primary_key.load())
}
pub(crate) fn secondary(&self) -> Option<SphinxKeyGuard> {
let guard = self.inner.secondary_key.load();
if guard.is_none() {
return None;
}
Some(SphinxKeyGuard::Secondary(SecondaryKeyGuard { guard }))
}
pub(crate) fn set_secondary(&self, new_key: SphinxPrivateKey) {
self.inner.secondary_key.store(Some(Arc::new(new_key)))
}
pub(crate) fn primary_key_rotation_id(&self) -> u32 {
self.inner.primary_key.load().rotation_id()
}
pub(crate) fn secondary_key_rotation_id(&self) -> Option<u32> {
self.inner
.secondary_key
.load()
.as_ref()
.map(|k| k.rotation_id())
}
// set the secondary (pre-announced key) as the primary
// and the current primary as the secondary (for the overlap epoch)
pub(crate) fn rotate(&self, expected_new_rotation: u32) -> bool {
let Some(pre_announced) = self.inner.secondary_key.load_full() else {
error!("sphinx key inconsistency - attempted to perform key rotation without having pre-announced new key");
return false;
};
if pre_announced.rotation_id() != expected_new_rotation {
error!("sphinx key inconsistency - pre-announced key rotation id != primary + 1");
return false;
}
let old_primary = self.inner.primary_key.swap(pre_announced);
self.inner.secondary_key.store(Some(old_primary));
true
}
pub(crate) fn deactivate_secondary(&self) {
self.inner.secondary_key.store(None);
}
}
pub(crate) enum SphinxKeyGuard {
// Primary(Guard<Arc<SphinxPrivateKey>>),
Primary(Guard<Arc<SphinxPrivateKey>>),
Secondary(SecondaryKeyGuard),
}
impl Deref for SphinxKeyGuard {
type Target = SphinxPrivateKey;
fn deref(&self) -> &Self::Target {
match self {
SphinxKeyGuard::Primary(g) => g.deref(),
SphinxKeyGuard::Secondary(g) => g.deref(),
}
}
}
// enum SecondaryKey {
// PreAnnounced(SphinxPrivateKey),
// PreviousOverlap(SphinxPrivateKey),
// }
// impl Deref for SecondaryKey {
// type Target = SphinxPrivateKey;
//
// fn deref(&self) -> &Self::Target {
// match self {
// SecondaryKey::PreAnnounced(key) => &key,
// SecondaryKey::PreviousOverlap(key) => &key,
// }
// }
// }
pub(crate) struct SecondaryKeyGuard {
guard: Guard<Option<Arc<SphinxPrivateKey>>>,
// guard: Guard<Option<Arc<SecondaryKey>>>,
}
impl Deref for SecondaryKeyGuard {
type Target = SphinxPrivateKey;
fn deref(&self) -> &Self::Target {
// SAFETY: the guard is ONLY constructed when the key is 'Some'
#[allow(clippy::unwrap_used)]
self.guard.as_ref().unwrap()
}
}
@@ -0,0 +1,381 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::config::Config;
use crate::node::key_rotation::manager::SphinxKeyManager;
use crate::node::nym_apis_client::NymApisClient;
use crate::node::replay_protection::manager::ReplayProtectionBloomfiltersManager;
use futures::pin_mut;
use nym_task::ShutdownToken;
use nym_validator_client::models::{KeyRotationInfoResponse, KeyRotationState};
use std::time::Duration;
use time::OffsetDateTime;
use tokio::time::{interval, sleep, Instant};
use tracing::{debug, error, info, trace, warn};
pub(crate) struct RotationConfig {
epoch_duration: Duration,
rotation_state: KeyRotationState,
}
impl RotationConfig {
fn rotation_lifetime(&self) -> Duration {
(self.rotation_state.validity_epochs + 1) * self.epoch_duration
}
}
impl From<KeyRotationInfoResponse> for RotationConfig {
fn from(value: KeyRotationInfoResponse) -> Self {
RotationConfig {
epoch_duration: value.epoch_duration,
rotation_state: value.key_rotation_state,
}
}
}
pub(crate) struct KeyRotationController {
// regular polling rate to catch any changes in the system config. they shouldn't happen too often
// so the requests can be sent quite infrequently
regular_polling_interval: Duration,
rotation_config: RotationConfig,
replay_protection_manager: ReplayProtectionBloomfiltersManager,
client: NymApisClient,
managed_keys: SphinxKeyManager,
shutdown_token: ShutdownToken,
}
struct NextAction {
typ: KeyRotationActionState,
deadline: OffsetDateTime,
}
impl NextAction {
fn new(typ: KeyRotationActionState, deadline: OffsetDateTime) -> Self {
NextAction { typ, deadline }
}
fn until_deadline(&self) -> Duration {
let now = OffsetDateTime::now_utc();
Duration::try_from(self.deadline - now).unwrap_or_else(|_| {
// deadline is already in the past
Duration::from_nanos(0)
})
}
fn wait(duration: Duration) -> NextAction {
NextAction::new(
KeyRotationActionState::Wait,
OffsetDateTime::now_utc() + duration,
)
}
fn pre_announce(rotation_id: u32, deadline: OffsetDateTime) -> Self {
NextAction::new(
KeyRotationActionState::PreAnnounce { rotation_id },
deadline,
)
}
fn swap_default(expected_new_rotation: u32, deadline: OffsetDateTime) -> Self {
NextAction::new(
KeyRotationActionState::SwapDefault {
expected_new_rotation,
},
deadline,
)
}
fn purge_secondary(deadline: OffsetDateTime) -> Self {
NextAction::new(KeyRotationActionState::PurgeOld, deadline)
}
}
#[derive(Debug, Clone, Copy)]
enum KeyRotationActionState {
// generate and pre-announce new key to the nym-api(s)
PreAnnounce { rotation_id: u32 },
// perform the following exchange
// primary -> secondary
// pre_announced -> primary
SwapDefault { expected_new_rotation: u32 },
// remove the old overlap key and purge associated data like the replay detection bloomfilter
PurgeOld,
// a no-op action that has only a single purpose - wait (used to handle slight desyncs)
Wait,
}
impl KeyRotationController {
pub(crate) fn new(
config: &Config,
rotation_config: RotationConfig,
client: NymApisClient,
replay_protection_manager: ReplayProtectionBloomfiltersManager,
managed_keys: SphinxKeyManager,
shutdown_token: ShutdownToken,
) -> Self {
KeyRotationController {
regular_polling_interval: config
.mixnet
.key_rotation
.debug
.rotation_state_poling_interval,
rotation_config,
replay_protection_manager,
client,
managed_keys,
shutdown_token,
}
}
async fn try_determine_next_action(&self) -> NextAction {
let now = OffsetDateTime::now_utc();
let Some(key_rotation_info) = self.try_get_key_rotation_info().await else {
warn!("failed to retrieve key rotation information");
return NextAction::wait(Duration::from_secs(240));
};
// check if we think the epoch is stuck (we're already 20% or more into following epoch with no advancement)
if key_rotation_info.is_epoch_stuck() {
warn!("the epoch is stuck - can't progress with key rotation");
return NextAction::wait(Duration::from_secs(240));
}
// >>>>> START: determine if we called this method pre-maturely due to clock skew
// current rotation id as determined by the current epoch id
let current_rotation_id = key_rotation_info.current_key_rotation_id();
// expected rotation id as determined by the current TIME
// used to determined epoch stalling or clocks being slightly out of sync
let expected_current_rotation_id = key_rotation_info.expected_current_rotation_id();
if current_rotation_id != expected_current_rotation_id {
warn!("the current rotation is {current_rotation_id} whilst we expected {expected_current_rotation_id}");
// if we got here, it means epoch is most likely NOT stuck (we're within the threshold)
// so probably we prematurely called this method before nym-api(s) got to advancing
// the epoch and thus the rotation, so wait a bit instead.
return NextAction::wait(Duration::from_secs(30));
}
// >>>>> END: determine if we called this method pre-maturely due to clock skew
// if we're less than 30s until next rotation, we probably started our binary in a rather
// unfortunate time, just wait until the next rotation rather than do all the work only to throw it
// away immediately
let Some(until_next_rotation) = key_rotation_info.until_next_rotation() else {
warn!("failed to determine time remaining until the next key rotation");
return NextAction::wait(Duration::from_secs(30));
};
if until_next_rotation < Duration::from_secs(30) {
debug!("less than 30s until next rotation - waiting until until then");
return NextAction::wait(Duration::from_secs(30));
}
let current_epoch = key_rotation_info.current_absolute_epoch_id;
// epoch id of when the current rotation has started
let current_rotation_start_epoch = key_rotation_info.current_rotation_starting_epoch_id();
// epoch id of when the new rotation id is meant to start
let next_rotation_start_epoch = key_rotation_info.next_rotation_starting_epoch_id();
let secondary_key_rotation_id = self.managed_keys.keys.secondary_key_rotation_id();
let primary_key_rotation_id = self.managed_keys.keys.primary_key_rotation_id();
debug!(
"current rotation: {current_rotation_id}, primary: {}, secondary: {secondary_key_rotation_id:?}",
self.managed_keys.keys.primary_key_rotation_id()
);
let rotates_next_epoch = next_rotation_start_epoch == current_epoch + 1;
let next_rotation_id = current_rotation_id + 1;
let Some(secondary_key_rotation_id) = secondary_key_rotation_id else {
debug!("we don't have a secondary key");
// figure out if we already have appropriate key (like we crashed or this is the first time node is running)
// or whether we have to regenerate anything or, which is the most likely case, we're waiting to
// pre-announce new key for the following rotation
if primary_key_rotation_id != current_rotation_id {
warn!("current primary key does not correspond to the current rotation - immediately pre-announcing new key (rotates next epoch: {rotates_next_epoch}");
// we don't have a secondary key and our current key is already outdated -
// preannounce a key for either this or the next rotation
// (and next time this method is called, it will be promoted to primary)
return if rotates_next_epoch {
NextAction::pre_announce(next_rotation_id, now)
} else {
NextAction::pre_announce(current_rotation_id, now)
};
}
// we have a primary key corresponding to the current rotation, so we just have to pre-announce
// a key for the next rotation an epoch before the rotation
let deadline = key_rotation_info.epoch_start_time(next_rotation_start_epoch - 1);
debug!(
"going to pre-announce secondary key for rotation {next_rotation_id} on {deadline}"
);
return NextAction::pre_announce(next_rotation_id, deadline);
};
// the current secondary key corresponds to the next rotation, i.e. this is the pre-announced key
if secondary_key_rotation_id == next_rotation_id {
debug!("secondary key is for the NEXT rotation - we need to swap into it");
let deadline = key_rotation_info.epoch_start_time(next_rotation_start_epoch);
return NextAction::swap_default(next_rotation_id, deadline);
}
if secondary_key_rotation_id == current_rotation_id {
debug!("secondary key is for the CURRENT rotation - we need to swap into it");
return NextAction::swap_default(current_rotation_id, now);
}
if secondary_key_rotation_id < current_rotation_id {
let deadline = if secondary_key_rotation_id == current_rotation_id - 1 {
debug!("secondary key is from the PREVIOUS rotations - we need to purge it");
// we purge the key after the end of overlap period, i.e. during the 2nd epoch of a rotation
key_rotation_info.epoch_start_time(current_rotation_start_epoch + 1)
} else {
debug!("secondary key is from AN OLD rotation - we need to purge it");
// the key is from some old rotation, we were probably offline for some time - we need to pre-announce new key
// for the upcoming rotation, so start off by purging this key immediately
now
};
return NextAction::purge_secondary(deadline);
}
// at this point all branches should have been covered, i.e. missing secondary key,
// secondary key == next rotation
// secondary key == current rotation
// secondary key < current rotation
// the only, theoretical, branch is if secondary key was from few rotations in the future,
// but this would require some weird chain shenanigans
error!("this code branch should have been unreachable - please report if you see this error with the following information:\
primary_key_rotation = {primary_key_rotation_id},
secondary_key_rotation = {secondary_key_rotation_id},
current_rotation = {current_rotation_id},
next_rotation = {next_rotation_id},
raw_response = {key_rotation_info:?}");
NextAction::wait(Duration::from_secs(240))
}
async fn try_get_key_rotation_info(&self) -> Option<KeyRotationInfoResponse> {
let Ok(rotation_info) = self.client.get_key_rotation_info().await else {
warn!("failed to retrieve key rotation information from ANY nym-api - we might miss configuration changes");
return None;
};
Some(rotation_info)
}
async fn pre_announce_new_key(&self, rotation_id: u32) {
info!("pre-announcing new key for rotation {rotation_id}");
if let Err(err) = self.managed_keys.generate_key_for_new_rotation(rotation_id) {
error!("failed to generate and store new sphinx key: {err}");
return;
};
if self
.replay_protection_manager
.allocate_pre_announced(rotation_id, self.rotation_config.rotation_lifetime())
.is_err()
{
// mutex poisoning - we have to exit
self.shutdown_token.cancel();
}
// no need to send the information explicitly to nym-apis, as they're scheduled to refresh
// self-described endpoints of all nodes before the key rotation epoch rolls over
}
fn swap_default_key(&self, expected_new_rotation: u32) {
info!("attempting to swap the primary key to the previously generated one");
if let Err(err) = self.managed_keys.rotate_keys(expected_new_rotation) {
error!("failed to perform sphinx key swap: {err}")
};
if self
.replay_protection_manager
.promote_pre_announced()
.is_err()
{
// mutex poisoning - we have to exit
self.shutdown_token.cancel();
}
}
fn purge_old_rotation_data(&self) {
info!("purging data associated with the old sphinx key");
if let Err(err) = self.managed_keys.remove_overlap_key() {
error!("failed to remove old sphinx key: {err}");
};
if self.replay_protection_manager.purge_secondary().is_err() {
// mutex poisoning - we have to exit
self.shutdown_token.cancel();
}
}
async fn execute_next_action(&self, action: KeyRotationActionState) {
match action {
KeyRotationActionState::PreAnnounce { rotation_id } => {
self.pre_announce_new_key(rotation_id).await
}
KeyRotationActionState::SwapDefault {
expected_new_rotation,
} => self.swap_default_key(expected_new_rotation),
KeyRotationActionState::PurgeOld => {
self.purge_old_rotation_data();
}
KeyRotationActionState::Wait => {}
}
}
pub(crate) async fn run(&self) {
info!("starting sphinx key rotation controller");
let mut polling_interval = interval(self.regular_polling_interval);
polling_interval.reset();
let mut next_action = self.try_determine_next_action().await;
debug!(
"next key rotation action to take: {:?} at {}",
next_action.typ, next_action.deadline
);
let state_update_future = sleep(next_action.until_deadline());
pin_mut!(state_update_future);
while !self.shutdown_token.is_cancelled() {
tokio::select! {
biased;
_ = self.shutdown_token.cancelled() => {
trace!("KeyRotationController: Received shutdown");
break;
}
_ = polling_interval.tick() => {}
_ = &mut state_update_future => {
self.execute_next_action(next_action.typ).await
}
}
next_action = self.try_determine_next_action().await;
debug!(
"next key rotation action to take: {:?} at {}",
next_action.typ, next_action.deadline
);
state_update_future
.as_mut()
.reset(Instant::now() + next_action.until_deadline());
}
trace!("KeyRotationController: exiting")
}
pub(crate) fn start(self) {
tokio::spawn(async move { self.run().await });
}
}
+137
View File
@@ -0,0 +1,137 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use nym_crypto::aes::cipher::crypto_common::rand_core::{CryptoRng, RngCore};
use nym_crypto::asymmetric::x25519;
use nym_pemstore::traits::PemStorableKey;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum MalformedSphinxKey {
#[error("inner x25519 key is malformed: {0}")]
X25519Failure(#[from] x25519::KeyRecoveryError),
#[error("did not receive sufficient number of bytes to recover the key")]
Incomplete,
}
pub(crate) struct SphinxPrivateKey {
rotation_id: u32,
inner: x25519::PrivateKey,
}
impl SphinxPrivateKey {
pub(crate) fn new<R: RngCore + CryptoRng>(rng: &mut R, rotation_id: u32) -> Self {
SphinxPrivateKey {
rotation_id,
inner: x25519::PrivateKey::new(rng),
}
}
pub(crate) fn import(key: x25519::PrivateKey, rotation_id: u32) -> Self {
SphinxPrivateKey {
rotation_id,
inner: key,
}
}
pub(crate) fn x25519_pubkey(&self) -> x25519::PublicKey {
self.inner.public_key()
}
pub(crate) fn inner(&self) -> &x25519::PrivateKey {
&self.inner
}
pub(crate) fn is_even_rotation(&self) -> bool {
self.rotation_id & 1 == 0
}
pub(crate) fn rotation_id(&self) -> u32 {
self.rotation_id
}
}
impl From<&SphinxPrivateKey> for SphinxPublicKey {
fn from(value: &SphinxPrivateKey) -> Self {
SphinxPublicKey {
rotation_id: value.rotation_id,
inner: (&value.inner).into(),
}
}
}
impl AsRef<x25519::PrivateKey> for SphinxPrivateKey {
fn as_ref(&self) -> &x25519::PrivateKey {
&self.inner
}
}
pub(crate) struct SphinxPublicKey {
pub(crate) rotation_id: u32,
pub(crate) inner: x25519::PublicKey,
}
impl AsRef<x25519::PublicKey> for SphinxPublicKey {
fn as_ref(&self) -> &x25519::PublicKey {
&self.inner
}
}
impl PemStorableKey for SphinxPrivateKey {
type Error = MalformedSphinxKey;
fn pem_type() -> &'static str {
// it's fine (and actually desired) to attach 'SPHINX' here, as this is not a valid X25519 key by itself.
// this is because it also contains the encoded rotation id
"X25519 SPHINX PRIVATE KEY"
}
fn to_bytes(&self) -> Vec<u8> {
self.rotation_id
.to_be_bytes()
.into_iter()
.chain(self.inner.to_bytes())
.collect()
}
fn from_bytes(bytes: &[u8]) -> Result<Self, Self::Error> {
if bytes.len() != x25519::PRIVATE_KEY_SIZE + 4 {
return Err(MalformedSphinxKey::Incomplete);
}
// SAFETY: we just checked we have sufficient bytes available
#[allow(clippy::unwrap_used)]
let rotation_id = u32::from_be_bytes(bytes[..4].try_into().unwrap());
Ok(SphinxPrivateKey {
rotation_id,
inner: x25519::PrivateKey::from_bytes(&bytes[4..])?,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use rand::SeedableRng;
use rand_chacha::ChaCha20Rng;
#[test]
fn private_key_bytes_convertion() {
// Set up a deterministic RNG.
let seed = [42u8; 32];
let mut rng = ChaCha20Rng::from_seed(seed);
let key = SphinxPrivateKey {
rotation_id: 42,
inner: x25519::PrivateKey::new(&mut rng),
};
let bytes = key.to_bytes();
assert_eq!(bytes.len(), 36); // 32 bytes for x25519 key and 4 bytes for rotation id
let recovered_key = SphinxPrivateKey::from_bytes(bytes.as_slice()).unwrap();
assert_eq!(recovered_key.rotation_id, 42);
assert_eq!(recovered_key.inner.to_bytes(), key.inner.to_bytes());
}
}
+187
View File
@@ -0,0 +1,187 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::error::{KeyIOFailure, NymNodeError};
use crate::node::helpers::{load_key, store_key};
use crate::node::key_rotation::active_keys::ActiveSphinxKeys;
use crate::node::key_rotation::key::{SphinxPrivateKey, SphinxPublicKey};
use rand::rngs::OsRng;
use rand::{CryptoRng, RngCore};
use std::fs;
use std::path::{Path, PathBuf};
use tracing::{trace, warn};
pub(crate) struct SphinxKeyManager {
pub(crate) keys: ActiveSphinxKeys,
primary_key_path: PathBuf,
secondary_key_path: PathBuf,
}
impl SphinxKeyManager {
// only called by newly initialised nym-nodes
pub(crate) fn initialise_new<R, P>(
rng: &mut R,
current_rotation_id: u32,
primary_key_path: P,
secondary_key_path: P,
) -> Result<Self, NymNodeError>
where
R: RngCore + CryptoRng,
P: AsRef<Path>,
{
let primary = SphinxPrivateKey::new(rng, current_rotation_id);
trace!("attempting to store primary x25519 sphinx key");
let primary_key_path = primary_key_path.as_ref();
store_key(&primary, primary_key_path, "x25519 sphinx")?;
Ok(SphinxKeyManager {
keys: ActiveSphinxKeys::new_fresh(primary),
primary_key_path: primary_key_path.to_path_buf(),
secondary_key_path: secondary_key_path.as_ref().to_path_buf(),
})
}
// moves the primary key to the secondary file
// and vice verse, i.e. secondary to the primary
fn swap_key_files<P: AsRef<Path>>(
primary_path: P,
secondary_path: P,
) -> Result<(), NymNodeError> {
let tmp_path = primary_path.as_ref().with_extension("tmp");
// 1. COPY: primary -> temp
fs::copy(primary_path.as_ref(), &tmp_path).map_err(|err| KeyIOFailure::KeyCopyFailure {
key: "old x25519 sphinx primary".to_string(),
source: primary_path.as_ref().to_path_buf(),
destination: tmp_path.clone(),
err,
})?;
// 2. MOVE: secondary -> primary
fs::rename(secondary_path.as_ref(), primary_path.as_ref()).map_err(|err| {
KeyIOFailure::KeyMoveFailure {
key: "x25519 sphinx secondary".to_string(),
source: secondary_path.as_ref().to_path_buf(),
destination: primary_path.as_ref().to_path_buf(),
err,
}
})?;
// 3. MOVE temp -> secondary
fs::rename(&tmp_path, secondary_path.as_ref()).map_err(|err| {
KeyIOFailure::KeyMoveFailure {
key: "old x25519 sphinx primary".to_string(),
source: tmp_path.clone(),
destination: primary_path.as_ref().to_path_buf(),
err,
}
})?;
Ok(())
}
pub(crate) fn generate_key_for_new_rotation(
&self,
expected_rotation: u32,
) -> Result<SphinxPublicKey, NymNodeError> {
let mut rng = OsRng;
let new = SphinxPrivateKey::new(&mut rng, expected_rotation);
let pub_key = (&new).into();
store_key(
&new,
&self.secondary_key_path,
"x22519 (pre-announced) sphinx",
)?;
self.keys.set_secondary(new);
Ok(pub_key)
}
pub(crate) fn rotate_keys(&self, expected_new_rotation: u32) -> Result<(), NymNodeError> {
if !self.keys.rotate(expected_new_rotation) {
self.generate_key_for_new_rotation(expected_new_rotation)?;
self.keys.rotate(expected_new_rotation);
}
Self::swap_key_files(&self.primary_key_path, &self.secondary_key_path)
}
pub(crate) fn remove_overlap_key(&self) -> Result<(), NymNodeError> {
self.keys.deactivate_secondary();
fs::remove_file(&self.secondary_key_path).map_err(|err| {
KeyIOFailure::KeyRemovalFailure {
key: "old x25519 sphinx secondary".to_string(),
path: self.secondary_key_path.clone(),
err,
}
})?;
Ok(())
}
pub(crate) fn try_load_or_regenerate<P: AsRef<Path>>(
current_rotation_id: u32,
primary_key_path: P,
secondary_key_path: P,
) -> Result<Self, NymNodeError> {
// if the temporary key exists, it means we crashed in the middle of rotating the key.
// rather than trying to figure out which exact step failed, just delete it and it will be redone
// (we still have the two keys, they just might be in the wrong order)
let tmp_location = primary_key_path.as_ref().with_extension("tmp");
if tmp_location.exists() {
warn!("we seem to have crashed in the middle of rotating the sphinx key");
fs::remove_file(&tmp_location).map_err(|err| KeyIOFailure::KeyRemovalFailure {
key: "old x25519 sphinx (temp location)".to_string(),
path: tmp_location,
err,
})?;
}
// primary key should always be present
let mut primary: SphinxPrivateKey =
load_key(primary_key_path.as_ref(), "x25519 sphinx primary")?;
let mut secondary: Option<SphinxPrivateKey> = if secondary_key_path.as_ref().exists() {
Some(load_key(
secondary_key_path.as_ref(),
"x25519 sphinx secondary",
)?)
} else {
None
};
let primary_id = primary.rotation_id();
let secondary_id = secondary.as_ref().map(|k| k.rotation_id());
// 1. check for failed (or missed) rotation, i.e. secondary > primary AND current_rotation > primary
if let Some(secondary_id) = secondary_id {
if secondary_id > primary_id && current_rotation_id > primary_id {
Self::swap_key_files(primary_key_path.as_ref(), secondary_key_path.as_ref())?;
// SAFETY: we just checked secondary exists
#[allow(clippy::unwrap_used)]
let tmp = secondary.take().unwrap();
secondary = Some(primary);
primary = tmp;
}
}
// if upon loading it turns out that the node has been inactive for a long time,
// immediately rotate keys (but leave 1h grace period for current primary, i.e. set it as secondary)
if primary.rotation_id() != current_rotation_id {
warn!("this node has been inactive for more than a key rotation duration. the current primary key was generated for rotation {} while the current rotation is {current_rotation_id}. new key will be generated now.", primary.rotation_id());
let this = SphinxKeyManager {
keys: ActiveSphinxKeys::new_loaded(primary, None),
primary_key_path: primary_key_path.as_ref().to_path_buf(),
secondary_key_path: secondary_key_path.as_ref().to_path_buf(),
};
this.generate_key_for_new_rotation(current_rotation_id)?;
return Ok(this);
}
Ok(SphinxKeyManager {
keys: ActiveSphinxKeys::new_loaded(primary, secondary),
primary_key_path: primary_key_path.as_ref().to_path_buf(),
secondary_key_path: secondary_key_path.as_ref().to_path_buf(),
})
}
}
+7
View File
@@ -0,0 +1,7 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
pub(crate) mod active_keys;
pub(crate) mod controller;
pub(crate) mod key;
pub(crate) mod manager;
+133 -32
View File
@@ -8,9 +8,11 @@ use nym_sphinx_framing::codec::NymCodec;
use nym_sphinx_framing::packet::FramedNymPacket;
use nym_sphinx_framing::processing::{
process_framed_packet, MixProcessingResult, MixProcessingResultData, PacketProcessingError,
PartiallyUnwrappedPacket, ProcessedFinalHop,
PartiallyUnwrappedPacket, PartialyUnwrappedPacketWithKeyRotation, ProcessedFinalHop,
};
use nym_sphinx_params::SphinxKeyRotation;
use nym_sphinx_types::{Delay, REPLAY_TAG_SIZE};
use std::collections::HashMap;
use std::mem;
use std::net::SocketAddr;
use tokio::net::TcpStream;
@@ -19,41 +21,50 @@ use tokio_util::codec::Framed;
use tracing::{debug, error, instrument, trace, warn};
struct PendingReplayCheckPackets {
packets: Vec<PartiallyUnwrappedPacket>,
// map of rotation id used for packet creation to the packets
packets: HashMap<u32, Vec<PartiallyUnwrappedPacket>>,
last_acquired_mutex: Instant,
}
impl PendingReplayCheckPackets {
fn new() -> PendingReplayCheckPackets {
PendingReplayCheckPackets {
packets: vec![],
packets: Default::default(),
last_acquired_mutex: Instant::now(),
}
}
fn reset(&mut self, now: Instant) -> Vec<PartiallyUnwrappedPacket> {
fn reset(&mut self, now: Instant) -> HashMap<u32, Vec<PartiallyUnwrappedPacket>> {
self.last_acquired_mutex = now;
mem::take(&mut self.packets)
}
fn push(&mut self, now: Instant, packet: PartiallyUnwrappedPacket) {
fn push(&mut self, now: Instant, packet: PartialyUnwrappedPacketWithKeyRotation) {
if self.packets.is_empty() {
self.last_acquired_mutex = now;
}
self.packets.push(packet);
self.packets
.entry(packet.used_key_rotation)
.or_default()
.push(packet.packet)
}
fn replay_tags(&self) -> Vec<&[u8; REPLAY_TAG_SIZE]> {
let mut replay_tags = Vec::with_capacity(self.packets.len());
for packet in &self.packets {
let Some(replay_tag) = packet.replay_tag() else {
error!(
"corrupted batch of {} packets - replay tag was missing",
self.packets.len()
);
return Vec::new();
};
replay_tags.push(replay_tag);
fn replay_tags(&self) -> HashMap<u32, Vec<&[u8; REPLAY_TAG_SIZE]>> {
let mut replay_tags = HashMap::with_capacity(self.packets.len());
'outer: for (rotation_id, packets) in &self.packets {
let mut rotation_replay_tags = Vec::with_capacity(packets.len());
for packet in packets {
let Some(replay_tag) = packet.replay_tag() else {
error!(
"corrupted batch of {} packets - replay tag was missing",
self.packets.len()
);
replay_tags.insert(*rotation_id, Vec::new());
continue 'outer;
};
rotation_replay_tags.push(replay_tag);
}
replay_tags.insert(*rotation_id, rotation_replay_tags);
}
replay_tags
}
@@ -212,6 +223,56 @@ impl ConnectionHandler {
time_threshold && count_threshold
}
fn try_partially_unwrap_packet(
&self,
packet: FramedNymPacket,
) -> Result<PartialyUnwrappedPacketWithKeyRotation, PacketProcessingError> {
// based on the received sphinx key rotation information,
// attempt to choose appropriate key for processing the packet
match packet.header().key_rotation {
SphinxKeyRotation::Unknown => {
let primary = self.shared.sphinx_keys.primary();
let primary_rotation = primary.rotation_id();
// we have to try both keys, start with the primary as it has higher likelihood of being correct
// if let Ok(partially_unwrapped) = PartiallyUnwrappedPacket::new()
match PartiallyUnwrappedPacket::new(packet, primary.inner().as_ref()) {
Ok(unwrapped_packet) => {
Ok(unwrapped_packet.with_key_rotation(primary_rotation))
}
Err((packet, err)) => {
if let Some(secondary) = self.shared.sphinx_keys.secondary() {
let secondary_rotation = secondary.rotation_id();
PartiallyUnwrappedPacket::new(packet, secondary.inner().as_ref())
.map_err(|(_, err)| err)
.map(|p| p.with_key_rotation(secondary_rotation))
} else {
Err(err)
}
}
}
}
SphinxKeyRotation::OddRotation => {
let Some(odd_key) = self.shared.sphinx_keys.odd() else {
return Err(PacketProcessingError::ExpiredKey);
};
let odd_rotation = odd_key.rotation_id();
PartiallyUnwrappedPacket::new(packet, odd_key.inner().as_ref())
.map_err(|(_, err)| err)
.map(|p| p.with_key_rotation(odd_rotation))
}
SphinxKeyRotation::EvenRotation => {
let Some(even_key) = self.shared.sphinx_keys.even() else {
return Err(PacketProcessingError::ExpiredKey);
};
let even_rotation = even_key.rotation_id();
PartiallyUnwrappedPacket::new(packet, even_key.inner().as_ref())
.map_err(|(_, err)| err)
.map(|p| p.with_key_rotation(even_rotation))
}
}
}
async fn handle_received_packet_with_replay_detection(
&mut self,
now: Instant,
@@ -219,10 +280,7 @@ impl ConnectionHandler {
) {
// 1. derive and expand shared secret
// also check the header integrity
let partially_unwrapped = match PartiallyUnwrappedPacket::new(
packet,
self.shared.sphinx_keys.private_key().as_ref(),
) {
let partially_unwrapped = match self.try_partially_unwrap_packet(packet) {
Ok(unwrapped) => unwrapped,
Err(err) => {
trace!("failed to process received mix packet: {err}");
@@ -277,17 +335,24 @@ impl ConnectionHandler {
async fn handle_post_replay_detection_packets(
&self,
now: Instant,
packets: Vec<PartiallyUnwrappedPacket>,
replay_check_results: Vec<bool>,
packets: HashMap<u32, Vec<PartiallyUnwrappedPacket>>,
replay_check_results: HashMap<u32, Vec<bool>>,
) {
for (packet, replayed) in packets.into_iter().zip(replay_check_results) {
let unwrapped_packet = if replayed {
Err(PacketProcessingError::PacketReplay)
} else {
packet.finalise_unwrapping()
for (rotation_id, packets) in packets {
let Some(replay_checks) = replay_check_results.get(&rotation_id) else {
// this should never happen, but if we messed up, and it does, don't panic, just drop the packets
error!("inconsistent replay check result - no values for rotation {rotation_id}");
continue;
};
for (packet, &replayed) in packets.into_iter().zip(replay_checks) {
let unwrapped_packet = if replayed {
Err(PacketProcessingError::PacketReplay)
} else {
packet.finalise_unwrapping()
};
self.handle_unwrapped_packet(now, unwrapped_packet).await;
self.handle_unwrapped_packet(now, unwrapped_packet).await;
}
}
}
@@ -340,6 +405,43 @@ impl ConnectionHandler {
.await;
}
fn try_full_unwrap_packet(
&self,
packet: FramedNymPacket,
) -> Result<MixProcessingResult, PacketProcessingError> {
// based on the received sphinx key rotation information,
// attempt to choose appropriate key for processing the packet
// NOTE: due to the function signatures, outfox packets will **only** attempt primary key
// if no rotation information is available (but that's fine given outfox is not really in use,
// and by the time we need it, the rotation info should be present)
match packet.header().key_rotation {
SphinxKeyRotation::Unknown => {
process_framed_packet(packet, self.shared.sphinx_keys.primary().inner().as_ref())
}
SphinxKeyRotation::OddRotation => {
let Some(odd_key) = self.shared.sphinx_keys.odd() else {
return Err(PacketProcessingError::ExpiredKey);
};
process_framed_packet(packet, odd_key.inner().as_ref())
}
SphinxKeyRotation::EvenRotation => {
let Some(even_key) = self.shared.sphinx_keys.even() else {
return Err(PacketProcessingError::ExpiredKey);
};
process_framed_packet(packet, even_key.inner().as_ref())
}
}
}
async fn handle_received_packet_with_no_replay_detection(
&mut self,
now: Instant,
packet: FramedNymPacket,
) {
let unwrapped_packet = self.try_full_unwrap_packet(packet);
self.handle_unwrapped_packet(now, unwrapped_packet).await;
}
#[instrument(skip(self, packet), level = "debug")]
async fn handle_received_nym_packet(&mut self, packet: FramedNymPacket) {
let now = Instant::now();
@@ -352,9 +454,8 @@ impl ConnectionHandler {
} else {
// otherwise just skip that whole procedure and go straight to payload unwrapping
// (assuming the basic framing is valid)
let unwrapped_packet =
process_framed_packet(packet, self.shared.sphinx_keys.private_key().as_ref());
self.handle_unwrapped_packet(now, unwrapped_packet).await;
self.handle_received_packet_with_no_replay_detection(now, packet)
.await;
};
}
@@ -58,32 +58,20 @@ impl<C, F> PacketForwarder<C, F> {
C: SendWithoutResponse,
F: RoutingFilter,
{
let next_hop = packet.next_hop();
let next_hop = packet.next_hop_address();
let packet_type = packet.packet_type();
let packet = packet.into_packet();
if let Err(err) = self
.mixnet_client
.send_without_response(next_hop, packet, packet_type)
{
if let Err(err) = self.mixnet_client.send_without_response(packet) {
if err.kind() == io::ErrorKind::WouldBlock {
// we only know for sure if we dropped a packet if our sending queue was full
// in any other case the connection might still be re-established (or created for the first time)
// and the packet might get sent, but we won't know about it
self.metrics
.mixnet
.egress_dropped_forward_packet(next_hop.into())
self.metrics.mixnet.egress_dropped_forward_packet(next_hop)
} else if err.kind() == io::ErrorKind::NotConnected {
// let's give the benefit of the doubt and assume we manage to establish connection
self.metrics
.mixnet
.egress_sent_forward_packet(next_hop.into())
self.metrics.mixnet.egress_sent_forward_packet(next_hop)
}
} else {
self.metrics
.mixnet
.egress_sent_forward_packet(next_hop.into())
self.metrics.mixnet.egress_sent_forward_packet(next_hop)
}
}
+7 -8
View File
@@ -2,10 +2,10 @@
// SPDX-License-Identifier: GPL-3.0-only
use crate::config::Config;
use crate::node::key_rotation::active_keys::ActiveSphinxKeys;
use crate::node::mixnet::handler::ConnectionHandler;
use crate::node::mixnet::SharedFinalHopData;
use crate::node::replay_protection::bloomfilter::ReplayProtectionBloomfilter;
use nym_crypto::asymmetric::x25519;
use crate::node::replay_protection::bloomfilter::ReplayProtectionBloomfilters;
use nym_gateway::node::GatewayStorageError;
use nym_mixnet_client::forwarder::{MixForwardingSender, PacketToForward};
use nym_node_metrics::mixnet::PacketKind;
@@ -18,7 +18,6 @@ use nym_sphinx_types::DestinationAddressBytes;
use nym_task::ShutdownToken;
use std::io;
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
use tokio::net::TcpStream;
use tokio::task::JoinHandle;
@@ -66,8 +65,8 @@ impl ProcessingConfig {
// explicitly do NOT derive clone as we want to manually apply relevant suffixes to the task clients
pub(crate) struct SharedData {
pub(super) processing_config: ProcessingConfig,
pub(super) sphinx_keys: Arc<x25519::KeyPair>,
pub(super) replay_protection_filter: ReplayProtectionBloomfilter,
pub(super) sphinx_keys: ActiveSphinxKeys,
pub(super) replay_protection_filter: ReplayProtectionBloomfilters,
// used for FORWARD mix packets and FINAL ack packets
pub(super) mixnet_forwarder: MixForwardingSender,
@@ -89,8 +88,8 @@ fn convert_to_metrics_version(processed: MixPacketVersion) -> PacketKind {
impl SharedData {
pub(crate) fn new(
processing_config: ProcessingConfig,
x25519_keys: Arc<x25519::KeyPair>,
replay_protection_filter: ReplayProtectionBloomfilter,
sphinx_keys: ActiveSphinxKeys,
replay_protection_filter: ReplayProtectionBloomfilters,
mixnet_forwarder: MixForwardingSender,
final_hop: SharedFinalHopData,
metrics: NymNodeMetrics,
@@ -98,7 +97,7 @@ impl SharedData {
) -> Self {
SharedData {
processing_config,
sphinx_keys: x25519_keys,
sphinx_keys,
replay_protection_filter,
mixnet_forwarder,
final_hop,
+146 -116
View File
@@ -9,15 +9,17 @@ use crate::config::{
use crate::error::{EntryGatewayError, NymNodeError, ServiceProvidersError};
use crate::node::description::{load_node_description, save_node_description};
use crate::node::helpers::{
load_ed25519_identity_keypair, load_key, load_x25519_noise_keypair, load_x25519_sphinx_keypair,
get_current_rotation_id, load_ed25519_identity_keypair, load_key, load_x25519_noise_keypair,
store_ed25519_identity_keypair, store_key, store_keypair, store_x25519_noise_keypair,
store_x25519_sphinx_keypair, DisplayDetails,
DisplayDetails,
};
use crate::node::http::api::api_requests;
use crate::node::http::helpers::sign_host_details;
use crate::node::http::helpers::system_info::get_system_info;
use crate::node::http::state::AppState;
use crate::node::http::state::{AppState, StaticNodeInformation};
use crate::node::http::{HttpServerConfig, NymNodeHttpServer, NymNodeRouter};
use crate::node::key_rotation::active_keys::ActiveSphinxKeys;
use crate::node::key_rotation::controller::KeyRotationController;
use crate::node::key_rotation::manager::SphinxKeyManager;
use crate::node::metrics::aggregator::MetricsAggregator;
use crate::node::metrics::console_logger::ConsoleLogger;
use crate::node::metrics::handler::client_sessions::GatewaySessionStatsHandler;
@@ -28,10 +30,14 @@ use crate::node::metrics::handler::pending_egress_packets_updater::PendingEgress
use crate::node::mixnet::packet_forwarding::PacketForwarder;
use crate::node::mixnet::shared::ProcessingConfig;
use crate::node::mixnet::SharedFinalHopData;
use crate::node::replay_protection::background_task::ReplayProtectionBackgroundTask;
use crate::node::replay_protection::bloomfilter::ReplayProtectionBloomfilter;
use crate::node::nym_apis_client::NymApisClient;
use crate::node::replay_protection::background_task::ReplayProtectionDiskFlush;
use crate::node::replay_protection::bloomfilter::ReplayProtectionBloomfilters;
use crate::node::replay_protection::manager::ReplayProtectionBloomfiltersManager;
use crate::node::routing_filter::{OpenFilter, RoutingFilter};
use crate::node::shared_network::{CachedNetwork, CachedTopologyProvider, NetworkRefresher};
use crate::node::shared_network::{
CachedNetwork, CachedTopologyProvider, LocalGatewayNode, NetworkRefresher,
};
use nym_bin_common::bin_info;
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_gateway::node::{ActiveClientsStore, GatewayTasksBuilder};
@@ -47,29 +53,28 @@ use nym_node_requests::api::v1::node::models::{AnnouncePorts, NodeDescription};
use nym_sphinx_acknowledgements::AckKey;
use nym_sphinx_addressing::Recipient;
use nym_task::{ShutdownManager, ShutdownToken, TaskClient};
use nym_validator_client::client::NymApiClientExt;
use nym_validator_client::models::NodeRefreshBody;
use nym_validator_client::{NymApiClient, UserAgent};
use nym_validator_client::UserAgent;
use nym_verloc::measurements::SharedVerlocStats;
use nym_verloc::{self, measurements::VerlocMeasurer};
use nym_wireguard::{peer_controller::PeerControlRequest, WireguardGatewayData};
use rand::rngs::OsRng;
use rand::{CryptoRng, RngCore};
use std::net::SocketAddr;
use std::ops::Deref;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::timeout;
use tracing::{debug, info, trace, warn};
use tracing::{debug, info, trace};
use zeroize::Zeroizing;
pub mod bonding_information;
pub mod description;
pub mod helpers;
pub(crate) mod http;
pub(crate) mod key_rotation;
pub(crate) mod metrics;
pub(crate) mod mixnet;
mod nym_apis_client;
pub(crate) mod replay_protection;
mod routing_filter;
mod shared_network;
@@ -148,10 +153,10 @@ impl ServiceProvidersData {
store_keypair(
&ed25519_keys,
ed25519_paths,
&ed25519_paths,
format!("{typ}-ed25519-identity"),
)?;
store_keypair(&x25519_keys, x25519_paths, format!("{typ}-x25519-dh"))?;
store_keypair(&x25519_keys, &x25519_paths, format!("{typ}-x25519-dh"))?;
store_key(&aes128ctr_key, ack_key_path, format!("{typ}-ack-key"))?;
Ok(())
@@ -324,7 +329,7 @@ impl WireguardData {
let (inner, peer_rx) = WireguardGatewayData::new(
config.clone().into(),
Arc::new(load_x25519_wireguard_keypair(
config.storage_paths.x25519_wireguard_storage_paths(),
&config.storage_paths.x25519_wireguard_storage_paths(),
)?),
);
Ok(WireguardData { inner, peer_rx })
@@ -336,7 +341,7 @@ impl WireguardData {
store_keypair(
&x25519_keys,
config.storage_paths.x25519_wireguard_storage_paths(),
&config.storage_paths.x25519_wireguard_storage_paths(),
"wg-x25519-dh",
)?;
@@ -372,7 +377,7 @@ pub(crate) struct NymNode {
wireguard: Option<WireguardData>,
ed25519_identity_keys: Arc<ed25519::KeyPair>,
x25519_sphinx_keys: Arc<x25519::KeyPair>,
sphinx_key_manager: Option<SphinxKeyManager>,
// to be used when noise is integrated
#[allow(dead_code)]
@@ -389,25 +394,26 @@ impl NymNode {
// global initialisation
let ed25519_identity_keys = ed25519::KeyPair::new(&mut rng);
let x25519_sphinx_keys = x25519::KeyPair::new(&mut rng);
let x25519_noise_keys = x25519::KeyPair::new(&mut rng);
let current_rotation_id =
get_current_rotation_id(&config.mixnet.nym_api_urls, &config.mixnet.nyxd_urls).await?;
let _ = SphinxKeyManager::initialise_new(
&mut rng,
current_rotation_id,
&config.storage_paths.keys.primary_x25519_sphinx_key_file,
&config.storage_paths.keys.secondary_x25519_sphinx_key_file,
)?;
trace!("attempting to store ed25519 identity keypair");
store_ed25519_identity_keypair(
&ed25519_identity_keys,
config.storage_paths.keys.ed25519_identity_storage_paths(),
)?;
trace!("attempting to store x25519 sphinx keypair");
store_x25519_sphinx_keypair(
&x25519_sphinx_keys,
config.storage_paths.keys.x25519_sphinx_storage_paths(),
&config.storage_paths.keys.ed25519_identity_storage_paths(),
)?;
trace!("attempting to store x25519 noise keypair");
store_x25519_noise_keypair(
&x25519_noise_keys,
config.storage_paths.keys.x25519_noise_storage_paths(),
&config.storage_paths.keys.x25519_noise_storage_paths(),
)?;
trace!("creating description file");
@@ -434,16 +440,20 @@ impl NymNode {
pub(crate) async fn new(config: Config) -> Result<Self, NymNodeError> {
let wireguard_data = WireguardData::new(&config.wireguard)?;
let current_rotation_id =
get_current_rotation_id(&config.mixnet.nym_api_urls, &config.mixnet.nyxd_urls).await?;
Ok(NymNode {
ed25519_identity_keys: Arc::new(load_ed25519_identity_keypair(
config.storage_paths.keys.ed25519_identity_storage_paths(),
&config.storage_paths.keys.ed25519_identity_storage_paths(),
)?),
x25519_sphinx_keys: Arc::new(load_x25519_sphinx_keypair(
config.storage_paths.keys.x25519_sphinx_storage_paths(),
sphinx_key_manager: Some(SphinxKeyManager::try_load_or_regenerate(
current_rotation_id,
&config.storage_paths.keys.primary_x25519_sphinx_key_file,
&config.storage_paths.keys.secondary_x25519_sphinx_key_file,
)?),
x25519_noise_keys: Arc::new(load_x25519_noise_keypair(
config.storage_paths.keys.x25519_noise_storage_paths(),
&config.storage_paths.keys.x25519_noise_storage_paths(),
)?),
description: load_node_description(&config.storage_paths.description)?,
metrics: NymNodeMetrics::new(),
@@ -510,11 +520,13 @@ impl NymNode {
}
pub(crate) fn display_details(&self) -> Result<DisplayDetails, NymNodeError> {
let sphinx_keys = self.sphinx_keys()?;
Ok(DisplayDetails {
current_modes: self.config.modes,
description: self.description.clone(),
ed25519_identity_key: self.ed25519_identity_key().to_base58_string(),
x25519_sphinx_key: self.x25519_sphinx_key().to_base58_string(),
x25519_primary_sphinx_key: sphinx_keys.keys.primary().deref().into(),
x25519_secondary_sphinx_key: sphinx_keys.keys.secondary().map(|g| g.deref().into()),
x25519_noise_key: self.x25519_noise_key().to_base58_string(),
x25519_wireguard_key: self.x25519_wireguard_key()?.to_base58_string(),
exit_network_requester_address: self.exit_network_requester_address().to_string(),
@@ -531,22 +543,19 @@ impl NymNode {
self.ed25519_identity_keys.public_key()
}
pub(crate) fn x25519_sphinx_key(&self) -> &x25519::PublicKey {
self.x25519_sphinx_keys.public_key()
}
pub(crate) fn x25519_sphinx_keys(&self) -> Arc<x25519::KeyPair> {
self.x25519_sphinx_keys.clone()
}
pub(crate) fn x25519_noise_key(&self) -> &x25519::PublicKey {
self.x25519_noise_keys.public_key()
}
#[track_caller]
pub(crate) fn active_sphinx_keys(&self) -> Result<ActiveSphinxKeys, NymNodeError> {
Ok(self.sphinx_keys()?.keys.clone())
}
async fn build_network_refresher(&self) -> Result<NetworkRefresher, NymNodeError> {
NetworkRefresher::initialise_new(
self.config.debug.testnet,
self.user_agent(),
Self::user_agent(),
self.config.mixnet.nym_api_urls.clone(),
self.config.debug.topology_cache_ttl,
self.config.debug.routing_nodes_check_interval,
@@ -555,7 +564,7 @@ impl NymNode {
.await
}
fn as_gateway_topology_node(&self) -> Result<nym_topology::RoutingNode, NymNodeError> {
fn as_gateway_topology_node(&self) -> Result<LocalGatewayNode, NymNodeError> {
let ip_addresses = self.config.host.public_ips.clone();
let Some(ip) = ip_addresses.first() else {
@@ -575,21 +584,15 @@ impl NymNode {
.announce_ws_port
.unwrap_or(self.config.gateway_tasks.ws_bind_address.port());
Ok(nym_topology::RoutingNode {
node_id: u32::MAX,
Ok(LocalGatewayNode {
active_sphinx_keys: self.active_sphinx_keys()?.clone(),
mix_host,
entry: Some(nym_topology::EntryDetails {
identity_key: *self.ed25519_identity_key(),
entry: nym_topology::EntryDetails {
ip_addresses,
clients_ws_port,
hostname: self.config.host.hostname.clone(),
clients_wss_port: self.config.gateway_tasks.announce_wss_port,
}),
sphinx_key: *self.x25519_sphinx_key(),
identity_key: *self.ed25519_identity_key(),
supported_roles: nym_topology::SupportedRoles {
mixnode: false,
mixnet_entry: true,
mixnet_exit: true,
},
})
}
@@ -697,13 +700,6 @@ impl NymNode {
}
pub(crate) async fn build_http_server(&self) -> Result<NymNodeHttpServer, NymNodeError> {
let host_details = sign_host_details(
&self.config,
self.x25519_sphinx_keys.public_key(),
self.x25519_noise_keys.public_key(),
&self.ed25519_identity_keys,
)?;
let auxiliary_details = api_requests::v1::node::models::AuxiliaryDetails {
location: self.config.host.location,
announce_ports: AnnouncePorts {
@@ -773,7 +769,7 @@ impl NymNode {
policy: None,
};
let mut config = HttpServerConfig::new(host_details)
let mut config = HttpServerConfig::new()
.with_landing_page_assets(self.config.http.landing_page_assets_path.as_ref())
.with_mixnode_details(mixnode_details)
.with_gateway_details(gateway_details)
@@ -804,7 +800,20 @@ 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 {
None
} else {
Some(*self.x25519_noise_keys.public_key())
};
let app_state = AppState::new(
StaticNodeInformation {
ed25519_identity_keys: self.ed25519_identity_keys.clone(),
x25519_noise_key,
ip_addresses: self.config.host.public_ips.clone(),
hostname: self.config.host.hostname.clone(),
},
self.active_sphinx_keys()?.clone(),
self.metrics.clone(),
self.verloc_stats.clone(),
self.config.http.node_load_cache_ttl,
@@ -815,55 +824,20 @@ impl NymNode {
.await?)
}
fn user_agent(&self) -> UserAgent {
fn user_agent() -> UserAgent {
bin_info!().into()
}
async fn try_refresh_remote_nym_api_cache(&self) {
info!("attempting to request described cache refresh from nym-api...");
if self.config.mixnet.nym_api_urls.is_empty() {
warn!("no nym-api urls available");
return;
}
async fn try_refresh_remote_nym_api_cache(
&self,
client: &NymApisClient,
) -> Result<(), NymNodeError> {
info!("attempting to request described cache refresh from nym-api(s)...");
for nym_api_url in &self.config.mixnet.nym_api_urls {
info!("trying {nym_api_url}...");
let nym_api = match nym_http_api_client::ClientBuilder::new_with_urls(vec![nym_api_url
.clone()
.into()])
.no_hickory_dns()
.with_user_agent(self.user_agent())
.build::<&str>()
{
Ok(b) => b,
Err(e) => {
warn!("failed to build http client for \"{nym_api_url}\": {e}",);
continue;
}
};
let client = NymApiClient::from(nym_api);
// make new request every time in case previous one takes longer and invalidates the signature
let request = NodeRefreshBody::new(self.ed25519_identity_keys.private_key());
match timeout(
Duration::from_secs(10),
client.nym_api.force_refresh_describe_cache(&request),
)
.await
{
Ok(Ok(_)) => {
info!("managed to refresh own self-described data cache")
}
Ok(Err(request_failure)) => {
warn!("failed to resolve the refresh request: {request_failure}")
}
Err(_timeout) => {
warn!("timed out while attempting to resolve the request. the cache might be stale")
}
};
}
client
.broadcast_force_refresh(self.ed25519_identity_keys.private_key())
.await;
Ok(())
}
pub(crate) fn start_verloc_measurements(&self) {
@@ -872,7 +846,7 @@ impl NymNode {
self.config.verloc.bind_address
);
let mut base_agent = self.user_agent();
let mut base_agent = Self::user_agent();
base_agent.application = format!("{}-verloc", base_agent.application);
let config = nym_verloc::measurements::ConfigBuilder::new(
self.config.mixnet.nym_api_urls.clone(),
@@ -965,7 +939,6 @@ impl NymNode {
// >>>> END: register all relevant handlers
// console logger to preserve old mixnode functionalities
// if self.config.logging.debug.log_to_console {
if self.config.metrics.debug.log_stats_to_console {
ConsoleLogger::new(
self.config.metrics.debug.console_logging_update_interval,
@@ -985,30 +958,78 @@ impl NymNode {
pub(crate) async fn setup_replay_detection(
&self,
) -> Result<ReplayProtectionBloomfilter, NymNodeError> {
) -> Result<ReplayProtectionBloomfiltersManager, NymNodeError> {
if self.config.mixnet.replay_protection.debug.unsafe_disabled {
return Ok(ReplayProtectionBloomfilter::new_disabled());
return Ok(ReplayProtectionBloomfiltersManager::new_disabled(
self.metrics.clone(),
));
}
// create the background task for the bloomfilter
// to reset it and flush it to disk
let mut replay_detection_background = ReplayProtectionBackgroundTask::new(
let sphinx_keys = self.sphinx_keys()?;
let mut replay_detection_background = ReplayProtectionDiskFlush::new(
&self.config,
sphinx_keys.keys.primary_key_rotation_id(),
sphinx_keys.keys.secondary_key_rotation_id(),
self.metrics.clone(),
self.shutdown_manager
.clone_token("replay-detection-background"),
.clone_token("replay-detection-background-flush"),
)
.await?;
let replay_protection_bloomfilter = replay_detection_background.global_bloomfilter();
let bloomfilters_manager = replay_detection_background.bloomfilters_manager();
self.shutdown_manager
.spawn(async move { replay_detection_background.run().await });
Ok(replay_protection_bloomfilter)
Ok(bloomfilters_manager)
}
// I'm assuming this will be needed in other places, so it's explicitly extracted
fn setup_nym_apis_client(&self) -> Result<NymApisClient, NymNodeError> {
NymApisClient::new(
&self.config.mixnet.nym_api_urls,
self.shutdown_manager.clone_token("nym-apis-client"),
)
}
#[track_caller]
fn sphinx_keys(&self) -> Result<&SphinxKeyManager, NymNodeError> {
self.sphinx_key_manager
.as_ref()
.ok_or(NymNodeError::ConsumedSphinxKeys)
}
fn take_managed_sphinx_keys(&mut self) -> Result<SphinxKeyManager, NymNodeError> {
self.sphinx_key_manager
.take()
.ok_or(NymNodeError::ConsumedSphinxKeys)
}
pub(crate) async fn setup_key_rotation(
&mut self,
nym_apis_client: NymApisClient,
replay_protection_manager: ReplayProtectionBloomfiltersManager,
) -> Result<(), NymNodeError> {
let managed_keys = self.take_managed_sphinx_keys()?;
let rotation_state = nym_apis_client.get_key_rotation_info().await?;
let rotation_controller = KeyRotationController::new(
&self.config,
rotation_state.into(),
nym_apis_client,
replay_protection_manager,
managed_keys,
self.shutdown_manager.clone_token("key-rotation-controller"),
);
rotation_controller.start();
Ok(())
}
pub(crate) async fn start_mixnet_listener<F>(
&self,
active_clients_store: &ActiveClientsStore,
replay_protection_bloomfilter: ReplayProtectionBloomfilters,
routing_filter: F,
shutdown: ShutdownToken,
) -> Result<(MixForwardingSender, ActiveConnections), NymNodeError>
@@ -1039,7 +1060,6 @@ impl NymNode {
);
let active_connections = mixnet_client.active_connections();
let replay_protection_bloomfilter = self.setup_replay_detection().await?;
let mut packet_forwarder = PacketForwarder::new(
mixnet_client,
routing_filter,
@@ -1056,7 +1076,7 @@ impl NymNode {
let shared = mixnet::SharedData::new(
processing_config,
self.x25519_sphinx_keys.clone(),
self.active_sphinx_keys()?,
replay_protection_bloomfilter,
mix_packet_sender.clone(),
final_hop_data,
@@ -1071,6 +1091,7 @@ impl NymNode {
pub(crate) async fn run_minimal_mixnet_processing(self) -> Result<(), NymNodeError> {
self.start_mixnet_listener(
&ActiveClientsStore::new(),
ReplayProtectionBloomfilters::new_disabled(),
OpenFilter,
self.shutdown_manager.clone_token("mixnet-traffic"),
)
@@ -1105,15 +1126,21 @@ impl NymNode {
}
});
self.try_refresh_remote_nym_api_cache().await;
let nym_apis_client = self.setup_nym_apis_client()?;
self.try_refresh_remote_nym_api_cache(&nym_apis_client)
.await?;
self.start_verloc_measurements();
let network_refresher = self.build_network_refresher().await?;
let active_clients_store = ActiveClientsStore::new();
let bloomfilters_manager = self.setup_replay_detection().await?;
let (mix_packet_sender, active_egress_mixnet_connections) = self
.start_mixnet_listener(
&active_clients_store,
bloomfilters_manager.bloomfilters(),
network_refresher.routing_filter(),
self.shutdown_manager.clone_token("mixnet-traffic"),
)
@@ -1134,6 +1161,9 @@ impl NymNode {
)
.await?;
self.setup_key_rotation(nym_apis_client, bloomfilters_manager)
.await?;
network_refresher.start();
self.shutdown_manager.close();
+223
View File
@@ -0,0 +1,223 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::error::NymNodeError;
use crate::node::NymNode;
use futures::{stream, StreamExt};
use nym_crypto::asymmetric::ed25519;
use nym_http_api_client::Client;
use nym_task::ShutdownToken;
use nym_validator_client::client::NymApiClientExt;
use nym_validator_client::models::{KeyRotationInfoResponse, NodeRefreshBody};
use nym_validator_client::nym_api::error::NymAPIError;
use nym_validator_client::NymApiClient;
use rand::prelude::SliceRandom;
use rand::thread_rng;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tokio::time::sleep;
use tracing::{debug, warn};
use url::Url;
#[derive(Clone)]
pub struct NymApisClient {
inner: Arc<RwLock<InnerClient>>,
}
struct InnerClient {
// NOTE: this was implemented before the internal http client supported multiple URLs
active_client: NymApiClient,
available_urls: Vec<Url>,
shutdown_token: ShutdownToken,
currently_used_api: usize,
}
impl NymApisClient {
pub(crate) fn new(
nym_apis: &[Url],
shutdown_token: ShutdownToken,
) -> Result<Self, NymNodeError> {
if nym_apis.is_empty() {
return Err(NymNodeError::NoNymApiUrls);
}
let mut urls = nym_apis.to_vec();
urls.shuffle(&mut thread_rng());
let active_client = nym_http_api_client::Client::builder(urls[0].clone())?
.no_hickory_dns()
.with_user_agent(NymNode::user_agent())
.with_timeout(Duration::from_secs(5))
.build()?;
Ok(NymApisClient {
inner: Arc::new(RwLock::new(InnerClient {
active_client: NymApiClient::from(active_client),
available_urls: urls,
shutdown_token,
currently_used_api: 0,
})),
})
}
// async fn use_next_endpoint(&self) {
// let mut guard = self.inner.write().await;
// if guard.available_urls.len() == 1 {
// return;
// }
//
// let next_index = (guard.currently_used_api + 1) % guard.available_urls.len();
// let next = guard.available_urls[next_index].clone();
// guard.currently_used_api = next_index;
// guard.active_client.change_nym_api(next)
// }
pub(crate) async fn query_exhaustively<R, T>(
&self,
req: R,
timeout_duration: Duration,
) -> Result<T, NymNodeError>
where
R: AsyncFn(Client) -> Result<T, NymAPIError>,
{
let guard = self.inner.read().await;
let (res, last_working_endpoint) = guard.query_exhaustively(req, timeout_duration).await?;
// if we had to use a different api, update our starting point for the future calls
if guard.currently_used_api != last_working_endpoint {
drop(guard);
let mut guard = self.inner.write().await;
let next_url = guard.available_urls[last_working_endpoint].clone();
guard.currently_used_api = last_working_endpoint;
guard.active_client.change_nym_api(next_url);
}
Ok(res)
}
pub(crate) async fn broadcast_force_refresh(&self, private_key: &ed25519::PrivateKey) {
self.inner
.read()
.await
.broadcast_force_refresh(private_key)
.await;
}
pub(crate) async fn get_key_rotation_info(
&self,
) -> Result<KeyRotationInfoResponse, NymNodeError> {
self.query_exhaustively(
async |c| c.get_key_rotation_info().await,
Duration::from_secs(5),
)
.await
}
}
impl InnerClient {
// currently there are no cases without json body, but for those we'd just need to slightly adjust the signature
async fn broadcast<B, R>(&self, request_body: &B, req: R, timeout_duration: Duration)
where
R: AsyncFn(Client, &B) -> Result<(), NymAPIError>,
{
let broadcast_fut =
stream::iter(self.available_urls.clone()).for_each_concurrent(None, |url| {
let nym_api = self
.active_client
.nym_api
.clone_with_new_url(url.clone().into());
let req_fut = req(nym_api, request_body);
async move {
if let Err(err) = req_fut.await {
warn!("broadcast request to {url} failed: {err}")
}
}
});
let timeout_fut = sleep(timeout_duration);
tokio::select! {
_ = broadcast_fut => {
debug!("managed to broadcast data to all nym apis")
}
_ = timeout_fut => {
warn!("timed out while attempting to broadcast data to known nym apis")
}
_ = self.shutdown_token.cancelled() => {
debug!("received shutdown while attempting to broadcast data to known nym apis")
}
}
}
async fn query_exhaustively<R, T>(
&self,
req: R,
timeout_duration: Duration,
) -> Result<(T, usize), NymNodeError>
where
R: AsyncFn(Client) -> Result<T, NymAPIError>,
{
let last_working = self.currently_used_api;
// start from the last working api and progress from there
// also, note this is DESIGNED to query sequentially (but exhaustively)
// and not to try to send queries to ALL apis at once
// and check which resolves first
for (idx, url) in self
.available_urls
.iter()
.enumerate()
.skip(last_working)
.chain(self.available_urls.iter().enumerate().take(last_working))
{
let nym_api = self
.active_client
.nym_api
.clone_with_new_url(url.clone().into());
let timeout_fut = sleep(timeout_duration);
let query_fut = req(nym_api);
tokio::select! {
res = query_fut => {
debug!("managed to broadcast data to all nym apis");
match res {
Ok(res) => return Ok((res, idx)),
Err(err) => {
warn!("failed to resolve query for {url}: {err}");
}
}
}
_ = timeout_fut => {
warn!("timed out while attempting to query {url}")
}
_ = self.shutdown_token.cancelled() => {
debug!("received shutdown while attempting to query {url}");
return Err(NymNodeError::ShutdownReceived)
}
}
}
Err(NymNodeError::NymApisExhausted)
}
async fn broadcast_force_refresh(&self, private_key: &ed25519::PrivateKey) {
let request = NodeRefreshBody::new(private_key);
self.broadcast(
&request,
async |client, request| client.force_refresh_describe_cache(request).await,
Duration::from_secs(10),
)
.await;
}
}
impl AsRef<NymApiClient> for InnerClient {
fn as_ref(&self) -> &NymApiClient {
&self.active_client
}
}
@@ -1,204 +1,233 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::config::persistence::{
DEFAULT_RD_BLOOMFILTER_FILE_EXT, DEFAULT_RD_BLOOMFILTER_FLUSH_FILE_EXT,
};
use crate::config::Config;
use crate::error::NymNodeError;
use crate::node::replay_protection::bloomfilter::ReplayProtectionBloomfilter;
use crate::node::replay_protection::bloomfilter::RotationFilter;
use crate::node::replay_protection::helpers::parse_rotation_id_from_filename;
use crate::node::replay_protection::items_in_bloomfilter;
use human_repr::HumanCount;
use crate::node::replay_protection::manager::ReplayProtectionBloomfiltersManager;
use human_repr::HumanDuration;
use nym_node_metrics::NymNodeMetrics;
use nym_task::ShutdownToken;
use std::cmp::max;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
use tokio::time::{interval, Instant};
use tracing::{error, info, trace, warn};
use tracing::{debug, error, info, trace, warn};
struct LastResetData {
packets_received_at_last_reset: usize,
reset_time: Instant,
}
struct ReplayProtectionBackgroundTaskConfig {
current_bloomfilter_path: PathBuf,
current_bloomfilter_temp_flush_path: PathBuf,
false_positive_rate: f64,
filter_reset_rate: Duration,
// background task responsible for periodically flushing the bloomfilters to disk
pub struct ReplayProtectionDiskFlush {
bloomfilters_directory: PathBuf,
disk_flushing_rate: Duration,
bloomfilter_size_multiplier: f64,
minimum_bloomfilter_packets_per_second: usize,
filters_manager: ReplayProtectionBloomfiltersManager,
shutdown_token: ShutdownToken,
}
impl From<&Config> for ReplayProtectionBackgroundTaskConfig {
fn from(config: &Config) -> Self {
ReplayProtectionBackgroundTaskConfig {
current_bloomfilter_path: config
impl ReplayProtectionDiskFlush {
pub(crate) async fn new(
config: &Config,
primary_key_rotation_id: u32,
secondary_key_rotation_id: Option<u32>,
metrics: NymNodeMetrics,
shutdown_token: ShutdownToken,
) -> Result<Self, NymNodeError> {
let bloomfilters_directory = config
.mixnet
.replay_protection
.storage_paths
.current_bloomfilters_directory
.clone();
let dir_read_err = |source| NymNodeError::BloomfilterIoFailure {
source,
path: bloomfilters_directory.clone(),
};
if !bloomfilters_directory.exists() {
fs::create_dir_all(&bloomfilters_directory).map_err(dir_read_err)?;
}
let available_filters_dir = fs::read_dir(&bloomfilters_directory).map_err(dir_read_err)?;
// figure out what bloomfilters we have available on disk
let mut filter_files = HashMap::new();
for entry in available_filters_dir.into_iter() {
let entry = entry.map_err(dir_read_err)?;
let path = entry.path();
let Some(rotation) = entry
.file_name()
.to_str()
.and_then(parse_rotation_id_from_filename)
else {
warn!("invalid bloomfilter file at '{}'", path.display());
continue;
};
// if any bloomfilter has the temp extension, we can't trust its data as it hasn't completed the flush
if let Some(ext) = entry.path().extension() {
if ext == DEFAULT_RD_BLOOMFILTER_FLUSH_FILE_EXT {
error!(
"bloomfilter {rotation} didn't get successfully flushed to disk and its data got corrupted"
);
fs::remove_file(&path)
.map_err(|source| NymNodeError::BloomfilterIoFailure { source, path })?;
continue;
}
}
filter_files.insert(rotation, path);
}
let rebuild_items_in_filter = items_in_bloomfilter(
Duration::from_secs(25 * 60 * 60),
config
.mixnet
.replay_protection
.storage_paths
.current_bloomfilter_filepath(),
current_bloomfilter_temp_flush_path: config
.mixnet
.replay_protection
.storage_paths
.current_bloomfilter_being_flushed_filepath(),
false_positive_rate: config.mixnet.replay_protection.debug.false_positive_rate,
filter_reset_rate: config.mixnet.replay_protection.debug.bloomfilter_reset_rate,
.debug
.initial_expected_packets_per_second,
);
let fp_r = config.mixnet.replay_protection.debug.false_positive_rate;
// if filters do not exist on disk, we must make new ones
let primary_bloomfilter = match filter_files.get(&primary_key_rotation_id) {
Some(primary_path) => RotationFilter::load(primary_path)?,
None => {
info!("no stored bloomfilter for rotation {primary_key_rotation_id}");
RotationFilter::new(rebuild_items_in_filter, fp_r, 0, primary_key_rotation_id)?
}
};
let secondary_bloomfilter =
if let Some(secondary_key_rotation_id) = secondary_key_rotation_id {
match filter_files.get(&secondary_key_rotation_id) {
Some(secondary_path) => Some(RotationFilter::load(secondary_path)?),
None => {
info!("no stored bloomfilter for rotation {secondary_key_rotation_id}");
Some(RotationFilter::new(
rebuild_items_in_filter,
fp_r,
0,
secondary_key_rotation_id,
)?)
}
}
} else {
None
};
Ok(ReplayProtectionDiskFlush {
bloomfilters_directory,
disk_flushing_rate: config
.mixnet
.replay_protection
.debug
.bloomfilter_disk_flushing_rate,
bloomfilter_size_multiplier: config
.mixnet
.replay_protection
.debug
.bloomfilter_size_multiplier,
minimum_bloomfilter_packets_per_second: config
.mixnet
.replay_protection
.debug
.bloomfilter_minimum_packets_per_second_size,
}
}
}
// background task responsible for periodically flushing the bloomfilter to disk
// as well as clearing it up on the specified timer
// (in the future this will be enforced by key rotation)
pub struct ReplayProtectionBackgroundTask {
config: ReplayProtectionBackgroundTaskConfig,
last_reset: LastResetData,
filter: ReplayProtectionBloomfilter,
metrics: NymNodeMetrics,
shutdown_token: ShutdownToken,
}
impl ReplayProtectionBackgroundTask {
pub(crate) async fn new(
config: &Config,
metrics: NymNodeMetrics,
shutdown_token: ShutdownToken,
) -> Result<Self, NymNodeError> {
let task_config: ReplayProtectionBackgroundTaskConfig = config.into();
if task_config.current_bloomfilter_temp_flush_path.exists() {
error!(
"bloomfilter didn't get successfully flushed to disk and its data got corrupted"
);
fs::remove_file(&task_config.current_bloomfilter_temp_flush_path).map_err(|source| {
NymNodeError::BloomfilterIoFailure {
source,
path: task_config.current_bloomfilter_temp_flush_path.clone(),
}
})?
}
// if there's nothing on disk, we must create a new filter
let bloomfilter = if task_config.current_bloomfilter_path.exists() {
ReplayProtectionBloomfilter::load(&task_config.current_bloomfilter_path).await?
} else {
let bf_items = items_in_bloomfilter(
task_config.filter_reset_rate,
config
.mixnet
.replay_protection
.debug
.initial_expected_packets_per_second,
);
ReplayProtectionBloomfilter::new_empty(bf_items, task_config.false_positive_rate)?
};
Ok(ReplayProtectionBackgroundTask {
config: task_config,
last_reset: LastResetData {
packets_received_at_last_reset: 0,
reset_time: Instant::now(),
},
filter: bloomfilter,
metrics,
filters_manager: ReplayProtectionBloomfiltersManager::new(
config,
primary_bloomfilter,
secondary_bloomfilter,
metrics,
),
shutdown_token,
})
}
pub(crate) fn global_bloomfilter(&self) -> ReplayProtectionBloomfilter {
self.filter.clone()
fn bloomfilter_filepath(&self, rotation_id: u32) -> PathBuf {
self.bloomfilters_directory
.join(format!("rot-{rotation_id}"))
.with_extension(DEFAULT_RD_BLOOMFILTER_FILE_EXT)
}
async fn flush_to_disk(&self) -> Result<(), NymNodeError> {
if let Some(temp_parent) = self.config.current_bloomfilter_temp_flush_path.parent() {
fs::create_dir_all(temp_parent).map_err(|source| {
NymNodeError::BloomfilterIoFailure {
source,
path: temp_parent.to_path_buf(),
}
})?
}
if let Some(current_parent) = self.config.current_bloomfilter_temp_flush_path.parent() {
fs::create_dir_all(current_parent).map_err(|source| {
NymNodeError::BloomfilterIoFailure {
source,
path: current_parent.to_path_buf(),
}
})?
}
fn current_bloomfilter_being_flushed_filepath(&self, rotation_id: u32) -> PathBuf {
self.bloomfilters_directory
.join(format!("rot-{rotation_id}"))
.with_extension(DEFAULT_RD_BLOOMFILTER_FLUSH_FILE_EXT)
}
pub(crate) fn bloomfilters_manager(&self) -> ReplayProtectionBloomfiltersManager {
self.filters_manager.clone()
}
async fn flush(&self, data: Vec<u8>, rotation_id: u32) -> Result<(), NymNodeError> {
// because it takes a while to actually write the file to disk,
// we first write bytes to temporary location,
// and then we move it to the correct path
let temp = &self.config.current_bloomfilter_temp_flush_path;
self.filter.flush_to_disk(temp).await?;
fs::rename(temp, &self.config.current_bloomfilter_path).map_err(|source| {
let temp_path = self.current_bloomfilter_being_flushed_filepath(rotation_id);
let final_path = self.bloomfilter_filepath(rotation_id);
debug!("flushing replay protection bloomfilter {rotation_id} to disk...");
let start = Instant::now();
let mut file = File::create(&temp_path).await.map_err(|source| {
NymNodeError::BloomfilterIoFailure {
source,
path: self.config.current_bloomfilter_path.clone(),
path: temp_path.clone(),
}
})?;
file.write_all(&data)
.await
.map_err(|source| NymNodeError::BloomfilterIoFailure {
source,
path: temp_path.to_path_buf(),
})?;
fs::rename(temp_path, &final_path).map_err(|source| {
NymNodeError::BloomfilterIoFailure {
source,
path: final_path,
}
})?;
let elapsed = start.elapsed();
info!(
"flushed replay protection bloomfilter {rotation_id} to disk. it took: {}",
elapsed.human_duration()
);
Ok(())
}
fn reset_bloomfilter(&mut self) -> Result<(), NymNodeError> {
// 1. determine parameters for new bloomfilter
let received = self.metrics.mixnet.ingress.forward_hop_packets_received()
+ self.metrics.mixnet.ingress.final_hop_packets_received();
// average HDD has the write speed of ~80MB/s so a 2GB bloomfilter would take almost 30s to write...
// and this function is explicitly async and using tokio's async operations, because otherwise
// we'd have to go through the whole hassle of using spawn_blocking and awaiting that one instead
async fn flush_primary(&self) -> Result<(), NymNodeError> {
let (bytes, id) = self.filters_manager.primary_bytes_and_id()?;
self.flush(bytes, id).await
}
let time_delta = self.last_reset.reset_time.elapsed();
let received_since_last_reset = received - self.last_reset.packets_received_at_last_reset;
let received_per_second =
(received_since_last_reset as f64 / time_delta.as_secs_f64()).round() as usize;
async fn flush_secondary(&self) -> Result<(), NymNodeError> {
let Some((bytes, id)) = self.filters_manager.secondary_bytes_and_id()? else {
return Ok(());
};
self.flush(bytes, id).await
}
let bf_received = max(
received_per_second,
self.config.minimum_bloomfilter_packets_per_second,
);
let items_in_new_filter = items_in_bloomfilter(self.config.filter_reset_rate, bf_received);
let adjusted =
(items_in_new_filter as f64 * self.config.bloomfilter_size_multiplier).round() as usize;
async fn flush_filters_to_disk(&self) -> Result<(), NymNodeError> {
if let Some(parent) = self.bloomfilters_directory.parent() {
fs::create_dir_all(parent).map_err(|source| NymNodeError::BloomfilterIoFailure {
source,
path: parent.to_path_buf(),
})?
}
info!(
"resetting bloom filter. new expected number of packets: {} that preserve fp rate of {}",
adjusted.human_count_bare(),
self.config.false_positive_rate
);
self.flush_primary().await?;
self.flush_secondary().await?;
// 2. update the filter
self.last_reset.reset_time = Instant::now();
self.last_reset.packets_received_at_last_reset = received_since_last_reset;
// if this fails with the mutex getting poisoned, the next received packet is going to cause
// a shutdown, so we don't have to propagate it here
self.filter.reset(adjusted, self.config.false_positive_rate)
Ok(())
}
pub(crate) async fn run(&mut self) {
let mut reset_timer = interval(self.config.filter_reset_rate);
reset_timer.reset();
let mut flush_timer = interval(self.config.disk_flushing_rate);
let mut flush_timer = interval(self.disk_flushing_rate);
flush_timer.reset();
loop {
@@ -208,13 +237,8 @@ impl ReplayProtectionBackgroundTask {
trace!("ReplayProtectionBackgroundTask: Received shutdown");
break;
}
_ = reset_timer.tick() => {
if let Err(err) = self.reset_bloomfilter() {
error!("failed to reset the bloomfilter: {err}")
}
}
_ = flush_timer.tick() => {
if let Err(err) = self.flush_to_disk().await {
if let Err(err) = self.flush_filters_to_disk().await {
error!("failed to flush bloomfilter to disk: {err}")
}
}
@@ -222,8 +246,8 @@ impl ReplayProtectionBackgroundTask {
}
info!("SHUTDOWN: flushing replay detection bloomfilter to disk. this might take a while. DO NOT INTERRUPT THIS PROCESS");
if let Err(err) = self.flush_to_disk().await {
warn!("failed to flush replay detection bloom filter on shutdown: {err}");
if let Err(err) = self.flush_filters_to_disk().await {
warn!("failed to flush replay detection bloom filters on shutdown: {err}");
}
}
}
+320 -141
View File
@@ -3,43 +3,128 @@
use crate::error::NymNodeError;
use bloomfilter::Bloom;
use human_repr::HumanDuration;
use nym_sphinx_types::REPLAY_TAG_SIZE;
use std::collections::HashMap;
use std::fs::File;
use std::io::Read;
use std::mem;
use std::path::Path;
use std::sync::{Arc, PoisonError, TryLockError};
use tokio::fs::File;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::time::Instant;
use tracing::{debug, info};
use std::sync::{Arc, Mutex, PoisonError, TryLockError};
use time::OffsetDateTime;
use tracing::{error, info, warn};
// auxiliary data associated with the bloomfilter to get some statistics from the time of its creation
// this is needed in order to more accurately resize it upon reset
#[derive(Copy, Clone)]
pub(crate) struct ReplayProtectionBloomfilterMetadata {
// used in the unlikely case of epoch durations being changed. it doesn't really cost us anything
// to include it, so might as well
pub(crate) creation_time: OffsetDateTime,
/// Number of packets that this node has received since startup, as recorded when this bloomfilter was created.
/// Used for determining the approximate packet rate and thus number of entries in the bloomfilter
pub(crate) packets_received_at_creation: usize,
pub(crate) rotation_id: u32,
}
impl ReplayProtectionBloomfilterMetadata {
const SERIALIZED_LEN: usize = size_of::<i64>() + size_of::<u64>() + size_of::<u32>();
// UNIX_TIMESTAMP || PACKETS_RECEIVED || ROTATION_ID
pub(crate) fn bytes(&self) -> Vec<u8> {
self.creation_time
.unix_timestamp()
.to_be_bytes()
.into_iter()
.chain((self.packets_received_at_creation as u64).to_be_bytes())
.chain(self.rotation_id.to_be_bytes())
.collect()
}
pub(crate) fn try_from_bytes(bytes: &[u8]) -> Result<Self, NymNodeError> {
if bytes.len() != Self::SERIALIZED_LEN {
return Err(NymNodeError::BloomfilterMetadataDeserialisationFailure);
}
// SAFETY: we just checked we have correct number of bytes
#[allow(clippy::unwrap_used)]
let creation_timestamp = i64::from_be_bytes(bytes[0..8].try_into().unwrap());
#[allow(clippy::unwrap_used)]
let packets_received_at_creation =
u64::from_be_bytes(bytes[8..16].try_into().unwrap()) as usize;
#[allow(clippy::unwrap_used)]
let rotation_id = u32::from_be_bytes(bytes[16..].try_into().unwrap());
Ok(ReplayProtectionBloomfilterMetadata {
creation_time: OffsetDateTime::from_unix_timestamp(creation_timestamp)
.map_err(|_| NymNodeError::BloomfilterMetadataDeserialisationFailure)?,
packets_received_at_creation,
rotation_id,
})
}
}
// it appears that now std Mutex is faster (or comparable) to parking_lot
// in high contention situations: https://github.com/rust-lang/rust/pull/95035#issuecomment-1073966631
// (tokio's async Mutex has too much overhead due to the number of access required)
#[derive(Clone)]
pub(crate) struct ReplayProtectionBloomfilter {
pub(crate) struct ReplayProtectionBloomfilters {
disabled: bool,
inner: Arc<std::sync::Mutex<ReplayProtectionBloomfilterInner>>,
inner: Arc<Mutex<ReplayProtectionBloomfiltersInner>>,
}
impl ReplayProtectionBloomfilter {
pub(crate) fn new_empty(items_count: usize, fp_p: f64) -> Result<Self, NymNodeError> {
Ok(ReplayProtectionBloomfilter {
impl ReplayProtectionBloomfilters {
pub(crate) fn new(primary: RotationFilter, secondary: Option<RotationFilter>) -> Self {
// figure out if the secondary filter is the overlap or pre_announced filter
let primary_id = primary.metadata.rotation_id;
let next = primary_id + 1;
let previous = primary_id.checked_sub(1);
let (overlap, pre_announced) = match secondary {
None => (None, None),
Some(secondary_filter) => {
let secondary_id = secondary_filter.metadata.rotation_id;
if secondary_id == next {
(None, Some(secondary_filter))
} else if Some(secondary_id) == previous {
(Some(secondary_filter), None)
} else {
warn!("{secondary_id} is not valid for either pre_announced or overlap bloomfilter given primary rotation of {primary_id}");
(None, None)
}
}
};
ReplayProtectionBloomfilters {
disabled: false,
inner: Arc::new(std::sync::Mutex::new(ReplayProtectionBloomfilterInner {
current_filter: Bloom::new_for_fp_rate(items_count, fp_p)
.map_err(NymNodeError::bloomfilter_failure)?,
inner: Arc::new(Mutex::new(ReplayProtectionBloomfiltersInner {
primary,
overlap,
pre_announced,
})),
})
}
}
// SAFETY: the hardcoded values of 1,1 are valid
#[allow(clippy::unwrap_used)]
pub(crate) fn new_disabled() -> Self {
// well, technically it's not fully empty, but the memory footprint is negligible
ReplayProtectionBloomfilter {
ReplayProtectionBloomfilters {
disabled: true,
inner: Arc::new(std::sync::Mutex::new(ReplayProtectionBloomfilterInner {
current_filter: Bloom::new(1, 1).unwrap(),
inner: Arc::new(std::sync::Mutex::new(ReplayProtectionBloomfiltersInner {
primary: RotationFilter {
metadata: ReplayProtectionBloomfilterMetadata {
creation_time: OffsetDateTime::now_utc(),
packets_received_at_creation: 0,
rotation_id: u32::MAX,
},
data: Bloom::new(1, 1).unwrap(),
},
overlap: None,
pre_announced: None,
})),
}
}
@@ -48,14 +133,13 @@ impl ReplayProtectionBloomfilter {
self.disabled
}
pub(crate) fn reset(&self, items_count: usize, fp_p: f64) -> Result<(), NymNodeError> {
// 1. build the new filter
let new_inner = ReplayProtectionBloomfilterInner {
current_filter: Bloom::new_for_fp_rate(items_count, fp_p)
.map_err(NymNodeError::bloomfilter_failure)?,
};
// 2. swap it
pub(crate) fn allocate_pre_announced(
&self,
items_count: usize,
fp_p: f64,
packets_received_at_creation: usize,
rotation_id: u32,
) -> Result<(), NymNodeError> {
let mut guard = self
.inner
.lock()
@@ -63,161 +147,256 @@ impl ReplayProtectionBloomfilter {
message: "mutex got poisoned",
})?;
*guard = new_inner;
guard.pre_announced = Some(RotationFilter::new(
items_count,
fp_p,
packets_received_at_creation,
rotation_id,
)?);
Ok(())
}
// NOTE: with key rotations we'll have to check whether the file is still valid and which
// key it corresponds to, but that's a future problem
pub(crate) async fn load<P: AsRef<Path>>(path: P) -> Result<Self, NymNodeError> {
info!("attempting to load prior replay detection bloomfilter...");
let path = path.as_ref();
let mut file =
File::open(path)
.await
.map_err(|source| NymNodeError::BloomfilterIoFailure {
source,
path: path.to_path_buf(),
})?;
let mut buf = Vec::new();
file.read_to_end(&mut buf)
.await
.map_err(|source| NymNodeError::BloomfilterIoFailure {
source,
path: path.to_path_buf(),
pub(crate) fn promote_pre_announced(&self) -> Result<(), NymNodeError> {
let mut guard = self
.inner
.lock()
.map_err(|_| NymNodeError::BloomfilterFailure {
message: "mutex got poisoned",
})?;
Ok(ReplayProtectionBloomfilter {
disabled: false,
inner: Arc::new(std::sync::Mutex::new(ReplayProtectionBloomfilterInner {
current_filter: Bloom::from_bytes(buf)
.map_err(NymNodeError::bloomfilter_failure)?,
})),
})
}
let Some(mut pre_announced) = guard.pre_announced.take() else {
error!("there was no pre-announced bloomfilter to promote");
return Ok(());
};
// average HDD has the write speed of ~80MB/s so a 2GB bloomfilter would take almost 30s to write...
// and this function is explicitly async and using tokio's async operations, because otherwise
// we'd have to go through the whole hassle of using spawn_blocking and awaiting that one instead
pub(crate) async fn flush_to_disk<P: AsRef<Path>>(&self, path: P) -> Result<(), NymNodeError> {
debug!("flushing replay protection bloomfilter to disk...");
let start = Instant::now();
let path = path.as_ref();
let mut file =
File::create(path)
.await
.map_err(|source| NymNodeError::BloomfilterIoFailure {
source,
path: path.to_path_buf(),
})?;
let data = self.bytes().map_err(|_| NymNodeError::BloomfilterFailure {
message: "mutex got poisoned",
})?;
file.write_all(&data)
.await
.map_err(|source| NymNodeError::BloomfilterIoFailure {
source,
path: path.to_path_buf(),
})?;
let elapsed = start.elapsed();
info!(
"flushed replay protection bloomfilter to disk. it took: {}",
elapsed.human_duration()
);
// pre_announced -> primary
// primary -> temp (pre_announced)
mem::swap(&mut guard.primary, &mut pre_announced);
// temp (pre_announced) -> secondary
guard.overlap = Some(pre_announced);
Ok(())
}
}
struct ReplayProtectionBloomfilterInner {
// metadata to do with epochs, etc.
current_filter: Bloom<[u8; REPLAY_TAG_SIZE]>,
// overlap_filter: bloomfilter::Bloom<[u8; REPLAY_TAG_SIZE]>,
}
impl ReplayProtectionBloomfilter {
#[allow(dead_code)]
pub(crate) fn check_and_set(
&self,
replay_tag: &[u8; REPLAY_TAG_SIZE],
) -> Result<bool, PoisonError<()>> {
let Ok(mut guard) = self.inner.lock() else {
return Err(PoisonError::new(()));
};
Ok(guard.current_filter.check_and_set(replay_tag))
pub(crate) fn purge_secondary(&self) -> Result<(), NymNodeError> {
let mut guard = self
.inner
.lock()
.map_err(|_| NymNodeError::BloomfilterFailure {
message: "mutex got poisoned",
})?;
guard.overlap = None;
Ok(())
}
#[allow(dead_code)]
pub(crate) fn try_check_and_set(
pub(crate) fn primary_metadata(
&self,
replay_tag: &[u8; REPLAY_TAG_SIZE],
) -> Option<Result<bool, PoisonError<()>>> {
let mut guard = match self.inner.try_lock() {
Ok(guard) => guard,
Err(TryLockError::Poisoned(_)) => return Some(Err(PoisonError::new(()))),
Err(TryLockError::WouldBlock) => return None,
};
) -> Result<ReplayProtectionBloomfilterMetadata, NymNodeError> {
let metadata = self
.inner
.lock()
.map_err(|_| NymNodeError::BloomfilterFailure {
message: "mutex got poisoned",
})?
.primary
.metadata;
Some(Ok(guard.current_filter.check_and_set(replay_tag)))
Ok(metadata)
}
pub(crate) fn primary_bytes_and_id(&self) -> Result<(Vec<u8>, u32), NymNodeError> {
let guard = self
.inner
.lock()
.map_err(|_| NymNodeError::BloomfilterFailure {
message: "mutex got poisoned",
})?;
let id = guard.primary.metadata.rotation_id;
let bytes = guard.primary.bytes();
Ok((bytes, id))
}
pub(crate) fn secondary_bytes_and_id(&self) -> Result<Option<(Vec<u8>, u32)>, NymNodeError> {
let guard = self
.inner
.lock()
.map_err(|_| NymNodeError::BloomfilterFailure {
message: "mutex got poisoned",
})?;
let secondary = match guard.overlap.as_ref() {
Some(overlap) => overlap,
None => {
let Some(pre_announced) = guard.pre_announced.as_ref() else {
return Ok(None);
};
pre_announced
}
};
let id = secondary.metadata.rotation_id;
let bytes = secondary.bytes();
Ok(Some((bytes, id)))
}
}
// map from particular rotation id to vector of results, based on the order of requests received
type BatchCheckResult = HashMap<u32, Vec<bool>>;
impl ReplayProtectionBloomfilters {
pub(crate) fn batch_try_check_and_set(
&self,
reply_tags: &[&[u8; REPLAY_TAG_SIZE]],
) -> Option<Result<Vec<bool>, PoisonError<()>>> {
reply_tags: &HashMap<u32, Vec<&[u8; REPLAY_TAG_SIZE]>>,
) -> Option<Result<BatchCheckResult, PoisonError<()>>> {
let mut guard = match self.inner.try_lock() {
Ok(guard) => guard,
Err(TryLockError::Poisoned(_)) => return Some(Err(PoisonError::new(()))),
Err(TryLockError::WouldBlock) => return None,
};
let mut result = Vec::with_capacity(reply_tags.len());
for tag in reply_tags {
result.push(guard.current_filter.check_and_set(tag));
}
// for testing throughput without disabling checks:
// return Some(Ok(vec![false; reply_tags.len()]));
Some(Ok(result))
Some(Ok(guard.batch_check_and_set(reply_tags)))
}
pub(crate) fn batch_check_and_set(
&self,
reply_tags: &[&[u8; REPLAY_TAG_SIZE]],
) -> Result<Vec<bool>, PoisonError<()>> {
reply_tags: &HashMap<u32, Vec<&[u8; REPLAY_TAG_SIZE]>>,
) -> Result<HashMap<u32, Vec<bool>>, PoisonError<()>> {
let Ok(mut guard) = self.inner.lock() else {
return Err(PoisonError::new(()));
};
let mut result = Vec::with_capacity(reply_tags.len());
for tag in reply_tags {
result.push(guard.current_filter.check_and_set(tag));
Ok(guard.batch_check_and_set(reply_tags))
}
}
struct ReplayProtectionBloomfiltersInner {
primary: RotationFilter,
// don't worry, we'll never have 3 active filters at once,
// we will either have a overlap (during the first epoch of a new rotation)
// or a pre_announced (during the last epoch of the current rotation)
// during epoch transition, the following change will happen:
// primary -> overlap
// pre_announced -> primary
// I'm not using an enum because it's easier to reason about those as separate fields
overlap: Option<RotationFilter>,
pre_announced: Option<RotationFilter>,
}
impl ReplayProtectionBloomfiltersInner {
fn batch_check_and_set(
&mut self,
reply_tags: &HashMap<u32, Vec<&[u8; REPLAY_TAG_SIZE]>>,
) -> HashMap<u32, Vec<bool>> {
let mut result = HashMap::with_capacity(reply_tags.len());
for (&rotation_id, reply_tags) in reply_tags {
// try to 'find' the relevant filter. we might be doing 3 reads here, but realistically it's
// going to be 'primary' most of the time and even if not, it's just few ns of overhead...
let filter = if self.primary.metadata.rotation_id == rotation_id {
Some(&mut self.primary.data)
} else if let Some(secondary) = &mut self.overlap {
// if let chaining won't be stable until 1.88 so we have to do the Option workaround
if secondary.metadata.rotation_id == rotation_id {
Some(&mut secondary.data)
} else {
None
}
} else if let Some(pre_announced) = &mut self.pre_announced {
if pre_announced.metadata.rotation_id == rotation_id {
Some(&mut pre_announced.data)
} else {
None
}
} else {
None
};
let Some(filter) = filter else {
// if we've received a packet from an unknown rotation, it most likely means it has been replayed
// from an older rotation, so mark it as such
result.insert(rotation_id, vec![false; reply_tags.len()]);
continue;
};
let mut rotation_results = Vec::with_capacity(reply_tags.len());
for tag in reply_tags {
rotation_results.push(filter.check_and_set(tag))
}
result.insert(rotation_id, rotation_results);
}
// for testing throughput without disabling checks:
// return Ok(vec![false; reply_tags.len()]);
Ok(result)
result
}
}
#[allow(dead_code)]
pub(crate) fn clear(&self) -> Result<(), PoisonError<()>> {
let mut guard = self.inner.lock().map_err(|_| PoisonError::new(()))?;
guard.current_filter.clear();
Ok(())
pub(crate) struct RotationFilter {
metadata: ReplayProtectionBloomfilterMetadata,
data: Bloom<[u8; REPLAY_TAG_SIZE]>,
}
impl RotationFilter {
pub(crate) fn new(
items_count: usize,
fp_p: f64,
packets_received_at_creation: usize,
rotation_id: u32,
) -> Result<Self, NymNodeError> {
let filter =
Bloom::new_for_fp_rate(items_count, fp_p).map_err(NymNodeError::bloomfilter_failure)?;
Ok(RotationFilter {
metadata: ReplayProtectionBloomfilterMetadata {
creation_time: OffsetDateTime::now_utc(),
packets_received_at_creation,
rotation_id,
},
data: filter,
})
}
// due to the size of the bloomfilter, extra caution has to be applied when using this method
// note: we're not getting reference to bytes as this method is used when flushing data to the disk
// (which takes ~30s) and we can't block the mutex for that long.
fn bytes(&self) -> Result<Vec<u8>, PoisonError<()>> {
let guard = self.inner.lock().map_err(|_| PoisonError::new(()))?;
Ok(guard.current_filter.to_bytes())
fn bytes(&self) -> Vec<u8> {
// attach metadata bytes at the end as it would make deserialisation cheaper (as we could avoid
// copying the bloomfilter bytes twice)
let mut bloom_bytes = self.data.to_bytes();
bloom_bytes.extend_from_slice(&self.metadata.bytes());
bloom_bytes
}
pub(crate) fn try_from_bytes(bytes: Vec<u8>) -> Result<Self, NymNodeError> {
let len = bytes.len();
if bytes.len() < ReplayProtectionBloomfilterMetadata::SERIALIZED_LEN {
return Err(NymNodeError::BloomfilterMetadataDeserialisationFailure);
}
let mut bloom_bytes = bytes;
let metadata_bytes =
bloom_bytes.split_off(len - ReplayProtectionBloomfilterMetadata::SERIALIZED_LEN);
Ok(RotationFilter {
metadata: ReplayProtectionBloomfilterMetadata::try_from_bytes(&metadata_bytes)?,
data: Bloom::from_bytes(bloom_bytes).map_err(NymNodeError::bloomfilter_failure)?,
})
}
pub(crate) fn load<P: AsRef<Path>>(path: P) -> Result<Self, NymNodeError> {
info!("attempting to load prior replay detection bloomfilter...");
let path = path.as_ref();
let mut file = File::open(path).map_err(|source| NymNodeError::BloomfilterIoFailure {
source,
path: path.to_path_buf(),
})?;
let mut buf = Vec::new();
file.read_to_end(&mut buf)
.map_err(|source| NymNodeError::BloomfilterIoFailure {
source,
path: path.to_path_buf(),
})?;
RotationFilter::try_from_bytes(buf)
}
}
@@ -0,0 +1,34 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
pub(crate) fn parse_rotation_id_from_filename(name: &str) -> Option<u32> {
let stripped = name.strip_prefix("rot-")?;
let ext_idx = stripped.rfind(".").unwrap_or(stripped.len());
let rotation = stripped.chars().take(ext_idx).collect::<String>();
rotation.parse::<u32>().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parsing_rotation_id() {
let test_cases = vec![
("rot", None),
("rot-123", Some(123)),
("foo-123", None),
("rot-123.ext", Some(123)),
("rot-123.different-ext", Some(123)),
("rot.123.aaa", None),
];
for (raw, expected) in test_cases {
assert_eq!(
parse_rotation_id_from_filename(raw),
expected,
"failed: {raw} to {expected:?}"
);
}
}
}
@@ -0,0 +1,115 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::config::Config;
use crate::error::NymNodeError;
use crate::node::replay_protection::bloomfilter::{ReplayProtectionBloomfilters, RotationFilter};
use crate::node::replay_protection::items_in_bloomfilter;
use human_repr::HumanCount;
use nym_node_metrics::NymNodeMetrics;
use std::cmp::max;
use std::time::Duration;
use time::OffsetDateTime;
use tracing::info;
#[derive(Clone)]
pub(crate) struct ReplayProtectionBloomfiltersManager {
target_fp_p: f64,
minimum_bloomfilter_packets_per_second: usize,
bloomfilter_size_multiplier: f64,
metrics: NymNodeMetrics,
filters: ReplayProtectionBloomfilters,
}
impl ReplayProtectionBloomfiltersManager {
pub(crate) fn new_disabled(metrics: NymNodeMetrics) -> Self {
// the exact config values are irrelevant as the filters will never be recreated
ReplayProtectionBloomfiltersManager {
target_fp_p: 0.001,
minimum_bloomfilter_packets_per_second: 1,
bloomfilter_size_multiplier: 1.0,
metrics,
filters: ReplayProtectionBloomfilters::new_disabled(),
}
}
pub(crate) fn new(
config: &Config,
primary: RotationFilter,
secondary: Option<RotationFilter>,
metrics: NymNodeMetrics,
) -> Self {
ReplayProtectionBloomfiltersManager {
target_fp_p: config.mixnet.replay_protection.debug.false_positive_rate,
minimum_bloomfilter_packets_per_second: config
.mixnet
.replay_protection
.debug
.bloomfilter_minimum_packets_per_second_size,
bloomfilter_size_multiplier: config
.mixnet
.replay_protection
.debug
.bloomfilter_size_multiplier,
metrics,
filters: ReplayProtectionBloomfilters::new(primary, secondary),
}
}
pub(crate) fn bloomfilters(&self) -> ReplayProtectionBloomfilters {
self.filters.clone()
}
pub(crate) fn primary_bytes_and_id(&self) -> Result<(Vec<u8>, u32), NymNodeError> {
self.filters.primary_bytes_and_id()
}
pub(crate) fn secondary_bytes_and_id(&self) -> Result<Option<(Vec<u8>, u32)>, NymNodeError> {
self.filters.secondary_bytes_and_id()
}
pub(crate) fn purge_secondary(&self) -> Result<(), NymNodeError> {
self.filters.purge_secondary()
}
pub(crate) fn promote_pre_announced(&self) -> Result<(), NymNodeError> {
self.filters.promote_pre_announced()
}
// TODO: actually do add some metrics
pub(crate) fn allocate_pre_announced(
&self,
rotation_id: u32,
rotation_lifetime: Duration,
) -> Result<(), NymNodeError> {
// 1. estimated the number of items in the filter based on the extrapolated items received
// by the primary filter
let received = self.metrics.mixnet.ingress.forward_hop_packets_received()
+ self.metrics.mixnet.ingress.final_hop_packets_received();
let primary = self.filters.primary_metadata()?;
let time_delta = OffsetDateTime::now_utc() - primary.creation_time;
let received_since_creation = received.saturating_sub(primary.packets_received_at_creation);
let received_per_second =
(received_since_creation as f64 / time_delta.as_seconds_f64()).round() as usize;
let bf_received = max(
received_per_second,
self.minimum_bloomfilter_packets_per_second,
);
let items_in_new_filter = items_in_bloomfilter(rotation_lifetime, bf_received);
let adjusted =
(items_in_new_filter as f64 * self.bloomfilter_size_multiplier).round() as usize;
info!(
"allocating new bloom filter. new expected number of packets: {} that preserve fp rate of {}",
adjusted.human_count_bare(),
self.target_fp_p
);
// 2. allocate the filter
self.filters
.allocate_pre_announced(adjusted, self.target_fp_p, received, rotation_id)
}
}
@@ -6,6 +6,8 @@ use std::time::Duration;
pub(crate) mod background_task;
pub(crate) mod bloomfilter;
mod helpers;
pub(crate) mod manager;
pub fn bitmap_size(false_positive_rate: f64, items_in_filter: usize) -> usize {
/// Equivalent to ln(1 / 2^ln(2)) = ln^2(2)
+63 -19
View File
@@ -2,18 +2,26 @@
// SPDX-License-Identifier: GPL-3.0-only
use crate::error::NymNodeError;
use crate::node::key_rotation::active_keys::ActiveSphinxKeys;
use crate::node::routing_filter::network_filter::NetworkRoutingFilter;
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_task::ShutdownToken;
use nym_topology::node::RoutingNode;
use nym_topology::{EpochRewardedSet, NymTopology, Role, TopologyProvider};
use nym_topology::{
EntryDetails, EpochRewardedSet, NodeId, NymTopology, NymTopologyMetadata, Role,
TopologyProvider,
};
use nym_validator_client::nym_api::NymApiClientExt;
use nym_validator_client::nym_nodes::{NodesByAddressesResponse, SkimmedNode};
use nym_validator_client::nym_nodes::{
NodesByAddressesResponse, SkimmedNode, SkimmedNodesWithMetadata,
};
use nym_validator_client::{NymApiClient, ValidatorClientError};
use std::collections::HashSet;
use std::net::IpAddr;
use std::net::{IpAddr, SocketAddr};
use std::ops::Deref;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
@@ -22,6 +30,8 @@ use tracing::log::error;
use tracing::{debug, trace, warn};
use url::Url;
const LOCAL_NODE_ID: NodeId = 1234567890;
struct NodesQuerier {
client: NymApiClient,
nym_api_urls: Vec<Url>,
@@ -53,10 +63,10 @@ impl NodesQuerier {
res
}
async fn current_nymnodes(&mut self) -> Result<Vec<SkimmedNode>, ValidatorClientError> {
async fn current_nymnodes(&mut self) -> Result<SkimmedNodesWithMetadata, ValidatorClientError> {
let res = self
.client
.get_all_basic_nodes()
.get_all_basic_nodes_with_metadata()
.await
.inspect_err(|err| error!("failed to get network nodes: {err}"));
@@ -84,16 +94,40 @@ impl NodesQuerier {
}
}
pub(crate) struct LocalGatewayNode {
pub(crate) active_sphinx_keys: ActiveSphinxKeys,
pub(crate) mix_host: SocketAddr,
pub(crate) identity_key: ed25519::PublicKey,
pub(crate) entry: EntryDetails,
}
impl LocalGatewayNode {
pub(crate) fn to_routing_node(&self) -> RoutingNode {
RoutingNode {
node_id: LOCAL_NODE_ID,
mix_host: self.mix_host,
entry: Some(self.entry.clone()),
identity_key: self.identity_key,
sphinx_key: self.active_sphinx_keys.primary().deref().x25519_pubkey(),
supported_roles: nym_topology::SupportedRoles {
mixnode: false,
mixnet_entry: true,
mixnet_exit: true,
},
}
}
}
#[derive(Clone)]
pub struct CachedTopologyProvider {
gateway_node: Arc<RoutingNode>,
gateway_node: Arc<LocalGatewayNode>,
cached_network: CachedNetwork,
min_mix_performance: u8,
}
impl CachedTopologyProvider {
pub(crate) fn new(
gateway_node: RoutingNode,
gateway_node: LocalGatewayNode,
cached_network: CachedNetwork,
min_mix_performance: u8,
) -> Self {
@@ -111,20 +145,24 @@ impl TopologyProvider for CachedTopologyProvider {
let network_guard = self.cached_network.inner.read().await;
let self_node = self.gateway_node.identity_key;
let mut topology = NymTopology::new_empty(network_guard.rewarded_set.clone())
.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
}
}));
let mut topology = NymTopology::new(
network_guard.topology_metadata,
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
}
}));
if !topology.has_node_details(self.gateway_node.node_id) {
if !topology.has_node(self.gateway_node.identity_key) {
debug!("{self_node} didn't exist in topology. inserting it.",);
topology.insert_node_details(self.gateway_node.as_ref().clone());
topology.insert_node_details(self.gateway_node.to_routing_node());
}
topology.force_set_active(self.gateway_node.node_id, Role::EntryGateway);
topology.force_set_active(LOCAL_NODE_ID, Role::EntryGateway);
Some(topology)
}
@@ -140,6 +178,7 @@ impl CachedNetwork {
CachedNetwork {
inner: Arc::new(RwLock::new(CachedNetworkInner {
rewarded_set: Default::default(),
topology_metadata: Default::default(),
network_nodes: vec![],
})),
}
@@ -148,6 +187,7 @@ impl CachedNetwork {
struct CachedNetworkInner {
rewarded_set: EpochRewardedSet,
topology_metadata: NymTopologyMetadata,
network_nodes: Vec<SkimmedNode>,
}
@@ -235,7 +275,9 @@ impl NetworkRefresher {
async fn refresh_network_nodes_inner(&mut self) -> Result<(), ValidatorClientError> {
let rewarded_set = self.querier.rewarded_set().await?;
let nodes = self.querier.current_nymnodes().await?;
let res = self.querier.current_nymnodes().await?;
let nodes = res.nodes;
let metadata = res.metadata;
// collect all known/allowed nodes information
let known_nodes = nodes
@@ -264,6 +306,8 @@ impl NetworkRefresher {
self.routing_filter.pending.clear().await;
let mut network_guard = self.network.inner.write().await;
network_guard.topology_metadata =
NymTopologyMetadata::new(metadata.rotation_id, metadata.absolute_epoch_id);
network_guard.network_nodes = nodes;
network_guard.rewarded_set = rewarded_set;