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:
committed by
GitHub
parent
adbe0392ca
commit
d8c84cc4d6
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user