Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a38f1c3a6 | |||
| fc79fe4738 | |||
| 187c6a51fd | |||
| c93d106ca3 | |||
| 5f1553d589 | |||
| 258ceded26 | |||
| be76065c66 | |||
| d2558d96e0 | |||
| 05ed775686 | |||
| c8f9959d7a | |||
| 8293870461 | |||
| c0a8f97a20 | |||
| 804b17517f | |||
| 2722544c86 | |||
| 732a09aa41 | |||
| e1c4085217 | |||
| 34045d02b9 | |||
| b7a36373e5 | |||
| 17d16503a7 | |||
| df566933ba | |||
| f73f1a5219 | |||
| 62a5d1437d | |||
| e952f9df24 | |||
| 525e9314b4 | |||
| 8573004c34 | |||
| 5636c5afc4 | |||
| f505c29926 | |||
| 95bec7422c | |||
| c02c28f7cb | |||
| 6fb4a98667 | |||
| 4a50f6dcd0 | |||
| 53dec68378 | |||
| f0ecdfd295 | |||
| 668477c5c3 | |||
| 53aaa71178 | |||
| 35517f1df6 | |||
| ed5ddf0170 | |||
| 644e669a15 | |||
| 1fd25529ce | |||
| 8677b98bcb | |||
| ca031af69a | |||
| 7c0264b839 |
Generated
+554
-295
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,7 @@ members = [
|
||||
"common/nym-kkt-ciphersuite",
|
||||
"common/nym-kkt-context",
|
||||
"common/nym-lp",
|
||||
"common/nym-lp-data",
|
||||
"common/nym-metrics",
|
||||
"common/nym_offline_compact_ecash",
|
||||
"common/nymnoise",
|
||||
@@ -135,6 +136,7 @@ members = [
|
||||
"nym-data-observatory",
|
||||
"nym-gateway-probe",
|
||||
"nym-ip-packet-client",
|
||||
"nym-mix-sim",
|
||||
"nym-network-monitor",
|
||||
"nym-node",
|
||||
"nym-node-status-api/nym-node-status-agent",
|
||||
@@ -185,6 +187,7 @@ default-members = [
|
||||
"nym-api",
|
||||
"nym-authenticator-client",
|
||||
"nym-credential-proxy/nym-credential-proxy",
|
||||
"nym-mix-sim",
|
||||
"nym-node",
|
||||
"nym-registration-client",
|
||||
"nym-statistics-api",
|
||||
@@ -459,6 +462,7 @@ nym-id = { version = "1.21.0", path = "common/nym-id" }
|
||||
nym-ip-packet-client = { version = "1.21.0", path = "nym-ip-packet-client" }
|
||||
nym-ip-packet-requests = { version = "1.21.0", path = "common/ip-packet-requests" }
|
||||
nym-lp = { version = "1.21.0", path = "common/nym-lp" }
|
||||
nym-lp-data = { version = "1.21.0", path = "common/nym-lp-data" }
|
||||
nym-kkt = { version = "1.21.0", path = "common/nym-kkt" }
|
||||
nym-kkt-ciphersuite = { version = "1.21.0", path = "common/nym-kkt-ciphersuite" }
|
||||
nym-kkt-context = { version = "1.21.0", path = "common/nym-kkt-context" }
|
||||
|
||||
@@ -60,6 +60,7 @@ nym-client-core-surb-storage = { workspace = true }
|
||||
nym-client-core-gateways-storage = { workspace = true }
|
||||
nym-ecash-time = { workspace = true }
|
||||
nym-mixnet-contract-common = { workspace = true }
|
||||
nym-lp-data = { workspace = true }
|
||||
|
||||
[target."cfg(not(target_arch = \"wasm32\"))".dependencies]
|
||||
nym-mixnet-client = { workspace = true }
|
||||
|
||||
@@ -11,6 +11,8 @@ use crate::client::event_control::EventControl;
|
||||
use crate::client::inbound_messages::{InputMessage, InputMessageReceiver, InputMessageSender};
|
||||
use crate::client::key_manager::ClientKeys;
|
||||
use crate::client::key_manager::persistence::KeyStore;
|
||||
use crate::client::lp::data::LpDataSetup;
|
||||
use crate::client::lp::data::shared::SharedLpDataState;
|
||||
use crate::client::mix_traffic::transceiver::{GatewayReceiver, GatewayTransceiver, RemoteGateway};
|
||||
use crate::client::mix_traffic::{BatchMixMessageSender, MixTrafficController, MixTrafficEvent};
|
||||
use crate::client::real_messages_control;
|
||||
@@ -636,7 +638,6 @@ where
|
||||
{
|
||||
Err(ClientCoreError::CustomGatewaySelectionExpected)
|
||||
} else {
|
||||
// and make sure to invalidate the task client, so we wouldn't cause premature shutdown
|
||||
custom_gateway_transceiver.set_packet_router(packet_router)?;
|
||||
Ok(custom_gateway_transceiver)
|
||||
};
|
||||
@@ -817,6 +818,24 @@ where
|
||||
(mix_tx, client_tx)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn build_lp_data_tasks(
|
||||
config: &Config,
|
||||
encryption_keys: Arc<x25519::KeyPair>,
|
||||
identity_keys: Arc<ed25519::KeyPair>,
|
||||
input_receiver: InputMessageReceiver,
|
||||
shutdown_tracker: &ShutdownTracker,
|
||||
) -> Result<LpDataSetup, ClientCoreError> {
|
||||
let shared_state = SharedLpDataState::new(
|
||||
config.debug,
|
||||
encryption_keys,
|
||||
identity_keys,
|
||||
shutdown_tracker.clone_shutdown_token(),
|
||||
);
|
||||
|
||||
LpDataSetup::new(shared_state, input_receiver, shutdown_tracker.clone())
|
||||
}
|
||||
|
||||
// TODO: rename it as it implies the data is persistent whilst one can use InMemBackend
|
||||
async fn setup_persistent_reply_storage(
|
||||
backend: S::ReplyStore,
|
||||
@@ -1063,12 +1082,27 @@ where
|
||||
)
|
||||
.await?;
|
||||
|
||||
// SW keep all the above
|
||||
|
||||
// LP Data channel
|
||||
// let lp_data_tasks = Self::build_lp_data_tasks(
|
||||
// &self.config,
|
||||
// encryption_keys.clone(),
|
||||
// identity_keys.clone(),
|
||||
// input_receiver,
|
||||
// &shutdown_tracker.clone(),
|
||||
// )?;
|
||||
// lp_data_tasks.start_tasks();
|
||||
|
||||
// SW Piping between inbound and outbound
|
||||
let gateway_packet_router = PacketRouter::new(
|
||||
ack_sender,
|
||||
mixnet_messages_sender,
|
||||
shutdown_tracker.clone_shutdown_token(),
|
||||
);
|
||||
|
||||
// SW this needs to become the IO handler
|
||||
|
||||
let gateway_transceiver = Self::setup_gateway_transceiver(
|
||||
self.custom_gateway_transceiver,
|
||||
&self.config,
|
||||
@@ -1090,6 +1124,7 @@ where
|
||||
)
|
||||
.await?;
|
||||
|
||||
// SW turn into inbound pipeline
|
||||
Self::start_received_messages_buffer_controller(
|
||||
encryption_keys,
|
||||
received_buffer_request_receiver,
|
||||
@@ -1100,6 +1135,8 @@ where
|
||||
&shutdown_tracker.clone(),
|
||||
);
|
||||
|
||||
// SW the rest below is outbound pipeline
|
||||
|
||||
// The message_sender is the transmitter for any component generating sphinx packets
|
||||
// that are to be sent to the mixnet. They are used by cover traffic stream and real
|
||||
// traffic stream.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use nym_lp_data::packet::frame::LpFrameKind;
|
||||
use nym_sphinx::addressing::nodes::NymNodeRoutingAddressError;
|
||||
use nym_sphinx::forwarding::packet::MixPacketFormattingError;
|
||||
use nym_sphinx::framing::processing::PacketProcessingError;
|
||||
use nym_sphinx::{OutfoxError, SphinxError};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LpDataHandlerError {
|
||||
#[error(transparent)]
|
||||
PacketFormattingError(#[from] MixPacketFormattingError),
|
||||
|
||||
#[error(transparent)]
|
||||
PacketProcessingError(#[from] PacketProcessingError),
|
||||
|
||||
#[error(transparent)]
|
||||
NymNodeRoutingAddressError(#[from] NymNodeRoutingAddressError),
|
||||
|
||||
#[error("failed to process received sphinx packet: {0}")]
|
||||
SphinxProcessingError(#[from] SphinxError),
|
||||
|
||||
#[error("failed to process received outfox packet: {0}")]
|
||||
OutfoxProcessingError(#[from] OutfoxError),
|
||||
|
||||
#[error("received payload type of an unexpected type: {typ:?}")]
|
||||
UnexpectedLpPayload { typ: LpFrameKind },
|
||||
|
||||
#[error("received an Lp Frame kind that we don't support: {typ:?}")]
|
||||
UnsupportedLpFrameKind { typ: LpFrameKind },
|
||||
|
||||
#[error("unwrapped a packet into a forward hop packet. This is no longer supported")]
|
||||
ForwardHop,
|
||||
|
||||
#[error("{0}")]
|
||||
Internal(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl LpDataHandlerError {
|
||||
pub fn internal(message: impl Into<String>) -> Self {
|
||||
LpDataHandlerError::Internal(message.into())
|
||||
}
|
||||
|
||||
pub fn other(message: impl Into<String>) -> Self {
|
||||
LpDataHandlerError::Other(message.into())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use nym_lp_data::packet::frame::{LpFrameAttributes, LpFrameHeader, LpFrameKind};
|
||||
use nym_sphinx::forwarding::packet::MixPacketFormattingError;
|
||||
use nym_sphinx::params::SphinxKeyRotation;
|
||||
|
||||
use crate::client::lp::data::handler::error::LpDataHandlerError;
|
||||
|
||||
/// Message types supported by clients
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ClientMessage {
|
||||
Sphinx(SphinxMessage),
|
||||
Outfox(OutfoxMessage),
|
||||
}
|
||||
|
||||
impl ClientMessage {
|
||||
pub fn from_frame_header(header: LpFrameHeader) -> Result<Self, LpDataHandlerError> {
|
||||
match header.kind {
|
||||
LpFrameKind::SphinxPacket => {
|
||||
Ok(ClientMessage::Sphinx(header.frame_attributes.try_into()?))
|
||||
}
|
||||
LpFrameKind::OutfoxPacket => {
|
||||
Ok(ClientMessage::Outfox(header.frame_attributes.try_into()?))
|
||||
}
|
||||
_ => Err(LpDataHandlerError::UnsupportedLpFrameKind { typ: header.kind }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SphinxMessage {
|
||||
pub key_rotation: SphinxKeyRotation,
|
||||
}
|
||||
|
||||
impl TryFrom<LpFrameAttributes> for SphinxMessage {
|
||||
type Error = LpDataHandlerError;
|
||||
|
||||
fn try_from(value: LpFrameAttributes) -> Result<Self, Self::Error> {
|
||||
let key_rotation = value[0]
|
||||
.try_into()
|
||||
.map_err(MixPacketFormattingError::InvalidKeyRotation)?;
|
||||
Ok(SphinxMessage { key_rotation })
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SphinxMessage> for LpFrameAttributes {
|
||||
fn from(value: SphinxMessage) -> Self {
|
||||
let mut attrs = [0; 14];
|
||||
attrs[0] = value.key_rotation as u8;
|
||||
attrs
|
||||
}
|
||||
}
|
||||
|
||||
// For now there are no differences. We can augment this variant when we will need it
|
||||
pub type OutfoxMessage = SphinxMessage;
|
||||
@@ -0,0 +1,216 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use crate::client::inbound_messages::InputMessageReceiver;
|
||||
use crate::client::lp::LpDataHandlerError;
|
||||
use crate::client::lp::data::PACKET_BUFFER_SIZE;
|
||||
use crate::client::lp::data::shared::SharedLpDataState;
|
||||
use nym_lp_data::clients::traits::ClientUnwrappingPipeline;
|
||||
use nym_lp_data::common::traits::TransportUnwrap;
|
||||
use nym_lp_data::packet::{EncryptedLpPacket, MalformedLpPacketError};
|
||||
use nym_lp_data::{AddressedTimedData, TimedData};
|
||||
use std::sync::{Arc, mpsc};
|
||||
use std::time::Instant;
|
||||
use std::{net::SocketAddr, time::Duration};
|
||||
use tokio::sync::mpsc::error::TrySendError;
|
||||
use tokio::time::interval;
|
||||
use tracing::*;
|
||||
|
||||
pub mod error;
|
||||
pub mod messages;
|
||||
pub mod pipeline;
|
||||
mod processing;
|
||||
|
||||
const PIPELINE_TICKING_DURATION: Duration = Duration::from_millis(1);
|
||||
|
||||
/// Bounded queue depth in front of each worker; keeps memory bounded under
|
||||
/// bursty load and provides drop-based backpressure.
|
||||
const WORKER_QUEUE_DEPTH: usize = 128;
|
||||
|
||||
type WorkerOutput = Result<Option<Vec<u8>>, MalformedLpPacketError>;
|
||||
|
||||
/// LP Data Handler for UDP data plane, acts as a pipeline driver and buffer
|
||||
/// for delaying packets. Heavy per-packet processing is fanned out across a
|
||||
/// pool of worker threads spawned on the shared blocking pool tracked by the
|
||||
/// surrounding [`nym_task::ShutdownTracker`].
|
||||
pub struct LpDataHandler {
|
||||
/// Shared state
|
||||
shared_state: Arc<SharedLpDataState>,
|
||||
|
||||
// Outbound pipeline
|
||||
/// Channel to receive data for the outbound pipeline
|
||||
outbound_input_rx: InputMessageReceiver,
|
||||
/// Buffer for outbound packet
|
||||
outbound_pkt_buffer: Vec<AddressedTimedData<EncryptedLpPacket>>,
|
||||
/// Channel to send outgoing data from the outbound pipeline
|
||||
outbound_output_tx: tokio::sync::mpsc::Sender<(EncryptedLpPacket, SocketAddr)>,
|
||||
|
||||
// Inbound pipeline
|
||||
/// Channel to receive incoming data for the inbound pipeline
|
||||
inbound_input_rx: mpsc::Receiver<EncryptedLpPacket>,
|
||||
/// Per-worker job queues (round-robin dispatch).
|
||||
worker_input_txs: Vec<mpsc::SyncSender<TimedData<EncryptedLpPacket>>>,
|
||||
/// Aggregated processed packets returned by the workers. (Inbound data)
|
||||
worker_output_rx: mpsc::Receiver<WorkerOutput>,
|
||||
|
||||
/// Shutdown token
|
||||
shutdown: nym_task::ShutdownToken,
|
||||
}
|
||||
|
||||
impl LpDataHandler {
|
||||
pub(crate) fn new(
|
||||
shared_state: Arc<SharedLpDataState>,
|
||||
outbound_input_rx: InputMessageReceiver,
|
||||
outbound_output_tx: tokio::sync::mpsc::Sender<(EncryptedLpPacket, SocketAddr)>,
|
||||
inbound_input_rx: mpsc::Receiver<EncryptedLpPacket>,
|
||||
// SW TODO : inbound output (worker_output_rx)
|
||||
shutdown_tracker: &nym_task::ShutdownTracker,
|
||||
) -> Result<Self, LpDataHandlerError> {
|
||||
let (worker_output_tx, worker_output_rx) = mpsc::sync_channel(PACKET_BUFFER_SIZE);
|
||||
|
||||
// Allow at least one worker, even if the config says 0
|
||||
let worker_count = 4; // SW Put that in the config
|
||||
|
||||
// Create workers. They will stop naturally when worker_output_rx is dropped.
|
||||
// The mode is decided once here; each closure picks the right pipeline type so
|
||||
// the worker loop monomorphizes against a single concrete pipeline.
|
||||
let worker_input_txs = (0..worker_count)
|
||||
.map(|_| {
|
||||
let (worker_input_tx, _worker_input_rx) = mpsc::sync_channel(WORKER_QUEUE_DEPTH);
|
||||
let _worker_state = shared_state.clone();
|
||||
let _worker_output = worker_output_tx.clone();
|
||||
|
||||
shutdown_tracker.spawn_blocking(move || {
|
||||
// Instantiat pipeline
|
||||
todo!()
|
||||
//Self::run_worker(pipeline, worker_input_rx, worker_output);
|
||||
});
|
||||
|
||||
worker_input_tx
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Self {
|
||||
shared_state,
|
||||
outbound_input_rx,
|
||||
outbound_pkt_buffer: Vec::new(),
|
||||
outbound_output_tx,
|
||||
inbound_input_rx,
|
||||
worker_input_txs,
|
||||
worker_output_rx,
|
||||
shutdown: shutdown_tracker.clone_shutdown_token(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn run(&mut self) {
|
||||
info!(
|
||||
workers = self.worker_input_txs.len(),
|
||||
"Starting LP data handler"
|
||||
);
|
||||
let mut ticking_interval = interval(PIPELINE_TICKING_DURATION);
|
||||
let mut next_worker = 0;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = self.shutdown.cancelled() => {
|
||||
info!("LP data handler: received shutdown signal");
|
||||
break;
|
||||
}
|
||||
|
||||
timestamp = ticking_interval.tick() => {
|
||||
let std_timestamp: Instant = timestamp.into();
|
||||
|
||||
// Drain processed packets returned by workers.
|
||||
while let Ok(processing_result) = self.worker_output_rx.try_recv() {
|
||||
match processing_result {
|
||||
Ok(_packets) => {
|
||||
// Dispatch to application
|
||||
todo!()
|
||||
},
|
||||
Err(e) => {
|
||||
warn!("LP data worker: error processing packet : {e}");
|
||||
},
|
||||
}
|
||||
|
||||
}
|
||||
// Dispatch incoming packets to workers.
|
||||
while let Ok(input) = self.inbound_input_rx.try_recv() {
|
||||
next_worker = self.dispatch_to_workers(
|
||||
TimedData::new(std_timestamp, input),
|
||||
next_worker,
|
||||
);
|
||||
}
|
||||
|
||||
// Run outbound pipeline
|
||||
while let Ok(_input) = self.outbound_input_rx.try_recv() {
|
||||
// Run outbound pipeline and stack result in outbound_pkt_buffer
|
||||
todo!()
|
||||
}
|
||||
|
||||
// Send packets that needs sending
|
||||
for pkt in self.outbound_pkt_buffer.extract_if(.., |p| p.data.timestamp <= std_timestamp) {
|
||||
if let Err(e) = self.outbound_output_tx.try_send((pkt.data.data, pkt.dst)) {
|
||||
match e {
|
||||
TrySendError::Full(_) => {
|
||||
warn!("LP data handler: packet sending buffer is full, the client might be overloaded");
|
||||
},
|
||||
TrySendError::Closed(_) => {
|
||||
break;
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Workers will stop because we are dropping the receiving channel
|
||||
info!("LP data handler shutdown complete");
|
||||
}
|
||||
|
||||
/// Round-robin dispatch a job across worker queues. If the chosen worker is
|
||||
/// full, fall through to the next one; if all are saturated, drop the packet
|
||||
/// (UDP-style) and bump a metric. Returns the worker index to start from on
|
||||
/// the next dispatch.
|
||||
fn dispatch_to_workers(&self, mut job: TimedData<EncryptedLpPacket>, start: usize) -> usize {
|
||||
let n = self.worker_input_txs.len();
|
||||
for offset in 0..n {
|
||||
let idx = (start + offset) % n;
|
||||
match self.worker_input_txs[idx].try_send(job) {
|
||||
Ok(()) => return (idx + 1) % n,
|
||||
Err(mpsc::TrySendError::Full(returned)) => {
|
||||
job = returned;
|
||||
}
|
||||
Err(mpsc::TrySendError::Disconnected(returned)) => {
|
||||
error!(
|
||||
"LP data worker {idx} disconnected; this shouldn't happen outside of shut down"
|
||||
);
|
||||
job = returned;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
warn!("LP data handler: all workers saturated, dropping packet");
|
||||
start
|
||||
}
|
||||
|
||||
fn run_worker<P>(
|
||||
mut pipeline: P,
|
||||
input_rx: mpsc::Receiver<TimedData<EncryptedLpPacket>>,
|
||||
output_tx: mpsc::SyncSender<WorkerOutput>,
|
||||
) where
|
||||
P: ClientUnwrappingPipeline<EncryptedLpPacket, ()> // SW fill in message kind
|
||||
+ TransportUnwrap<EncryptedLpPacket, Error = MalformedLpPacketError>, // This is needed to specify the error type
|
||||
{
|
||||
while let Ok(input) = input_rx.recv() {
|
||||
// Blocking is fine, we don't want to unclog ourself and process a new packet that will be dropped anyway
|
||||
if let Err(e) = output_tx.send(pipeline.unwrap(input.data, input.timestamp)) {
|
||||
trace!(
|
||||
"Failed to send processing data back to handler : {e}. We are probably shutting down"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// TODO
|
||||
@@ -0,0 +1,5 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
pub(crate) mod outfox;
|
||||
pub(crate) mod sphinx;
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use nym_lp_data::TimedPayload;
|
||||
use nym_sphinx::OutfoxPacket;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::client::lp::data::{
|
||||
handler::{error::LpDataHandlerError, messages::OutfoxMessage},
|
||||
shared::SharedLpDataState,
|
||||
};
|
||||
|
||||
pub(crate) fn process(
|
||||
shared_state: &SharedLpDataState,
|
||||
outfox_packet: TimedPayload,
|
||||
_metadata: OutfoxMessage,
|
||||
) -> Result<TimedPayload, LpDataHandlerError> {
|
||||
let TimedPayload {
|
||||
data: outfox_bytes,
|
||||
timestamp: arrival_timestamp,
|
||||
} = outfox_packet;
|
||||
|
||||
let mut outfox_packet = OutfoxPacket::try_from(outfox_bytes.as_slice())?;
|
||||
|
||||
let _next_address =
|
||||
outfox_packet.decode_next_layer(shared_state.encryption_keys.private_key().as_ref())?;
|
||||
|
||||
if outfox_packet.is_final_hop() {
|
||||
Ok(TimedPayload::new(
|
||||
arrival_timestamp,
|
||||
outfox_packet.payload().to_vec(),
|
||||
))
|
||||
} else {
|
||||
warn!("Dropping forward hop packet in a client");
|
||||
Err(LpDataHandlerError::ForwardHop)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use nym_lp_data::TimedPayload;
|
||||
use nym_sphinx::{ProcessedPacketData, SphinxPacket};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::client::lp::data::{
|
||||
handler::{error::LpDataHandlerError, messages::SphinxMessage},
|
||||
shared::SharedLpDataState,
|
||||
};
|
||||
|
||||
pub(crate) fn process(
|
||||
shared_state: &SharedLpDataState,
|
||||
sphinx_packet: TimedPayload,
|
||||
_metadata: SphinxMessage,
|
||||
) -> Result<TimedPayload, LpDataHandlerError> {
|
||||
let TimedPayload {
|
||||
data: sphinx_bytes,
|
||||
timestamp: arrival_timestamp,
|
||||
} = sphinx_packet;
|
||||
|
||||
let sphinx_packet = SphinxPacket::from_bytes(&sphinx_bytes)?;
|
||||
|
||||
// Final processing
|
||||
let processed_packet =
|
||||
sphinx_packet.process(shared_state.encryption_keys.private_key().as_ref())?;
|
||||
|
||||
match processed_packet.data {
|
||||
ProcessedPacketData::ForwardHop { .. } => {
|
||||
warn!("Dropping forward hop packet in a client");
|
||||
Err(LpDataHandlerError::ForwardHop)
|
||||
}
|
||||
ProcessedPacketData::FinalHop { payload, .. } => Ok(TimedPayload::new(
|
||||
arrival_timestamp,
|
||||
payload.recover_plaintext()?,
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use crate::client::lp::data::MAX_UDP_PACKET_SIZE;
|
||||
use crate::client::lp::data::shared::SharedLpDataState;
|
||||
use crate::error::ClientCoreError;
|
||||
use nym_lp_data::packet::EncryptedLpPacket;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, mpsc, mpsc::TrySendError};
|
||||
use tokio::net::UdpSocket;
|
||||
use tracing::log::warn;
|
||||
use tracing::{error, info};
|
||||
|
||||
/// LP UDP listener that accepts TCP connections on port 51264 (by default)
|
||||
pub(crate) struct LpDataListener {
|
||||
/// Shared state
|
||||
shared_state: Arc<SharedLpDataState>,
|
||||
|
||||
/// Channel to send incoming data to the processing pipeline
|
||||
inbound_input_tx: mpsc::SyncSender<EncryptedLpPacket>,
|
||||
|
||||
// This has to be a tokio channel, to be async and bounded
|
||||
/// Channel to receive outgoing data from the processling pipeline
|
||||
outbound_output_rx: tokio::sync::mpsc::Receiver<(EncryptedLpPacket, SocketAddr)>,
|
||||
|
||||
/// Shutdown token
|
||||
shutdown: nym_task::ShutdownToken,
|
||||
}
|
||||
|
||||
impl LpDataListener {
|
||||
pub fn new(
|
||||
shared_state: Arc<SharedLpDataState>,
|
||||
inbound_input_tx: mpsc::SyncSender<EncryptedLpPacket>,
|
||||
outbound_output_rx: tokio::sync::mpsc::Receiver<(EncryptedLpPacket, SocketAddr)>,
|
||||
shutdown: nym_task::ShutdownToken,
|
||||
) -> Self {
|
||||
Self {
|
||||
shared_state,
|
||||
inbound_input_tx,
|
||||
outbound_output_rx,
|
||||
shutdown,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(&mut self) -> Result<(), ClientCoreError> {
|
||||
let socket = UdpSocket::bind("[::]:0").await.map_err(|source| {
|
||||
error!("Failed to bind LP data socket: {source}");
|
||||
ClientCoreError::LpBindFailure { source }
|
||||
})?;
|
||||
info!("Started LP data socket on {}", socket.local_addr()?);
|
||||
|
||||
let mut buf = vec![0u8; MAX_UDP_PACKET_SIZE];
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = self.shutdown.cancelled() => {
|
||||
info!("LP data listener: received shutdown signal");
|
||||
break;
|
||||
}
|
||||
|
||||
result = self.outbound_output_rx.recv() => {
|
||||
match result {
|
||||
Some((payload, dst_addr)) => {
|
||||
if let Err(e) = socket.send_to(&payload.to_bytes(), dst_addr).await {
|
||||
warn!("LP data packet error to {dst_addr}: {e}");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
warn!("LP outgoing packet channel closed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = socket.recv_from(&mut buf) => {
|
||||
match result {
|
||||
Ok((len, src_addr)) => {
|
||||
info!("received {len} bytes from {src_addr} on the LP Data socket");
|
||||
if let Ok(encrypted_packet) = EncryptedLpPacket::decode(&buf[..len]) {
|
||||
if let Err(e) = self.inbound_input_tx.try_send(encrypted_packet) {
|
||||
match e {
|
||||
TrySendError::Full(_) => {
|
||||
warn!("LP data listener: packet sending buffer is full, the client might be overloaded");
|
||||
},
|
||||
TrySendError::Disconnected(_) => {
|
||||
warn!("LP data listener: incoming packet channel is closed");
|
||||
break;
|
||||
},
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("Error reading LP packet from wire");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("LP data socket recv error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("LP data handler shutdown complete");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Parking the branch
|
||||
#![allow(clippy::todo)]
|
||||
#![allow(dead_code)]
|
||||
#![allow(clippy::incompatible_msrv)]
|
||||
|
||||
use std::sync::{Arc, mpsc};
|
||||
|
||||
use crate::client::inbound_messages::InputMessageReceiver;
|
||||
use crate::client::lp::data::handler::LpDataHandler;
|
||||
use crate::client::lp::data::listener::LpDataListener;
|
||||
use crate::client::lp::data::shared::SharedLpDataState;
|
||||
use crate::error::ClientCoreError;
|
||||
|
||||
use nym_task::ShutdownTracker;
|
||||
use tracing::error;
|
||||
|
||||
/// Maximum UDP packet size we'll accept
|
||||
/// Sphinx packets are typically ~2KB, LP overhead is ~50 bytes, so 4KB is plenty
|
||||
const MAX_UDP_PACKET_SIZE: usize = 4096;
|
||||
|
||||
pub(crate) const PACKET_BUFFER_SIZE: usize = 100;
|
||||
|
||||
pub mod handler;
|
||||
mod listener;
|
||||
pub mod shared;
|
||||
|
||||
pub struct LpDataSetup {
|
||||
listener: LpDataListener,
|
||||
|
||||
handler: LpDataHandler,
|
||||
|
||||
/// Shutdown coordination
|
||||
shutdown: ShutdownTracker,
|
||||
}
|
||||
|
||||
impl LpDataSetup {
|
||||
pub(crate) fn new(
|
||||
shared_state: SharedLpDataState,
|
||||
outbound_input_rx: InputMessageReceiver,
|
||||
shutdown: ShutdownTracker,
|
||||
) -> Result<Self, ClientCoreError> {
|
||||
let (inbound_input_tx, inbound_input_rx) = mpsc::sync_channel(PACKET_BUFFER_SIZE);
|
||||
let (outbound_output_tx, outbound_output_rx) =
|
||||
tokio::sync::mpsc::channel(PACKET_BUFFER_SIZE);
|
||||
|
||||
let shared_state = Arc::new(shared_state);
|
||||
|
||||
let listener = LpDataListener::new(
|
||||
shared_state.clone(),
|
||||
inbound_input_tx,
|
||||
outbound_output_rx,
|
||||
shutdown.clone_shutdown_token(),
|
||||
);
|
||||
|
||||
let handler = LpDataHandler::new(
|
||||
shared_state,
|
||||
outbound_input_rx,
|
||||
outbound_output_tx,
|
||||
inbound_input_rx,
|
||||
&shutdown,
|
||||
)?;
|
||||
|
||||
Ok(LpDataSetup {
|
||||
listener,
|
||||
handler,
|
||||
shutdown,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn start_tasks(mut self) {
|
||||
// Spawn the UDP data handler for LP data plane
|
||||
// The data handler listens on UDP port 51264 and processes LP-wrapped Sphinx packets
|
||||
// from registered clients. It decrypts the LP layer and forwards the Sphinx packets
|
||||
let shutdown_token = self.shutdown.clone_shutdown_token();
|
||||
let mut listener = self.listener;
|
||||
self.shutdown.try_spawn_named(
|
||||
async move {
|
||||
if let Err(err) = listener.run().await {
|
||||
shutdown_token.cancel();
|
||||
error!("LP data listener error: {err}");
|
||||
}
|
||||
},
|
||||
"LP::LpDataListener",
|
||||
);
|
||||
|
||||
self.shutdown
|
||||
.try_spawn_named(async move { self.handler.run().await }, "LP::LpDataHandler");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Sphinx packets are typically around 2KB
|
||||
// 4KB should be plenty with room to spare
|
||||
const _: () = {
|
||||
assert!(MAX_UDP_PACKET_SIZE >= 2048 + 100);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use nym_client_core_config_types::DebugConfig;
|
||||
use nym_crypto::asymmetric::{ed25519, x25519};
|
||||
use nym_lp_data::fragmentation::reconstruction::MessageReconstructor;
|
||||
use nym_task::ShutdownToken;
|
||||
|
||||
/// Shared state for LP data plane
|
||||
pub struct SharedLpDataState {
|
||||
pub(crate) config: DebugConfig,
|
||||
|
||||
pub(crate) encryption_keys: Arc<x25519::KeyPair>,
|
||||
pub(crate) identity_keys: Arc<ed25519::KeyPair>,
|
||||
|
||||
pub(crate) message_reconstructor: MessageReconstructor,
|
||||
|
||||
pub(crate) shutdown_token: ShutdownToken,
|
||||
}
|
||||
|
||||
impl SharedLpDataState {
|
||||
pub(crate) fn new(
|
||||
config: DebugConfig,
|
||||
encryption_keys: Arc<x25519::KeyPair>,
|
||||
identity_keys: Arc<ed25519::KeyPair>,
|
||||
shutdown_token: ShutdownToken,
|
||||
) -> Self {
|
||||
SharedLpDataState {
|
||||
config,
|
||||
encryption_keys,
|
||||
identity_keys,
|
||||
message_reconstructor: Default::default(),
|
||||
shutdown_token,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
pub use data::handler::error::LpDataHandlerError;
|
||||
|
||||
pub mod data;
|
||||
@@ -7,6 +7,7 @@ pub(crate) mod event_control;
|
||||
pub(crate) mod helpers;
|
||||
pub mod inbound_messages;
|
||||
pub mod key_manager;
|
||||
pub mod lp;
|
||||
pub mod mix_traffic;
|
||||
pub mod real_messages_control;
|
||||
pub mod received_buffer;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright 2022-2023 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use crate::client::lp::LpDataHandlerError;
|
||||
use crate::client::mix_traffic::transceiver::ErasedGatewayError;
|
||||
use nym_crypto::asymmetric::ed25519::Ed25519RecoveryError;
|
||||
use nym_gateway_client::error::GatewayClientError;
|
||||
@@ -263,6 +264,12 @@ pub enum ClientCoreError {
|
||||
|
||||
#[error("Could not access task registry, {0}")]
|
||||
RegistryAccess(#[from] RegistryAccessError),
|
||||
|
||||
#[error("failed to bind LP UDP socket: {source}")]
|
||||
LpBindFailure { source: std::io::Error },
|
||||
|
||||
#[error(transparent)]
|
||||
LpFailure(#[from] LpDataHandlerError),
|
||||
}
|
||||
|
||||
impl From<tungstenite::Error> for ClientCoreError {
|
||||
|
||||
@@ -5,6 +5,7 @@ use dashmap::DashMap;
|
||||
use futures::StreamExt;
|
||||
use nym_noise::config::NoiseConfig;
|
||||
use nym_noise::upgrade_noise_initiator;
|
||||
use nym_sphinx::addressing::nodes::NymNodeRoutingAddress;
|
||||
use nym_sphinx::forwarding::packet::MixPacket;
|
||||
use nym_sphinx::framing::codec::NymCodec;
|
||||
use nym_sphinx::framing::packet::FramedNymPacket;
|
||||
@@ -309,7 +310,13 @@ impl Client {
|
||||
|
||||
impl SendWithoutResponse for Client {
|
||||
fn send_without_response(&self, packet: MixPacket) -> io::Result<()> {
|
||||
let address = packet.next_hop_address();
|
||||
let address = match packet.next_hop() {
|
||||
NymNodeRoutingAddress::Client(_) => {
|
||||
warn!("mix packet addressed to a client in the legacy send_without_response path. This should never happen!");
|
||||
return Ok(());
|
||||
}
|
||||
NymNodeRoutingAddress::Node(address) => address,
|
||||
};
|
||||
trace!("Sending packet to {address}");
|
||||
|
||||
// TODO: optimisation for the future: rather than constantly using legacy encoding,
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use super::PublicKey;
|
||||
use super::{PrivateKey, PublicKey};
|
||||
|
||||
pub mod bs58_x25519_private_key {
|
||||
use super::*;
|
||||
use serde::{Deserialize, Deserializer, Serializer};
|
||||
|
||||
pub fn serialize<S: Serializer>(key: &PrivateKey, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&key.to_base58_string())
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<PrivateKey, D::Error> {
|
||||
let s = String::deserialize(deserializer)?;
|
||||
PrivateKey::from_base58_string(s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
pub mod bs58_x25519_pubkey {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "nym-lp-data"
|
||||
description = "Lewes Protocol data structure for the Nym network"
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
documentation.workspace = true
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
bytes.workspace = true
|
||||
dashmap.workspace = true
|
||||
num_enum.workspace = true
|
||||
tracing.workspace = true
|
||||
thiserror.workspace = true
|
||||
rand.workspace = true
|
||||
|
||||
nym-common.workspace = true
|
||||
|
||||
|
||||
[dev-dependencies]
|
||||
nym-lp.workspace = true
|
||||
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,103 @@
|
||||
# nym-lp-data
|
||||
|
||||
Trait definitions and data structures for Lewes Protocol (LP) processing pipelines in the Nym mixnet.
|
||||
|
||||
This crate is a *vocabulary* crate — it defines the traits that clients and mix nodes implement to compose a packet-processing pipeline, plus a few generic data wrappers (`TimedData`, `AddressedTimedData`, `PipelineData`) that thread per-packet state through every stage. It contains no concrete cryptography, transport, or network code. A concrete implementation live in [`nym-mix-sim`](../../nym-mix-sim).
|
||||
|
||||
## Crate layout
|
||||
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| [`common`](src/common) | Wire-layer traits ([`Framing`], [`FramingUnwrap`], [`Transport`], [`TransportUnwrap`]) and their composed supertraits ([`WireWrappingPipeline`], [`WireUnwrappingPipeline`]) shared by both clients and mixnodes, plus [`NoOpWireWrapper`] / [`NoOpWireUnwrapper`] marker traits for opting into a pass-through wire layer |
|
||||
| [`clients`](src/clients) | Client-side outbound/inbound pipeline traits: [`Chunking`], [`Reliability`], [`Obfuscation`], [`RoutingSecurity`], plus the supertraits [`ClientWrappingPipeline`] / [`ClientUnwrappingPipeline`], a `Pipeline` composition struct, no-op marker traits, and a tick-driven [`ClientWrappingPipelineDriver`] |
|
||||
| [`mixnodes`](src/mixnodes) | Mixnode processing trait [`NymNodeProcessingPipeline`] (unwrap → mix → re-wrap) and a `Pipeline` composition struct |
|
||||
|
||||
[`Framing`]: src/common/traits.rs
|
||||
[`FramingUnwrap`]: src/common/traits.rs
|
||||
[`Transport`]: src/common/traits.rs
|
||||
[`TransportUnwrap`]: src/common/traits.rs
|
||||
[`WireWrappingPipeline`]: src/common/traits.rs
|
||||
[`WireUnwrappingPipeline`]: src/common/traits.rs
|
||||
[`NoOpWireWrapper`]: src/common/helpers.rs
|
||||
[`NoOpWireUnwrapper`]: src/common/helpers.rs
|
||||
[`Chunking`]: src/clients/traits.rs
|
||||
[`Reliability`]: src/clients/traits.rs
|
||||
[`Obfuscation`]: src/clients/traits.rs
|
||||
[`RoutingSecurity`]: src/clients/traits.rs
|
||||
[`ClientWrappingPipeline`]: src/clients/traits.rs
|
||||
[`ClientUnwrappingPipeline`]: src/clients/traits.rs
|
||||
[`ClientWrappingPipelineDriver`]: src/clients/driver.rs
|
||||
[`NymNodeProcessingPipeline`]: src/mixnodes/traits.rs
|
||||
|
||||
## Core data types
|
||||
|
||||
```text
|
||||
TimedData<Ts, D> ── pairs a value of type D with a timestamp Ts
|
||||
TimedPayload<Ts> ── alias for TimedData<Ts, Vec<u8>>
|
||||
|
||||
AddressedTimedData<Ts, D, NdId> ── TimedData plus a destination address
|
||||
AddressedTimedPayload<Ts, NdId> ── alias for AddressedTimedData<Ts, Vec<u8>, NdId>
|
||||
|
||||
PipelineData<Ts, D, Opts, NdId> ── TimedData plus per-message Opts
|
||||
(used inside the client wrapping pipeline)
|
||||
PipelinePayload<Ts, Opts, NdId> ── alias for PipelineData<Ts, Vec<u8>, Opts, NdId>
|
||||
```
|
||||
|
||||
`Ts` is the timestamp / tick-context type, `NdId` is the next-hop identifier type, and `Opts` is an [`InputOptions`](src/clients/mod.rs)-implementing per-message marker that toggles which optional pipeline stages run for a given payload (reliability, obfuscation, routing security).
|
||||
|
||||
## Client wrapping pipeline
|
||||
|
||||
The outbound client pipeline composes six stages, each represented by its own trait:
|
||||
|
||||
```text
|
||||
Vec<u8> ──▶ Chunking ──▶ Reliability ──▶ Obfuscation
|
||||
│
|
||||
▼
|
||||
AddressedTimedData<Ts, Pkt, NdId> ◀── Transport ◀── Framing ◀── RoutingSecurity
|
||||
```
|
||||
|
||||
[`ClientWrappingPipeline`] is the supertrait that ties them together and provides a default `process()` method which runs all six stages in order on every tick. Each stage is opt-in per message via the active [`InputOptions`].
|
||||
|
||||
### Pipeline tick semantics
|
||||
|
||||
`process()` is intended to be called on every tick (with or without an input payload):
|
||||
|
||||
- [`Reliability::reliable_encode`] is always called once with `Some(input)` (when present), then once more with `None` so that timer-driven retransmissions can fire even when no new payload arrived.
|
||||
- [`Obfuscation::obfuscate`] follows the same pattern — once with the real input and once with `None` so that cover-traffic loops can fire on idle ticks.
|
||||
- [`Chunking`] and [`RoutingSecurity`] only run when a payload is actually present.
|
||||
|
||||
This convention is what allows pipelines to support Poisson cover traffic and SURB-ACK retransmission without the caller having to know whether anything is in flight.
|
||||
|
||||
## Mixnode processing pipeline
|
||||
|
||||
The mixnode pipeline is simpler — three stages that consume a packet and emit zero or more re-wrapped output packets:
|
||||
|
||||
```text
|
||||
Pkt ──▶ WireUnwrappingPipeline ──▶ mix ──▶ WireWrappingPipeline ──▶ Vec<AddressedTimedData<Ts, Pkt, NdId>>
|
||||
(TransportUnwrap + ▲ (Framing + Transport)
|
||||
FramingUnwrap) │
|
||||
└── implementor decrypts, routes,
|
||||
schedules delays, etc.
|
||||
```
|
||||
|
||||
Implementors fill in `mix()`; everything else is provided by the [`NymNodeProcessingPipeline`] supertrait's default `process()`.
|
||||
|
||||
## Helpers
|
||||
|
||||
- **Client-stage no-op marker traits** ([`NoOpReliability`], [`NoOpRoutingSecurity`], [`NoOpObfuscation`] in [`clients/helpers.rs`](src/clients/helpers.rs)) — implement these to opt out of a pipeline stage with zero overhead. Useful for stub or testing pipelines.
|
||||
- **Wire-layer no-op marker traits** ([`NoOpWireWrapper`], [`NoOpWireUnwrapper`] in [`common/helpers.rs`](src/common/helpers.rs)) — collapse the entire wire layer (framing + transport, or their inverses) to a pass-through. Use these when your packet type is already self-contained on the wire (e.g. a Sphinx packet) and needs no extra framing or transport header. `NoOpWireWrapper` requires `Pkt: From<Vec<u8>>`; `NoOpWireUnwrapper` requires `Pkt: Into<Vec<u8>>` and `Mk: Default`.
|
||||
- **`Pipeline` composition structs** (in [`clients/types.rs`](src/clients/types.rs)) — generic structs that aggregate one component per pipeline stage and provide blanket impls of the relevant supertraits, so you can build a working pipeline by plugging in any combination of stage implementations.
|
||||
- **[`ClientWrappingPipelineDriver`](src/clients/driver.rs)** — wraps a dyn-compatible client pipeline behind a tick-driven `tick(timestamp) -> Vec<(Pkt, NdId)>` interface, with an internal mpsc channel for application-supplied input payloads. Reads new input only when the internal buffer is empty so buffered packets do not stack additional latency on top.
|
||||
|
||||
[`NoOpReliability`]: src/clients/helpers.rs
|
||||
[`NoOpRoutingSecurity`]: src/clients/helpers.rs
|
||||
[`NoOpObfuscation`]: src/clients/helpers.rs
|
||||
[`InputOptions`]: src/clients/mod.rs
|
||||
[`Reliability::reliable_encode`]: src/clients/traits.rs
|
||||
[`Obfuscation::obfuscate`]: src/clients/traits.rs
|
||||
|
||||
## Example users
|
||||
|
||||
[`nym-mix-sim`](../../nym-mix-sim) is the reference consumer: it ships two complete pipeline implementations (a pass-through `Simple*` family and a full Sphinx + Poisson + SURB-ACK family) on top of the traits defined here. See its source for end-to-end examples of implementing each pipeline stage.
|
||||
|
||||
The integration test under [`tests/integration`](tests/integration) wires together a small synthetic pipeline (`MockChunking`, `KcpReliability`, `SphinxSecurity`, `KekwObfuscation`, `LpFraming`, `LpTransport`) against the [`nym-lp`](../nym-lp) packet types — a useful starting point if you want to read a self-contained example of every trait being implemented.
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::AddressedTimedData;
|
||||
use crate::clients::traits::DynClientWrappingPipeline;
|
||||
|
||||
/// Drives a [`DynClientWrappingPipeline`] tick-by-tick, feeding it raw application
|
||||
/// payloads and emitting transport packets whose scheduled timestamp is due.
|
||||
///
|
||||
/// ## How it works
|
||||
///
|
||||
/// 1. The caller submits raw byte payloads via [`ClientWrappingPipelineDriver::input_sender`].
|
||||
/// 2. On each call to [`ClientWrappingPipelineDriver::tick`], the driver reads one pending
|
||||
/// payload (only when both the packet buffer and the obfuscation buffer are
|
||||
/// empty, to avoid adding extra latency on top of buffered data), runs it
|
||||
/// through the pipeline, and appends the resulting timestamped packets to an
|
||||
/// internal buffer.
|
||||
/// 3. Packets whose `timestamp ≤ now` are extracted from the buffer and
|
||||
/// returned to the caller for sending.
|
||||
///
|
||||
/// Timestamps are [`Instant`]s, compared with `≤` to decide which packets are due.
|
||||
///
|
||||
pub struct ClientWrappingPipelineDriver<Pkt, Opts> {
|
||||
pipeline: Box<dyn DynClientWrappingPipeline<Pkt, Opts>>,
|
||||
|
||||
packet_buffer: Vec<AddressedTimedData<Pkt>>,
|
||||
|
||||
input: mpsc::Receiver<(Vec<u8>, Opts, SocketAddr)>,
|
||||
|
||||
// Keeping a ref so we don't have problem about it being dropped
|
||||
input_sender: mpsc::SyncSender<(Vec<u8>, Opts, SocketAddr)>,
|
||||
}
|
||||
|
||||
impl<Pkt, Opts> ClientWrappingPipelineDriver<Pkt, Opts> {
|
||||
/// Create a new driver wrapping `pipeline`.
|
||||
///
|
||||
/// Internally allocates a zero-capacity `sync_channel` for input payloads.
|
||||
pub fn new(pipeline: impl DynClientWrappingPipeline<Pkt, Opts> + 'static) -> Self {
|
||||
let (input_sender, input_receiver) = mpsc::sync_channel(0);
|
||||
|
||||
Self {
|
||||
pipeline: Box::new(pipeline),
|
||||
packet_buffer: Vec::new(),
|
||||
input: input_receiver,
|
||||
input_sender,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a clone of the sender half of the input channel.
|
||||
///
|
||||
/// Send raw application payloads here; they will be picked up on the next
|
||||
/// tick when the pipeline's internal buffers are empty.
|
||||
pub fn input_sender(&self) -> mpsc::SyncSender<(Vec<u8>, Opts, SocketAddr)> {
|
||||
self.input_sender.clone()
|
||||
}
|
||||
|
||||
/// Advance the driver by one tick.
|
||||
///
|
||||
/// Reads a pending input payload (if both the packet buffer and the
|
||||
/// obfuscation buffer are empty), runs it through the pipeline, then
|
||||
/// returns all packets whose `timestamp ≤ now`.
|
||||
pub fn tick(&mut self, timestamp: Instant) -> Vec<(Pkt, SocketAddr)> {
|
||||
// We're reading a message only if our buffer is empty
|
||||
// Otherwise, we will have buffers adding latencies to data
|
||||
let next_message = if self.packet_buffer.is_empty() {
|
||||
self.input
|
||||
.try_recv()
|
||||
.inspect_err(|_| tracing::trace!("No message in the queue"))
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.packet_buffer
|
||||
.extend(self.pipeline.process(next_message, timestamp));
|
||||
|
||||
self.packet_buffer
|
||||
.extract_if(.., |p| p.data.timestamp <= timestamp)
|
||||
.map(|pkt| (pkt.data.data, pkt.dst))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::PipelinePayload;
|
||||
use crate::clients::traits::{Obfuscation, Reliability, RoutingSecurity};
|
||||
|
||||
/// Marker trait for a no-op [`Reliability`] implementation.
|
||||
///
|
||||
/// Implement this for your pipeline type to get a [`Reliability`] impl that
|
||||
/// passes the payload through unchanged with zero byte overhead.
|
||||
pub trait NoOpReliability {}
|
||||
|
||||
impl<T, Opts> Reliability<Opts> for T
|
||||
where
|
||||
T: NoOpReliability,
|
||||
{
|
||||
const OVERHEAD_SIZE: usize = 0;
|
||||
fn reliable_encode(
|
||||
&mut self,
|
||||
input: Option<PipelinePayload<Opts>>,
|
||||
_: Instant,
|
||||
) -> Vec<PipelinePayload<Opts>> {
|
||||
input.map(|payload| vec![payload]).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker trait for a no-op [`RoutingSecurity`] implementation.
|
||||
///
|
||||
/// Implement this for your pipeline type to get a [`RoutingSecurity`] impl that
|
||||
/// passes the payload through unchanged with zero byte overhead and `nb_frames() == 1`.
|
||||
pub trait NoOpRoutingSecurity {}
|
||||
|
||||
impl<T, Opts> RoutingSecurity<Opts> for T
|
||||
where
|
||||
T: NoOpRoutingSecurity,
|
||||
{
|
||||
const OVERHEAD_SIZE: usize = 0;
|
||||
|
||||
fn nb_frames(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn encrypt(&mut self, input: PipelinePayload<Opts>) -> PipelinePayload<Opts> {
|
||||
input
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker trait for a no-op [`Obfuscation`] implementation.
|
||||
///
|
||||
/// Implement this for your pipeline type to get an [`Obfuscation`] impl that
|
||||
/// passes the input through unchanged with no cover traffic, delay, or
|
||||
/// buffering.
|
||||
pub trait NoOpObfuscation {}
|
||||
|
||||
impl<T, Opts> Obfuscation<Opts> for T
|
||||
where
|
||||
T: NoOpObfuscation,
|
||||
{
|
||||
fn obfuscate(
|
||||
&mut self,
|
||||
input: Option<PipelinePayload<Opts>>,
|
||||
_: Instant,
|
||||
) -> Vec<PipelinePayload<Opts>> {
|
||||
input.map(|payload| vec![payload]).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
pub mod driver;
|
||||
pub mod helpers;
|
||||
pub mod traits;
|
||||
pub mod types;
|
||||
@@ -0,0 +1,250 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::PipelinePayload;
|
||||
use crate::common::traits::{WireUnwrappingPipeline, WireWrappingPipeline};
|
||||
use crate::{AddressedTimedData, TimedPayload};
|
||||
|
||||
/// Trait for splitting an incoming payload into timestamped chunks.
|
||||
///
|
||||
/// # Type Parameters
|
||||
/// - `Opts`: Opaque per-message metadata carried by each produced [`PipelinePayload`].
|
||||
///
|
||||
/// # Required Methods
|
||||
/// - `chunked`: Split `input` (a [`PipelinePayload`] carrying the raw bytes,
|
||||
/// per-message options, and destination) into chunks of at most `chunk_size`
|
||||
/// bytes. Each output [`PipelinePayload`] inherits the input's options and
|
||||
/// destination and is stamped with `timestamp`, ready to be fed through the
|
||||
/// rest of the pipeline.
|
||||
pub trait Chunking<Opts> {
|
||||
fn chunked(
|
||||
&mut self,
|
||||
input: PipelinePayload<Opts>,
|
||||
chunk_size: usize,
|
||||
timestamp: Instant,
|
||||
) -> Vec<PipelinePayload<Opts>>;
|
||||
}
|
||||
|
||||
/// Trait for applying reliability encoding (e.g. SURB ACKs, retransmissions) to
|
||||
/// a timed payload.
|
||||
///
|
||||
/// # Type Parameters
|
||||
/// - `Opts`: Opaque per-message metadata carried by the [`PipelinePayload`].
|
||||
///
|
||||
/// # Associated Constants
|
||||
/// - `OVERHEAD_SIZE`: Number of additional bytes added by the reliability scheme.
|
||||
///
|
||||
/// # Required Methods
|
||||
/// - `reliable_encode`: Encode `input` with the reliability mechanism. When
|
||||
/// `input` is `None`, the method is still called every tick so the layer can
|
||||
/// emit pending retransmissions or scheduled control packets.
|
||||
pub trait Reliability<Opts> {
|
||||
const OVERHEAD_SIZE: usize;
|
||||
fn reliable_encode(
|
||||
&mut self,
|
||||
input: Option<PipelinePayload<Opts>>,
|
||||
timestamp: Instant,
|
||||
) -> Vec<PipelinePayload<Opts>>;
|
||||
}
|
||||
|
||||
/// Trait for applying obfuscation (cover traffic, traffic shaping) to a timed payload.
|
||||
///
|
||||
/// When obfuscation is enabled, `obfuscate` must be called on every tick — not
|
||||
/// only on ticks that carry input — so the layer can produce cover traffic on
|
||||
/// schedule even when the application has nothing to send.
|
||||
///
|
||||
/// # Type Parameters
|
||||
/// - `Opts`: Opaque per-message metadata carried by the [`PipelinePayload`].
|
||||
pub trait Obfuscation<Opts> {
|
||||
/// Obfuscate `input` at the given `timestamp`.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `input`: Payload to obfuscate, or `None` when the pipeline is ticking
|
||||
/// with no real message available.
|
||||
/// - `timestamp`: Current timestamp.
|
||||
///
|
||||
/// # Returns
|
||||
/// A `Vec` of obfuscated payloads, possibly empty when no packet is due to be
|
||||
/// emitted at this tick.
|
||||
fn obfuscate(
|
||||
&mut self,
|
||||
input: Option<PipelinePayload<Opts>>,
|
||||
timestamp: Instant,
|
||||
) -> Vec<PipelinePayload<Opts>>;
|
||||
}
|
||||
|
||||
/// Trait for applying routing-security encryption (e.g. Sphinx) to a timed payload.
|
||||
///
|
||||
/// # Type Parameters
|
||||
/// - `Opts`: Opaque per-message metadata carried by the [`PipelinePayload`].
|
||||
///
|
||||
/// # Associated Constants
|
||||
/// - `OVERHEAD_SIZE`: Number of additional bytes added by the encryption scheme.
|
||||
///
|
||||
/// # Required Methods
|
||||
/// - `encrypt`: Encrypt the given payload, returning a new [`PipelinePayload`].
|
||||
///
|
||||
/// # Provided Methods
|
||||
/// - `nb_frames`: Number of transport frames that one encrypted payload expands
|
||||
/// into; defaults to `1`. Override when the encryption scheme (e.g. Sphinx)
|
||||
/// produces multiple frames per input chunk.
|
||||
pub trait RoutingSecurity<Opts> {
|
||||
const OVERHEAD_SIZE: usize;
|
||||
fn nb_frames(&self) -> usize;
|
||||
fn encrypt(&mut self, input: PipelinePayload<Opts>) -> PipelinePayload<Opts>;
|
||||
}
|
||||
|
||||
/// Full client-side outbound message pipeline.
|
||||
///
|
||||
/// Composes all six processing stages — [`Chunking`], [`Reliability`],
|
||||
/// [`Obfuscation`], [`RoutingSecurity`], and the shared [`WireWrappingPipeline`]
|
||||
/// (framing + transport) — into a single `process` call that takes a raw byte
|
||||
/// payload and returns a list of timestamped transport packets ready for sending.
|
||||
///
|
||||
/// Every stage runs unconditionally; a pipeline that does not want a given stage
|
||||
/// composes a no-op implementation for it (see the `NoOp*` marker traits), whose
|
||||
/// `OVERHEAD_SIZE` is `0`.
|
||||
///
|
||||
/// # Type Parameters
|
||||
/// - `Pkt`: Final transport packet type produced by transport.
|
||||
/// - `Opts`: Opaque per-message metadata threaded through the pipeline.
|
||||
///
|
||||
/// # Provided Methods
|
||||
/// - `chunk_size`: Derived from `frame_size` (via [`WireWrappingPipeline`]) minus
|
||||
/// routing-security and reliability overheads, accounting for `nb_frames` expansion.
|
||||
/// - `process`: Runs the full pipeline in order:
|
||||
/// chunk → reliability encode → obfuscate → encrypt → frame → transport.
|
||||
pub trait ClientWrappingPipeline<Pkt, Opts>:
|
||||
Chunking<Opts>
|
||||
+ Reliability<Opts>
|
||||
+ Obfuscation<Opts>
|
||||
+ RoutingSecurity<Opts>
|
||||
+ WireWrappingPipeline<Pkt, Opts>
|
||||
{
|
||||
fn chunk_size(&self) -> usize {
|
||||
// Frame size comes from WireWrappingPipeline
|
||||
// SAFETY : While this CAN technically fail, it means that something is wrong in the code and it's pointless to continue anyway
|
||||
#[allow(clippy::expect_used)]
|
||||
(self.frame_size() * self.nb_frames())
|
||||
.checked_sub(<Self as RoutingSecurity<_>>::OVERHEAD_SIZE)
|
||||
.expect("not enough room in a packet for routing security overhead")
|
||||
.checked_sub(<Self as Reliability<_>>::OVERHEAD_SIZE)
|
||||
.expect("not enough room in a packet for reliability overhead")
|
||||
}
|
||||
|
||||
fn process(
|
||||
&mut self,
|
||||
input: Option<(Vec<u8>, Opts, SocketAddr)>, // Optional to be able to tick the pipeline without input
|
||||
timestamp: Instant,
|
||||
) -> Vec<AddressedTimedData<Pkt>> {
|
||||
let chunk_size = self.chunk_size();
|
||||
let mut chunks = if let Some((input_data, input_options, next_hop)) = input {
|
||||
let input_payload =
|
||||
PipelinePayload::new(timestamp, input_data, input_options, next_hop);
|
||||
self.chunked(input_payload, chunk_size, timestamp)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Reliability stage
|
||||
chunks = if chunks.is_empty() {
|
||||
// Even if we had nothing go into the reliability stage, we need to catch potential retransmissions
|
||||
self.reliable_encode(None, timestamp)
|
||||
} else {
|
||||
chunks
|
||||
.into_iter()
|
||||
.flat_map(|chunk| self.reliable_encode(Some(chunk), timestamp))
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Obfuscation stage
|
||||
chunks = if chunks.is_empty() {
|
||||
// Even if we had nothing go into the obfuscation stage, we need to catch potential cover traffic
|
||||
self.obfuscate(None, timestamp)
|
||||
} else {
|
||||
chunks
|
||||
.into_iter()
|
||||
.flat_map(|chunk| self.obfuscate(Some(chunk), timestamp))
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Routing-security stage
|
||||
chunks = chunks
|
||||
.into_iter()
|
||||
.map(|chunk| self.encrypt(chunk))
|
||||
.collect();
|
||||
|
||||
chunks
|
||||
.into_iter()
|
||||
.flat_map(|payload| self.wire_wrap(payload))
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
}
|
||||
|
||||
/// Dyn-compatible mirror of [`ClientWrappingPipeline`].
|
||||
///
|
||||
/// All associated constants from the sub-traits are exposed as methods so the
|
||||
/// trait can be used as `dyn DynClientWrappingPipeline<Pkt, Opts>`, erasing the
|
||||
/// concrete pipeline type while keeping `Pkt` and `Opts` visible.
|
||||
///
|
||||
/// Implement [`ClientWrappingPipeline`] on your concrete type; the blanket impl
|
||||
/// below provides `DynClientWrappingPipeline` for free.
|
||||
pub trait DynClientWrappingPipeline<Pkt, Opts> {
|
||||
/// On-wire size of an output packet in bytes.
|
||||
fn packet_size(&self) -> usize;
|
||||
|
||||
/// Run the full client wrapping pipeline; see [`ClientWrappingPipeline::process`].
|
||||
fn process(
|
||||
&mut self,
|
||||
input: Option<(Vec<u8>, Opts, SocketAddr)>,
|
||||
timestamp: Instant,
|
||||
) -> Vec<AddressedTimedData<Pkt>>;
|
||||
}
|
||||
|
||||
impl<T, Pkt, Opts> DynClientWrappingPipeline<Pkt, Opts> for T
|
||||
where
|
||||
T: ClientWrappingPipeline<Pkt, Opts>,
|
||||
{
|
||||
fn packet_size(&self) -> usize {
|
||||
WireWrappingPipeline::packet_size(self)
|
||||
}
|
||||
|
||||
fn process(
|
||||
&mut self,
|
||||
input: Option<(Vec<u8>, Opts, SocketAddr)>,
|
||||
timestamp: Instant,
|
||||
) -> Vec<AddressedTimedData<Pkt>> {
|
||||
ClientWrappingPipeline::process(self, input, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
/// Full client-side inbound pipeline.
|
||||
///
|
||||
/// Combines the shared [`WireUnwrappingPipeline`] (transport + framing unwrap) with a
|
||||
/// blank [`process_unwrapped`](Self::process_unwrapped) step that the implementor
|
||||
/// fills in (routing-security decrypt, reliability decode, chunk reassembly, etc.).
|
||||
///
|
||||
/// # Type Parameters
|
||||
/// - `Pkt`: Transport packet type consumed as input.
|
||||
/// - `Mk`: Message-kind marker returned alongside reassembled payloads.
|
||||
///
|
||||
/// # Required Methods
|
||||
/// - `process_unwrapped`: Called with the reassembled payload and its message kind
|
||||
/// once a complete message is available. Returns the decoded application bytes,
|
||||
/// or `None` if reassembly is still in progress.
|
||||
///
|
||||
/// # Provided Methods
|
||||
/// - `unwrap`: Strips the wire layers via [`WireUnwrappingPipeline::wire_unwrap`],
|
||||
/// then delegates to `process_unwrapped`.
|
||||
pub trait ClientUnwrappingPipeline<Pkt, Mk>: WireUnwrappingPipeline<Pkt, Mk> {
|
||||
fn process_unwrapped(&mut self, payload: TimedPayload, kind: Mk) -> Option<Vec<u8>>;
|
||||
|
||||
fn unwrap(&mut self, input: Pkt, timestamp: Instant) -> Result<Option<Vec<u8>>, Self::Error> {
|
||||
Ok(self
|
||||
.wire_unwrap(input, timestamp)?
|
||||
.and_then(|(payload, kind)| self.process_unwrapped(payload, kind)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::clients::traits::{
|
||||
Chunking, ClientWrappingPipeline, Obfuscation, Reliability, RoutingSecurity,
|
||||
};
|
||||
use crate::common::traits::{Framing, Transport, WireWrappingPipeline};
|
||||
use crate::{AddressedTimedData, PipelinePayload};
|
||||
|
||||
/// Generic composition struct that implements [`ClientWrappingPipeline`] by
|
||||
/// delegating each stage to a held component.
|
||||
///
|
||||
/// Type parameters correspond to the six pipeline stages:
|
||||
/// - `C`: [`Chunking`]
|
||||
/// - `R`: [`Reliability`]
|
||||
/// - `O`: [`Obfuscation`]
|
||||
/// - `Rs`: [`RoutingSecurity`]
|
||||
/// - `F`: [`Framing`]
|
||||
/// - `T`: [`Transport`]
|
||||
pub struct Pipeline<C, R, O, Rs, F, T> {
|
||||
/// On-wire size of an output packet in bytes; returned by
|
||||
/// [`WireWrappingPipeline::packet_size`].
|
||||
pub packet_size: usize,
|
||||
/// [`Chunking`] stage.
|
||||
pub chunking: C,
|
||||
/// [`Reliability`] stage.
|
||||
pub reliability: R,
|
||||
/// [`Obfuscation`] stage.
|
||||
pub obfuscation: O,
|
||||
/// [`RoutingSecurity`] stage.
|
||||
pub security: Rs,
|
||||
/// [`Framing`] stage.
|
||||
pub framing: F,
|
||||
/// [`Transport`] stage.
|
||||
pub transport: T,
|
||||
}
|
||||
|
||||
impl<Opts, C, R, O, Rs, F, T> Chunking<Opts> for Pipeline<C, R, O, Rs, F, T>
|
||||
where
|
||||
C: Chunking<Opts>,
|
||||
{
|
||||
fn chunked(
|
||||
&mut self,
|
||||
input: PipelinePayload<Opts>,
|
||||
chunk_size: usize,
|
||||
timestamp: Instant,
|
||||
) -> Vec<PipelinePayload<Opts>> {
|
||||
self.chunking.chunked(input, chunk_size, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
impl<Opts, C, R, O, Rs, F, T> Reliability<Opts> for Pipeline<C, R, O, Rs, F, T>
|
||||
where
|
||||
R: Reliability<Opts>,
|
||||
{
|
||||
const OVERHEAD_SIZE: usize = R::OVERHEAD_SIZE;
|
||||
|
||||
fn reliable_encode(
|
||||
&mut self,
|
||||
input: Option<PipelinePayload<Opts>>,
|
||||
timestamp: Instant,
|
||||
) -> Vec<PipelinePayload<Opts>> {
|
||||
self.reliability.reliable_encode(input, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
impl<Opts, C, R, O, Rs, F, T> Obfuscation<Opts> for Pipeline<C, R, O, Rs, F, T>
|
||||
where
|
||||
O: Obfuscation<Opts>,
|
||||
{
|
||||
fn obfuscate(
|
||||
&mut self,
|
||||
input: Option<PipelinePayload<Opts>>,
|
||||
timestamp: Instant,
|
||||
) -> Vec<PipelinePayload<Opts>> {
|
||||
self.obfuscation.obfuscate(input, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
impl<Opts, C, R, O, Rs, F, T> RoutingSecurity<Opts> for Pipeline<C, R, O, Rs, F, T>
|
||||
where
|
||||
Rs: RoutingSecurity<Opts>,
|
||||
{
|
||||
const OVERHEAD_SIZE: usize = Rs::OVERHEAD_SIZE;
|
||||
|
||||
fn nb_frames(&self) -> usize {
|
||||
self.security.nb_frames()
|
||||
}
|
||||
|
||||
fn encrypt(&mut self, input: PipelinePayload<Opts>) -> PipelinePayload<Opts> {
|
||||
self.security.encrypt(input)
|
||||
}
|
||||
}
|
||||
|
||||
impl<Opts, C, R, O, Rs, F, T> Framing<Opts> for Pipeline<C, R, O, Rs, F, T>
|
||||
where
|
||||
F: Framing<Opts>,
|
||||
{
|
||||
type Frame = F::Frame;
|
||||
const OVERHEAD_SIZE: usize = F::OVERHEAD_SIZE;
|
||||
|
||||
fn to_frame(
|
||||
&mut self,
|
||||
payload: PipelinePayload<Opts>,
|
||||
frame_size: usize,
|
||||
) -> Vec<AddressedTimedData<F::Frame>> {
|
||||
self.framing.to_frame(payload, frame_size)
|
||||
}
|
||||
}
|
||||
|
||||
impl<Pkt, C, R, O, Rs, F, T> Transport<Pkt> for Pipeline<C, R, O, Rs, F, T>
|
||||
where
|
||||
T: Transport<Pkt>,
|
||||
{
|
||||
type Frame = T::Frame;
|
||||
const OVERHEAD_SIZE: usize = T::OVERHEAD_SIZE;
|
||||
|
||||
fn to_transport_packet(
|
||||
&mut self,
|
||||
frame: AddressedTimedData<T::Frame>,
|
||||
) -> AddressedTimedData<Pkt> {
|
||||
self.transport.to_transport_packet(frame)
|
||||
}
|
||||
}
|
||||
|
||||
impl<Pkt, Opts, C, R, O, Rs, F, T> WireWrappingPipeline<Pkt, Opts> for Pipeline<C, R, O, Rs, F, T>
|
||||
where
|
||||
F: Framing<Opts>,
|
||||
T: Transport<Pkt, Frame = F::Frame>,
|
||||
{
|
||||
fn packet_size(&self) -> usize {
|
||||
self.packet_size
|
||||
}
|
||||
}
|
||||
|
||||
impl<Pkt, Opts, C, R, O, Rs, F, T> ClientWrappingPipeline<Pkt, Opts> for Pipeline<C, R, O, Rs, F, T>
|
||||
where
|
||||
C: Chunking<Opts>,
|
||||
R: Reliability<Opts>,
|
||||
O: Obfuscation<Opts>,
|
||||
Rs: RoutingSecurity<Opts>,
|
||||
F: Framing<Opts>,
|
||||
T: Transport<Pkt, Frame = F::Frame>,
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::{
|
||||
AddressedTimedData, AddressedTimedPayload, PipelinePayload, TimedData, TimedPayload,
|
||||
common::traits::{
|
||||
Framing, FramingUnwrap, Transport, TransportUnwrap, WireUnwrappingPipeline,
|
||||
WireWrappingPipeline,
|
||||
},
|
||||
};
|
||||
|
||||
/// Marker trait for a no-op [`WireWrappingPipeline`] implementation.
|
||||
///
|
||||
/// Implement this for your pipeline type to get a [`WireWrappingPipeline`] impl that
|
||||
/// passes the payload through unchanged with zero byte overhead.
|
||||
pub trait NoOpWireWrapper {
|
||||
const PACKET_SIZE: usize = 1500;
|
||||
}
|
||||
|
||||
impl<T, Opts> Framing<Opts> for T
|
||||
where
|
||||
T: NoOpWireWrapper,
|
||||
{
|
||||
type Frame = Vec<u8>;
|
||||
const OVERHEAD_SIZE: usize = 0;
|
||||
fn to_frame(
|
||||
&mut self,
|
||||
payload: PipelinePayload<Opts>,
|
||||
_: usize,
|
||||
) -> Vec<AddressedTimedPayload> {
|
||||
vec![payload.into_addressed()]
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, Pkt> Transport<Pkt> for T
|
||||
where
|
||||
T: NoOpWireWrapper,
|
||||
Pkt: From<Vec<u8>>,
|
||||
{
|
||||
type Frame = Vec<u8>;
|
||||
const OVERHEAD_SIZE: usize = 0;
|
||||
fn to_transport_packet(
|
||||
&mut self,
|
||||
frame: AddressedTimedPayload,
|
||||
) -> AddressedTimedData<Pkt> {
|
||||
frame.data_transform(|data| data.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, Pkt, Opts> WireWrappingPipeline<Pkt, Opts> for T
|
||||
where
|
||||
T: NoOpWireWrapper,
|
||||
Pkt: From<Vec<u8>>,
|
||||
{
|
||||
fn packet_size(&self) -> usize {
|
||||
T::PACKET_SIZE
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker trait for a no-op [`WireUnwrappingPipeline`] implementation.
|
||||
///
|
||||
/// Implement this for your pipeline type to get a [`WireUnwrappingPipeline`] impl that
|
||||
/// passes the payload through unchanged.
|
||||
pub trait NoOpWireUnwrapper {}
|
||||
|
||||
impl<T, Mk> FramingUnwrap<Mk> for T
|
||||
where
|
||||
T: NoOpWireUnwrapper,
|
||||
Mk: Default,
|
||||
{
|
||||
type Frame = Vec<u8>;
|
||||
fn frame_to_message(&mut self, frame: TimedPayload) -> Option<(TimedPayload, Mk)> {
|
||||
Some((frame, Default::default()))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, Pkt> TransportUnwrap<Pkt> for T
|
||||
where
|
||||
T: NoOpWireUnwrapper,
|
||||
Pkt: Into<Vec<u8>>,
|
||||
{
|
||||
type Frame = Vec<u8>;
|
||||
type Error = std::convert::Infallible;
|
||||
fn packet_to_frame(
|
||||
&mut self,
|
||||
packet: Pkt,
|
||||
timestamp: Instant,
|
||||
) -> Result<TimedPayload, Self::Error> {
|
||||
Ok(TimedData {
|
||||
timestamp,
|
||||
data: packet.into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, Pkt, Mk> WireUnwrappingPipeline<Pkt, Mk> for T
|
||||
where
|
||||
T: NoOpWireUnwrapper,
|
||||
Pkt: Into<Vec<u8>>,
|
||||
Mk: Default,
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
pub mod helpers;
|
||||
pub mod traits;
|
||||
@@ -0,0 +1,163 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::{AddressedTimedData, PipelinePayload, TimedData, TimedPayload};
|
||||
|
||||
/// Trait for applying framing to a timed payload.
|
||||
///
|
||||
/// # Type Parameters
|
||||
/// - `Opts` : Opts type carried by the `PipelinePayload`
|
||||
///
|
||||
/// # Associated Types
|
||||
/// - `Frame`: Frame type produced by the framing operation.
|
||||
///
|
||||
/// # Associated Constants
|
||||
/// - `OVERHEAD_SIZE`: Number of additional bytes added by the framing scheme.
|
||||
///
|
||||
/// # Required Methods
|
||||
/// - `to_frame`: Splits the payload into a `Vec<AddressedTimedData<Self::Frame>>` of frames of the given size.
|
||||
pub trait Framing<Opts> {
|
||||
type Frame;
|
||||
const OVERHEAD_SIZE: usize;
|
||||
fn to_frame(
|
||||
&mut self,
|
||||
payload: PipelinePayload<Opts>,
|
||||
frame_size: usize,
|
||||
) -> Vec<AddressedTimedData<Self::Frame>>;
|
||||
}
|
||||
|
||||
/// Trait for unwrapping framing from a frame back into a payload.
|
||||
///
|
||||
/// # Type Parameters
|
||||
/// - `Mk`: Enum describing the kind of message that can be returned.
|
||||
///
|
||||
/// # Associated Types
|
||||
/// - `Frame`: Frame type consumed as input.
|
||||
///
|
||||
/// # Required Methods
|
||||
/// - `frame_to_message`: Attempts to reassemble a payload from the given frame, returning
|
||||
/// `Some((payload, kind))` when a complete message is available, or `None` otherwise.
|
||||
pub trait FramingUnwrap<Mk> {
|
||||
type Frame;
|
||||
fn frame_to_message(&mut self, frame: TimedData<Self::Frame>) -> Option<(TimedPayload, Mk)>;
|
||||
}
|
||||
|
||||
/// Trait for applying a transport layer to a framed payload.
|
||||
///
|
||||
/// # Type Parameters
|
||||
/// - `Pkt`: Transport packet type produced as output.
|
||||
///
|
||||
/// # Associated Types
|
||||
/// - `Frame`: Frame type consumed as input.
|
||||
///
|
||||
/// # Associated Constants
|
||||
/// - `OVERHEAD_SIZE`: Number of additional bytes added by the transport scheme.
|
||||
///
|
||||
/// # Required Methods
|
||||
/// - `to_transport_packet`: Wraps a frame into a transport packet.
|
||||
pub trait Transport<Pkt> {
|
||||
type Frame;
|
||||
const OVERHEAD_SIZE: usize;
|
||||
fn to_transport_packet(
|
||||
&mut self,
|
||||
frame: AddressedTimedData<Self::Frame>,
|
||||
) -> AddressedTimedData<Pkt>;
|
||||
}
|
||||
|
||||
/// Trait for unwrapping a transport packet back into a frame.
|
||||
///
|
||||
/// # Type Parameters
|
||||
/// - `Pkt`: Transport packet type consumed as input.
|
||||
///
|
||||
/// # Associated Types
|
||||
/// - `Frame`: Frame type produced as output.
|
||||
/// - `Error`: Error type
|
||||
///
|
||||
/// # Required Methods
|
||||
/// - `packet_to_frame`: Strips the transport layer from a packet, returning the inner frame
|
||||
/// tagged with the given timestamp.
|
||||
pub trait TransportUnwrap<Pkt> {
|
||||
type Frame;
|
||||
type Error;
|
||||
fn packet_to_frame(
|
||||
&mut self,
|
||||
packet: Pkt,
|
||||
timestamp: Instant,
|
||||
) -> Result<TimedData<Self::Frame>, Self::Error>;
|
||||
}
|
||||
|
||||
/// Supertrait combining [`Framing`] and [`Transport`] into a reusable wire-wrapping layer.
|
||||
///
|
||||
/// Used as the bottom stage of any outbound pipeline (client or mixnode).
|
||||
///
|
||||
/// # Type Parameters
|
||||
/// - `Pkt`: Final transport packet type.
|
||||
/// - `Opts` : Option type
|
||||
///
|
||||
/// Both [`Framing`] and [`Transport`] declare their own `type Frame`; this
|
||||
/// supertrait cross-constrains them so `to_frame`'s output feeds directly into
|
||||
/// `to_transport_packet`.
|
||||
///
|
||||
/// # Required Methods
|
||||
/// - `packet_size`: Total on-wire size of an output packet in bytes.
|
||||
///
|
||||
/// # Provided Methods
|
||||
/// - `frame_size`: Derived from `packet_size` minus transport and framing overheads.
|
||||
/// - `wire_wrap`: Frames a payload and wraps each frame into a transport packet.
|
||||
pub trait WireWrappingPipeline<Pkt, Opts>:
|
||||
Transport<Pkt> + Framing<Opts, Frame = <Self as Transport<Pkt>>::Frame>
|
||||
{
|
||||
// IMPORTANT NOTE : This fn can be not constant to allow e.g. flexible MTU
|
||||
// However, every possible value must be able to accommodate the different overhead.
|
||||
// If it doesn't, the pipeline becomes unusable
|
||||
fn packet_size(&self) -> usize;
|
||||
|
||||
fn frame_size(&self) -> usize {
|
||||
// SAFETY : While this CAN technically fail, it means that something is wrong in the code and it's pointless to continue anyway
|
||||
#[allow(clippy::expect_used)]
|
||||
self.packet_size()
|
||||
.checked_sub(
|
||||
<Self as Transport<Pkt>>::OVERHEAD_SIZE + <Self as Framing<Opts>>::OVERHEAD_SIZE,
|
||||
)
|
||||
.expect("packet_size smaller than transport + framing overhead")
|
||||
}
|
||||
|
||||
fn wire_wrap(&mut self, payload: PipelinePayload<Opts>) -> Vec<AddressedTimedData<Pkt>> {
|
||||
let frame_size = self.frame_size();
|
||||
self.to_frame(payload, frame_size)
|
||||
.into_iter()
|
||||
.map(|frame| self.to_transport_packet(frame))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Supertrait combining [`TransportUnwrap`] and [`FramingUnwrap`] into a reusable
|
||||
/// wire-unwrapping layer.
|
||||
///
|
||||
/// Used as the bottom stage of any inbound pipeline (client or mixnode).
|
||||
///
|
||||
/// # Type Parameters
|
||||
/// - `Pkt`: Transport packet type consumed as input.
|
||||
/// - `Mk`: Message-kind marker returned alongside the reassembled payload.
|
||||
///
|
||||
/// Both [`TransportUnwrap`] and [`FramingUnwrap`] declare their own `type Frame`;
|
||||
/// this supertrait cross-constrains them so `packet_to_frame`'s output feeds
|
||||
/// directly into `frame_to_message`.
|
||||
///
|
||||
/// # Provided Methods
|
||||
/// - `wire_unwrap`: Strips the transport layer from a packet and attempts to reassemble
|
||||
/// a payload, returning `Some((payload, kind))` when a complete message is available.
|
||||
pub trait WireUnwrappingPipeline<Pkt, Mk>:
|
||||
TransportUnwrap<Pkt> + FramingUnwrap<Mk, Frame = <Self as TransportUnwrap<Pkt>>::Frame>
|
||||
{
|
||||
fn wire_unwrap(
|
||||
&mut self,
|
||||
input: Pkt,
|
||||
timestamp: Instant,
|
||||
) -> Result<Option<(TimedPayload, Mk)>, Self::Error> {
|
||||
let frame = self.packet_to_frame(input, timestamp)?;
|
||||
Ok(self.frame_to_message(frame))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use crate::{
|
||||
fragmentation::FragmentationError,
|
||||
packet::{
|
||||
LpFrame,
|
||||
frame::{LpFrameAttributes, LpFrameKind},
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(PartialEq, Clone, Debug)]
|
||||
pub struct FragmentHeader {
|
||||
/// ID associated to this particular `Fragment`.
|
||||
id: u64,
|
||||
|
||||
/// Total number of `Fragment`s, used to be able to determine if entire
|
||||
/// set was fully received as well as to perform bound checks.
|
||||
total_fragments: u8,
|
||||
|
||||
/// Index of this fragment, in (0..total_fragments)
|
||||
current_fragment: u8,
|
||||
|
||||
reserved: [u8; 4],
|
||||
}
|
||||
|
||||
impl FragmentHeader {
|
||||
// It's up to the caller to make sure values are valid
|
||||
fn new(id: u64, total_fragments: u8, current_fragment: u8) -> Self {
|
||||
FragmentHeader {
|
||||
id,
|
||||
total_fragments,
|
||||
current_fragment,
|
||||
reserved: [0; 4],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FragmentHeader> for LpFrameAttributes {
|
||||
fn from(value: FragmentHeader) -> Self {
|
||||
let mut buf = [0u8; 14];
|
||||
buf[0..8].copy_from_slice(&value.id.to_be_bytes());
|
||||
buf[8] = value.total_fragments;
|
||||
buf[9] = value.current_fragment;
|
||||
buf[10..14].copy_from_slice(&value.reserved);
|
||||
buf
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<LpFrameAttributes> for FragmentHeader {
|
||||
type Error = FragmentationError;
|
||||
fn try_from(value: LpFrameAttributes) -> Result<Self, Self::Error> {
|
||||
let total_fragments = value[8];
|
||||
let current_fragment = value[9];
|
||||
if current_fragment >= total_fragments {
|
||||
return Err(FragmentationError::FragmentIndexOutOfBounds);
|
||||
}
|
||||
|
||||
// SAFETY : Three conversion from slices to arrays with correct size
|
||||
Ok(FragmentHeader {
|
||||
#[allow(clippy::unwrap_used)]
|
||||
id: u64::from_be_bytes(value[0..8].try_into().unwrap()),
|
||||
total_fragments,
|
||||
current_fragment,
|
||||
#[allow(clippy::unwrap_used)]
|
||||
reserved: value[10..14].try_into().unwrap(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Clone, Debug)]
|
||||
pub struct Fragment {
|
||||
header: FragmentHeader,
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Fragment {
|
||||
// It's up to the caller to make sure values are valid
|
||||
fn new(payload: &[u8], id: u64, total_fragments: u8, current_fragment: u8) -> Self {
|
||||
let header = FragmentHeader::new(id, total_fragments, current_fragment);
|
||||
Fragment {
|
||||
header,
|
||||
payload: payload.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_lp_frame(self) -> LpFrame {
|
||||
LpFrame::new_with_attributes(LpFrameKind::FragmentedData, self.header, self.payload)
|
||||
}
|
||||
|
||||
/// Extracts id of this `Fragment`.
|
||||
pub fn id(&self) -> u64 {
|
||||
self.header.id
|
||||
}
|
||||
|
||||
/// Extracts total number of fragments associated with this particular `Fragment` (belonging to
|
||||
/// the same `FragmentSet`).
|
||||
pub fn total_fragments(&self) -> u8 {
|
||||
self.header.total_fragments
|
||||
}
|
||||
|
||||
/// Extracts position of this `Fragment` in a `FragmentSet`.
|
||||
pub fn current_fragment(&self) -> u8 {
|
||||
self.header.current_fragment
|
||||
}
|
||||
|
||||
/// Consumes `self` to obtain payload (i.e. part of original message) associated with this
|
||||
/// `Fragment`.
|
||||
pub(crate) fn extract_payload(self) -> Vec<u8> {
|
||||
self.payload
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<LpFrame> for Fragment {
|
||||
type Error = FragmentationError;
|
||||
fn try_from(value: LpFrame) -> Result<Self, Self::Error> {
|
||||
match value.kind() {
|
||||
LpFrameKind::FragmentedData => Ok(Fragment {
|
||||
header: value.header.frame_attributes.try_into()?,
|
||||
payload: value.content.to_vec(),
|
||||
}),
|
||||
_ => Err(FragmentationError::InvalidFrameKind),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits an LpFrame into multiple `Fragment`s
|
||||
/// This is meant to be used during Framing, not Chunking. This way we can ensure it fits in less than 255 fragments
|
||||
pub fn fragment_lp_message<R: rand::Rng>(
|
||||
rng: &mut R,
|
||||
message: LpFrame,
|
||||
fragment_payload_size: usize,
|
||||
) -> Vec<Fragment> {
|
||||
debug_assert!(message.len() <= u8::MAX as usize * fragment_payload_size);
|
||||
|
||||
let message_bytes = message.to_bytes();
|
||||
|
||||
let id = rng.r#gen();
|
||||
|
||||
let num_fragments = (message_bytes.len() as f64 / fragment_payload_size as f64).ceil() as u8;
|
||||
|
||||
let mut fragments = Vec::with_capacity(num_fragments as usize);
|
||||
|
||||
for i in 0..num_fragments as usize {
|
||||
let lb = i * fragment_payload_size;
|
||||
let ub = usize::min(message_bytes.len(), (i + 1) * fragment_payload_size);
|
||||
fragments.push(Fragment::new(
|
||||
&message_bytes[lb..ub],
|
||||
id,
|
||||
num_fragments,
|
||||
i as u8,
|
||||
))
|
||||
}
|
||||
|
||||
fragments
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
pub mod fragment;
|
||||
pub mod reconstruction;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum FragmentationError {
|
||||
#[error("Fragment index is out of bounds for the announced lentgh")]
|
||||
FragmentIndexOutOfBounds,
|
||||
|
||||
#[error("Provided frame isn't fragmented")]
|
||||
InvalidFrameKind,
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use crate::fragmentation::fragment::Fragment;
|
||||
use crate::packet::{LpFrame, MalformedLpPacketError};
|
||||
|
||||
use dashmap::DashMap;
|
||||
use dashmap::mapref::entry::Entry;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
pub const DEFAULT_FRAGMENT_TIMEOUT_DURATION: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Per-message buffer that collects every `Fragment` of a fragmented message
|
||||
/// and reassembles the original payload once they are all in.
|
||||
#[derive(Debug, Clone)]
|
||||
struct MessageBuffer {
|
||||
/// Cached completion flag, set as soon as the last missing slot has been
|
||||
/// filled. Avoids re-scanning `fragments` on every read.
|
||||
is_complete: bool,
|
||||
|
||||
/// Position-indexed slots for the message's fragments. Allocated up front
|
||||
/// to `total_fragments` `None` entries on first sight of the message,
|
||||
/// giving O(1) inserts and O(n) reassembly while preserving order.
|
||||
fragments: Vec<Option<Fragment>>,
|
||||
|
||||
/// Timestamp of the most recently inserted fragment. Read by
|
||||
/// [`MessageReconstructor::cleanup_stale_buffers`] to evict messages whose
|
||||
/// remaining fragments never showed up.
|
||||
last_fragment_timestamp: Instant,
|
||||
}
|
||||
|
||||
impl MessageBuffer {
|
||||
/// Create an empty buffer sized for `total_fragments` slots.
|
||||
/// The `u8` argument bounds the allocation at `u8::MAX`.
|
||||
fn new(total_fragments: u8, timestamp: Instant) -> Self {
|
||||
// `new` should never be called with size 0: `total_fragments` is taken
|
||||
// from the first received `Fragment` of the message, and decoding
|
||||
// rejects any header where `current_fragment >= total_fragments`, so
|
||||
// the smallest valid value is 1.
|
||||
debug_assert!(total_fragments > 0);
|
||||
|
||||
MessageBuffer {
|
||||
is_complete: false,
|
||||
fragments: vec![None; total_fragments as usize],
|
||||
last_fragment_timestamp: timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume the buffer and concatenate every fragment payload into the
|
||||
/// original message bytes. The caller is expected to have observed
|
||||
/// `is_complete == true` first.
|
||||
fn into_message(self) -> Vec<u8> {
|
||||
debug_assert!(self.is_complete);
|
||||
|
||||
// SAFETY: `is_complete` is only set inside `insert_fragment` after
|
||||
// `is_done_receiving` confirms every slot is `Some`. The
|
||||
// `debug_assert!` above pins this invariant, so reading slot 0 and
|
||||
// unwrapping every slot below cannot panic.
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let id = self.fragments[0].as_ref().unwrap().id();
|
||||
debug!(
|
||||
"Got {} fragments for message id {}",
|
||||
self.fragments.len(),
|
||||
id
|
||||
);
|
||||
|
||||
// SAFETY: same invariant as above — every slot is `Some`.
|
||||
#[allow(clippy::unwrap_used)]
|
||||
self.fragments
|
||||
.into_iter()
|
||||
.flat_map(|fragment| fragment.unwrap().extract_payload())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether every fragment slot has been filled.
|
||||
fn is_done_receiving(&self) -> bool {
|
||||
!self.fragments.contains(&None)
|
||||
}
|
||||
|
||||
/// Insert `fragment` into the slot at `fragment.current_fragment()` and
|
||||
/// update `last_fragment_timestamp` and `is_complete` accordingly.
|
||||
///
|
||||
/// Duplicate fragments are logged, then ignored
|
||||
fn insert_fragment(&mut self, fragment: Fragment, timestamp: Instant) {
|
||||
self.last_fragment_timestamp = timestamp;
|
||||
|
||||
// All fragments routed into a given buffer must share the same id —
|
||||
// it is part of the buffer's lookup key, so a mismatch would
|
||||
// indicate a routing bug upstream.
|
||||
debug_assert!({
|
||||
let present = self.fragments.iter().find(|frag| frag.is_some());
|
||||
// SAFETY: `find` returned a slot that satisfied `is_some`, so
|
||||
// the inner `unwrap` cannot panic.
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let same_id = present.is_none_or(|p| p.as_ref().unwrap().id() == fragment.id());
|
||||
same_id
|
||||
});
|
||||
|
||||
let fragment_index = fragment.current_fragment() as usize;
|
||||
if self.fragments[fragment_index].is_some() {
|
||||
// If we receive a duplicate, we ignore it
|
||||
warn!(
|
||||
"duplicate fragment received! - frag - {} (message id: {})",
|
||||
fragment.current_fragment(),
|
||||
fragment.id()
|
||||
);
|
||||
} else {
|
||||
self.fragments[fragment_index] = Some(fragment);
|
||||
if self.is_done_receiving() {
|
||||
self.is_complete = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Public reassembly state for fragmented messages. Buffers in-flight
|
||||
/// messages keyed on their fragment id and yields the original bytes
|
||||
/// once every fragment of a given message has been received.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MessageReconstructor {
|
||||
/// In-flight messages keyed on the random 64-bit fragment id.
|
||||
in_flight_messages: Arc<DashMap<u64, MessageBuffer>>,
|
||||
|
||||
/// How long an incomplete message is allowed to sit before it is
|
||||
/// dropped on the next `cleanup_stale_buffers` pass.
|
||||
incomplete_message_timeout: Duration,
|
||||
}
|
||||
|
||||
impl MessageReconstructor {
|
||||
/// Create an empty `MessageReconstructor`.
|
||||
pub fn new(incomplete_message_timeout: Duration) -> Self {
|
||||
Self {
|
||||
in_flight_messages: Default::default(),
|
||||
incomplete_message_timeout,
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert `fragment` into the buffer for its message and, if it was the
|
||||
/// last outstanding fragment, return the reassembled LpFrame
|
||||
///
|
||||
/// Stale incomplete messages are evicted on every call.
|
||||
pub fn insert_new_fragment(
|
||||
&self,
|
||||
fragment: Fragment,
|
||||
timestamp: Instant,
|
||||
) -> Option<Result<LpFrame, MalformedLpPacketError>> {
|
||||
let frag_id = fragment.id();
|
||||
let total_fragments = fragment.total_fragments();
|
||||
|
||||
let maybe_message = match self.in_flight_messages.entry(frag_id) {
|
||||
Entry::Occupied(mut entry) => {
|
||||
entry.get_mut().insert_fragment(fragment, timestamp);
|
||||
entry
|
||||
.get()
|
||||
.is_complete
|
||||
.then(|| LpFrame::decode(&entry.remove().into_message()))
|
||||
}
|
||||
Entry::Vacant(entry) => {
|
||||
let mut buf = MessageBuffer::new(total_fragments, timestamp);
|
||||
buf.insert_fragment(fragment, timestamp);
|
||||
if buf.is_complete {
|
||||
Some(LpFrame::decode(&buf.into_message()))
|
||||
} else {
|
||||
entry.insert(buf);
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// This might be a bit slow, keep an eye on it
|
||||
self.cleanup_stale_buffers(timestamp);
|
||||
maybe_message
|
||||
}
|
||||
|
||||
/// Drop incomplete messages whose `last_fragment_timestamp` is older
|
||||
/// than `incomplete_message_timeout` ago.
|
||||
pub fn cleanup_stale_buffers(&self, timestamp: Instant) {
|
||||
trace!("Cleaning up stale buffers");
|
||||
self.in_flight_messages.retain(|_, buf| {
|
||||
let keep = buf.last_fragment_timestamp + self.incomplete_message_timeout > timestamp;
|
||||
if !keep {
|
||||
debug!(
|
||||
"Removing stale buffer for message id {:?}",
|
||||
buf.fragments
|
||||
.first()
|
||||
.and_then(|f| f.as_ref().map(|f| f.id()))
|
||||
);
|
||||
}
|
||||
keep
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MessageReconstructor {
|
||||
fn default() -> Self {
|
||||
MessageReconstructor::new(DEFAULT_FRAGMENT_TIMEOUT_DURATION)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use super::*;
|
||||
use crate::fragmentation::fragment::fragment_lp_message;
|
||||
use crate::packet::LpFrame;
|
||||
use crate::packet::frame::LpFrameKind;
|
||||
use rand::SeedableRng;
|
||||
use rand::rngs::StdRng;
|
||||
|
||||
const SPHINX: LpFrameKind = LpFrameKind::SphinxPacket;
|
||||
|
||||
/// Build a `Fragment` with explicit header values via the public
|
||||
/// `LpFrame` round-trip, so tests can craft duplicates, out-of-order
|
||||
/// inserts and id collisions without depending on RNG output.
|
||||
fn make_fragment(
|
||||
id: u64,
|
||||
total_fragments: u8,
|
||||
current_fragment: u8,
|
||||
inner_kind: LpFrameKind,
|
||||
payload: Vec<u8>,
|
||||
) -> Fragment {
|
||||
let mut attrs = [0u8; 14];
|
||||
attrs[0..8].copy_from_slice(&id.to_be_bytes());
|
||||
attrs[8] = total_fragments;
|
||||
attrs[9] = current_fragment;
|
||||
attrs[10..12].copy_from_slice(&u16::to_be_bytes(inner_kind.into()));
|
||||
let frame = LpFrame::new_with_attributes(LpFrameKind::FragmentedData, attrs, payload);
|
||||
Fragment::try_from(frame).unwrap()
|
||||
}
|
||||
|
||||
fn split(message: LpFrame, fragment_size: usize) -> Vec<Fragment> {
|
||||
let mut rng = StdRng::seed_from_u64(0xdead_beef);
|
||||
fragment_lp_message(&mut rng, message, fragment_size)
|
||||
}
|
||||
|
||||
/// Shared base instant for the test module. `Instant` cannot be constructed
|
||||
/// from an absolute value, so we anchor on a single `now()` and express the
|
||||
/// formerly-`u64` tick timestamps as offsets from it — only differences
|
||||
/// matter for buffering/eviction logic, so determinism is preserved.
|
||||
static BASE: std::sync::LazyLock<Instant> = std::sync::LazyLock::new(Instant::now);
|
||||
|
||||
/// A timestamp `ms` milliseconds after [`BASE`] (replaces the old `u64` ticks).
|
||||
fn at(ms: u64) -> Instant {
|
||||
*BASE + Duration::from_millis(ms)
|
||||
}
|
||||
|
||||
/// A timeout of `ms` milliseconds (replaces the old `u64` offsets).
|
||||
fn timeout(ms: u64) -> Duration {
|
||||
Duration::from_millis(ms)
|
||||
}
|
||||
|
||||
/// Build a deterministic, *decodable* set of `Fragment`s for a message of
|
||||
/// `inner_kind` carrying `content`, tagged with `id` and split into exactly
|
||||
/// `count` fragments.
|
||||
///
|
||||
/// Unlike [`make_fragment`], which crafts a single fragment from a raw
|
||||
/// payload, this encodes a real [`LpFrame`] first (header + content) and
|
||||
/// slices the encoded bytes — matching what `fragment_lp_message` does in
|
||||
/// production, so the reassembled bytes decode back into the original frame.
|
||||
fn make_message_fragments(
|
||||
id: u64,
|
||||
inner_kind: LpFrameKind,
|
||||
content: &[u8],
|
||||
count: u8,
|
||||
) -> Vec<Fragment> {
|
||||
let encoded = LpFrame::new(inner_kind, content.to_vec()).to_bytes();
|
||||
let frag_size = encoded.len().div_ceil(count as usize);
|
||||
let frags: Vec<Fragment> = encoded
|
||||
.chunks(frag_size)
|
||||
.enumerate()
|
||||
.map(|(i, chunk)| make_fragment(id, count, i as u8, inner_kind, chunk.to_vec()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
frags.len() as u8,
|
||||
count,
|
||||
"content/count combination did not split into exactly {count} fragments"
|
||||
);
|
||||
frags
|
||||
}
|
||||
|
||||
// ---------- MessageBuffer ----------
|
||||
|
||||
#[test]
|
||||
fn buffer_completes_on_single_fragment() {
|
||||
let f = make_fragment(1, 1, 0, SPHINX, b"hi".to_vec());
|
||||
let mut buf = MessageBuffer::new(1, at(0));
|
||||
assert!(!buf.is_complete);
|
||||
buf.insert_fragment(f, at(0));
|
||||
assert!(buf.is_complete);
|
||||
assert_eq!(buf.into_message(), b"hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_completes_only_after_last_fragment() {
|
||||
let mut buf = MessageBuffer::new(3, at(0));
|
||||
buf.insert_fragment(make_fragment(7, 3, 0, SPHINX, vec![0xaa]), at(1));
|
||||
assert!(!buf.is_complete);
|
||||
buf.insert_fragment(make_fragment(7, 3, 1, SPHINX, vec![0xbb]), at(2));
|
||||
assert!(!buf.is_complete);
|
||||
buf.insert_fragment(make_fragment(7, 3, 2, SPHINX, vec![0xcc]), at(3));
|
||||
assert!(buf.is_complete);
|
||||
assert_eq!(buf.into_message(), vec![0xaa, 0xbb, 0xcc]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_reassembles_in_order_regardless_of_insertion_order() {
|
||||
let mut buf = MessageBuffer::new(4, at(0));
|
||||
buf.insert_fragment(make_fragment(1, 4, 2, SPHINX, vec![3]), at(0));
|
||||
buf.insert_fragment(make_fragment(1, 4, 0, SPHINX, vec![1]), at(0));
|
||||
buf.insert_fragment(make_fragment(1, 4, 3, SPHINX, vec![4]), at(0));
|
||||
buf.insert_fragment(make_fragment(1, 4, 1, SPHINX, vec![2]), at(0));
|
||||
assert!(buf.is_complete);
|
||||
assert_eq!(buf.into_message(), vec![1, 2, 3, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_tracks_last_fragment_timestamp() {
|
||||
let mut buf = MessageBuffer::new(2, at(100));
|
||||
assert_eq!(buf.last_fragment_timestamp, at(100));
|
||||
buf.insert_fragment(make_fragment(1, 2, 0, SPHINX, vec![0]), at(250));
|
||||
assert_eq!(buf.last_fragment_timestamp, at(250));
|
||||
buf.insert_fragment(make_fragment(1, 2, 1, SPHINX, vec![1]), at(400));
|
||||
assert_eq!(buf.last_fragment_timestamp, at(400));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_duplicate_fragment_does_not_break_completion() {
|
||||
let mut buf = MessageBuffer::new(2, at(0));
|
||||
buf.insert_fragment(make_fragment(1, 2, 0, SPHINX, vec![0xaa]), at(0));
|
||||
// Same slot twice
|
||||
buf.insert_fragment(make_fragment(1, 2, 0, SPHINX, vec![0xaa]), at(0));
|
||||
assert!(!buf.is_complete);
|
||||
buf.insert_fragment(make_fragment(1, 2, 1, SPHINX, vec![0xbb]), at(0));
|
||||
assert!(buf.is_complete);
|
||||
assert_eq!(buf.into_message(), vec![0xaa, 0xbb]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_empty_payloads_reassemble_to_empty_message() {
|
||||
let mut buf = MessageBuffer::new(2, at(0));
|
||||
buf.insert_fragment(make_fragment(1, 2, 0, SPHINX, vec![]), at(0));
|
||||
buf.insert_fragment(make_fragment(1, 2, 1, SPHINX, vec![]), at(0));
|
||||
assert!(buf.is_complete);
|
||||
assert!(buf.into_message().is_empty());
|
||||
}
|
||||
|
||||
// ---------- MessageReconstructor: round trip via fragment_payload ----------
|
||||
|
||||
#[test]
|
||||
fn reconstructor_round_trip_single_fragment_message() {
|
||||
let message = LpFrame::new(SPHINX, b"small".as_slice());
|
||||
let mut fragments = split(message.clone(), 64);
|
||||
assert_eq!(fragments.len(), 1);
|
||||
|
||||
let rec = MessageReconstructor::new(timeout(60));
|
||||
let out = rec.insert_new_fragment(fragments.pop().unwrap(), at(0));
|
||||
let recovered_frame = out
|
||||
.expect("single fragment must complete the message")
|
||||
.unwrap();
|
||||
assert_eq!(recovered_frame, message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconstructor_round_trip_multi_fragment_message() {
|
||||
let message = LpFrame::new(SPHINX, (0u8..=200).collect::<Vec<_>>());
|
||||
let fragments = split(message.clone(), 16);
|
||||
assert!(fragments.len() > 1);
|
||||
|
||||
let rec = MessageReconstructor::new(timeout(60));
|
||||
let total = fragments.len();
|
||||
let mut out = None;
|
||||
for (i, f) in fragments.into_iter().enumerate() {
|
||||
out = rec.insert_new_fragment(f, at(i as u64));
|
||||
if i + 1 < total {
|
||||
assert!(out.is_none(), "premature completion at fragment {i}");
|
||||
}
|
||||
}
|
||||
let recovered_frame = out
|
||||
.expect("last fragment must complete the message")
|
||||
.unwrap();
|
||||
assert_eq!(recovered_frame, message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconstructor_handles_out_of_order_arrival() {
|
||||
let message = LpFrame::new(SPHINX, (0u8..=200).collect::<Vec<_>>());
|
||||
let mut fragments = split(message.clone(), 18);
|
||||
// Reverse arrival order.
|
||||
fragments.reverse();
|
||||
|
||||
let rec = MessageReconstructor::new(timeout(60));
|
||||
let mut out = None;
|
||||
for (i, f) in fragments.into_iter().enumerate() {
|
||||
out = rec.insert_new_fragment(f, at(i as u64));
|
||||
}
|
||||
let recovered_frame = out
|
||||
.expect("last fragment must complete the message")
|
||||
.unwrap();
|
||||
assert_eq!(recovered_frame, message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconstructor_keeps_distinct_messages_separate() {
|
||||
// Two messages with different ids interleaved.
|
||||
let mut a = make_message_fragments(1, SPHINX, &[0xa1, 0xa2], 2);
|
||||
let mut b = make_message_fragments(2, SPHINX, &[0xb1, 0xb2], 2);
|
||||
|
||||
let rec = MessageReconstructor::new(timeout(60));
|
||||
// Interleave.
|
||||
assert!(rec.insert_new_fragment(a.remove(0), at(0)).is_none());
|
||||
assert!(rec.insert_new_fragment(b.remove(0), at(1)).is_none());
|
||||
let msg_a = rec
|
||||
.insert_new_fragment(a.remove(0), at(2))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let msg_b = rec
|
||||
.insert_new_fragment(b.remove(0), at(3))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(msg_a.content, vec![0xa1, 0xa2]);
|
||||
assert_eq!(msg_b.content, vec![0xb1, 0xb2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconstructor_clears_buffer_after_emitting_message() {
|
||||
let f = make_message_fragments(99, SPHINX, &[0xff], 1).remove(0);
|
||||
let rec = MessageReconstructor::new(timeout(60));
|
||||
rec.insert_new_fragment(f, at(0))
|
||||
.expect("single fragment must complete the message")
|
||||
.unwrap();
|
||||
assert!(
|
||||
rec.in_flight_messages.is_empty(),
|
||||
"completed messages must not linger in the in-flight map"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- cleanup_stale_buffers ----------
|
||||
|
||||
#[test]
|
||||
fn cleanup_evicts_buffers_older_than_timeout() {
|
||||
let f = make_fragment(1, 2, 0, SPHINX, vec![0]);
|
||||
let rec = MessageReconstructor::new(timeout(10));
|
||||
// First (and only) fragment received at t=0; the message stays
|
||||
// incomplete.
|
||||
assert!(rec.insert_new_fragment(f, at(0)).is_none());
|
||||
assert_eq!(rec.in_flight_messages.len(), 1);
|
||||
|
||||
// Within the timeout window — buffer must survive.
|
||||
rec.cleanup_stale_buffers(at(5));
|
||||
assert_eq!(rec.in_flight_messages.len(), 1);
|
||||
|
||||
// Past the window — evicted.
|
||||
rec.cleanup_stale_buffers(at(100));
|
||||
assert!(rec.in_flight_messages.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_runs_implicitly_on_insert() {
|
||||
// Stale message at t=0, then a brand new message arrives well past
|
||||
// the timeout. The implicit cleanup inside `insert_new_fragment`
|
||||
// must drop the stale entry.
|
||||
// Only the first of the stale message's two fragments is ever delivered.
|
||||
let stale = make_message_fragments(1, SPHINX, &[0x00, 0x01], 2).remove(0);
|
||||
let fresh = make_message_fragments(2, SPHINX, &[0xff], 1).remove(0);
|
||||
|
||||
let rec = MessageReconstructor::new(timeout(10));
|
||||
assert!(rec.insert_new_fragment(stale, at(0)).is_none());
|
||||
assert_eq!(rec.in_flight_messages.len(), 1);
|
||||
|
||||
let msg = rec.insert_new_fragment(fresh, at(1_000)).unwrap().unwrap();
|
||||
assert_eq!(msg.content, vec![0xff]);
|
||||
// `fresh` was a single-fragment message and is removed on emission;
|
||||
// the stale buffer must also be gone.
|
||||
assert!(rec.in_flight_messages.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_resets_idle_timer_on_each_fragment() {
|
||||
// A buffer that keeps receiving fragments must not be evicted
|
||||
// even if the absolute time exceeds the timeout, as long as the
|
||||
// gap between fragments stays under it.
|
||||
let rec = MessageReconstructor::new(timeout(10));
|
||||
let mut frags = make_message_fragments(1, SPHINX, &[0xa, 0xb, 0xc], 3).into_iter();
|
||||
|
||||
assert!(
|
||||
rec.insert_new_fragment(frags.next().unwrap(), at(0))
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
rec.insert_new_fragment(frags.next().unwrap(), at(8))
|
||||
.is_none()
|
||||
);
|
||||
// Absolute time is now 16 (> 10), but the gap from the previous
|
||||
// fragment (8) to now (16) is 8, still within the 10-tick timeout.
|
||||
let out = rec.insert_new_fragment(frags.next().unwrap(), at(16));
|
||||
let msg = out.expect("buffer must still be alive").unwrap();
|
||||
assert_eq!(msg.content, vec![0xa, 0xb, 0xc]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Trait definitions and data structures for low-level packet (LP) processing
|
||||
//! pipelines in the Nym mixnet.
|
||||
//!
|
||||
//! ## Crate layout
|
||||
//!
|
||||
//! | Module | Purpose |
|
||||
//! |--------|---------|
|
||||
//! | [`clients`] | Client-side pipeline traits and types: chunking, reliability, obfuscation, routing security, framing, transport |
|
||||
//! | [`common`] | Shared framing and transport traits used by both clients and mixnodes |
|
||||
//! | [`nymnodes`] | Mixnode-side pipeline traits: unwrap incoming packets, re-wrap and forward them |
|
||||
//!
|
||||
//! ## Core types
|
||||
//!
|
||||
//! [`TimedData`] is the foundational wrapper that pairs any piece of data with an
|
||||
//! [`Instant`] timestamp, threading timing information through every stage of the
|
||||
//! pipeline. [`TimedPayload`] is a convenience alias for `TimedData<Vec<u8>>`.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Instant;
|
||||
|
||||
pub mod clients;
|
||||
pub mod common;
|
||||
pub mod fragmentation;
|
||||
pub mod nymnodes;
|
||||
pub mod packet;
|
||||
|
||||
/// Convenience alias for [`TimedData`] when the payload is a raw byte buffer.
|
||||
pub type TimedPayload = TimedData<Vec<u8>>;
|
||||
/// Convenience alias for [`AddressedTimedData`] when the payload is a raw byte buffer.
|
||||
pub type AddressedTimedPayload = AddressedTimedData<Vec<u8>>;
|
||||
/// Convenience alias for [`PipelineData`] when the payload is a raw byte buffer.
|
||||
pub type PipelinePayload<Opts, NdId = SocketAddr> = PipelineData<Vec<u8>, Opts, NdId>;
|
||||
|
||||
/// A value of type `D` tagged with an [`Instant`] timestamp.
|
||||
///
|
||||
/// `TimedData` threads timing information through every stage of the LP
|
||||
/// pipeline. It is produced by [`clients::traits::Chunking`] and propagated
|
||||
/// unchanged (or with its timestamp replaced via [`TimedData::with_timestamp`])
|
||||
/// through every subsequent pipeline stage until the packet is sent on the wire.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TimedData<D> {
|
||||
pub timestamp: Instant,
|
||||
pub data: D,
|
||||
}
|
||||
|
||||
impl<D> TimedData<D> {
|
||||
pub fn new(timestamp: Instant, data: D) -> Self {
|
||||
TimedData { timestamp, data }
|
||||
}
|
||||
/// Apply `op` to the data component, leaving the timestamp unchanged.
|
||||
///
|
||||
/// `Nd` can differ from `D`, so this also acts as a type transform.
|
||||
pub fn data_transform<F, Nd>(self, mut op: F) -> TimedData<Nd>
|
||||
where
|
||||
F: FnMut(D) -> Nd,
|
||||
{
|
||||
TimedData {
|
||||
data: op(self.data),
|
||||
timestamp: self.timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a new timestamp
|
||||
pub fn with_timestamp(self, new_timestamp: Instant) -> Self {
|
||||
TimedData {
|
||||
data: self.data,
|
||||
timestamp: new_timestamp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A timestamped payload extended with pipeline-stage options and a destination address.
|
||||
///
|
||||
/// `PipelineData` is the value flowing between client-side pipeline stages
|
||||
/// ([`Chunking`], [`Reliability`], [`Obfuscation`], [`RoutingSecurity`], [`Framing`],
|
||||
/// [`Transport`]). It carries:
|
||||
///
|
||||
/// - `data`: a [`TimedData`] pairing the payload with its scheduled timestamp,
|
||||
/// - `options`: opaque per-message metadata threaded through the pipeline (`()`
|
||||
/// once the message is reduced to an addressed payload),
|
||||
/// - `dst`: the next-hop socket address the wire layer should send to.
|
||||
///
|
||||
/// [`Chunking`]: crate::clients::traits::Chunking
|
||||
/// [`Reliability`]: crate::clients::traits::Reliability
|
||||
/// [`Obfuscation`]: crate::clients::traits::Obfuscation
|
||||
/// [`RoutingSecurity`]: crate::clients::traits::RoutingSecurity
|
||||
/// [`Framing`]: crate::common::traits::Framing
|
||||
/// [`Transport`]: crate::common::traits::Transport
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PipelineData<D, Opts, NdId = SocketAddr> {
|
||||
pub data: TimedData<D>,
|
||||
pub options: Opts,
|
||||
pub dst: NdId,
|
||||
}
|
||||
|
||||
impl<D, Opts, NdId> PipelineData<D, Opts, NdId> {
|
||||
/// Construct a new [`PipelineData`] from its parts.
|
||||
pub fn new(timestamp: Instant, data: D, options: Opts, dst: NdId) -> Self {
|
||||
PipelineData {
|
||||
data: TimedData::new(timestamp, data),
|
||||
options,
|
||||
dst,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply `op` to the data component, leaving the timestamp, options, and
|
||||
/// destination unchanged.
|
||||
///
|
||||
/// `Nd` can differ from `D`, so this also acts as a type transform.
|
||||
pub fn data_transform<F, Nd>(self, op: F) -> PipelineData<Nd, Opts, NdId>
|
||||
where
|
||||
F: FnMut(D) -> Nd,
|
||||
{
|
||||
PipelineData {
|
||||
data: self.data.data_transform(op),
|
||||
options: self.options,
|
||||
dst: self.dst,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a new timestamp
|
||||
pub fn with_timestamp(self, new_timestamp: Instant) -> Self {
|
||||
PipelineData {
|
||||
data: self.data.with_timestamp(new_timestamp),
|
||||
options: self.options,
|
||||
dst: self.dst,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply `op` to the options component, leaving the timestamp, data, and
|
||||
/// destination unchanged.
|
||||
///
|
||||
/// `No` can differ from `O`, so this also acts as a type transform.
|
||||
pub fn options_transform<F, No>(self, mut op: F) -> PipelineData<D, No, NdId>
|
||||
where
|
||||
F: FnMut(Opts) -> No,
|
||||
{
|
||||
PipelineData {
|
||||
data: self.data,
|
||||
options: op(self.options),
|
||||
dst: self.dst,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a new destination
|
||||
pub fn with_dst<NewNdId>(self, new_dst: NewNdId) -> PipelineData<D, Opts, NewNdId> {
|
||||
PipelineData {
|
||||
data: self.data,
|
||||
options: self.options,
|
||||
dst: new_dst,
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the pipeline options, producing a plain addressed payload.
|
||||
pub fn into_addressed(self) -> AddressedTimedData<D, NdId> {
|
||||
AddressedTimedData {
|
||||
data: self.data,
|
||||
options: (),
|
||||
dst: self.dst,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience alias for [`PipelineData`] when no per-message pipeline options
|
||||
/// are needed. Avoids duplicating the pipeline data structure.
|
||||
pub type AddressedTimedData<D, NdId = SocketAddr> = PipelineData<D, (), NdId>;
|
||||
|
||||
impl<D, NdId> AddressedTimedData<D, NdId> {
|
||||
/// Construct a new [`AddressedTimedData`] with unit `options`.
|
||||
pub fn new_addressed(timestamp: Instant, data: D, dst: NdId) -> Self {
|
||||
AddressedTimedData {
|
||||
data: TimedData::new(timestamp, data),
|
||||
options: (),
|
||||
dst,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a [`AddressedTimedData`] into a [`PipelineData`] with the provided options.
|
||||
pub fn with_options<Opts>(self, opts: Opts) -> PipelineData<D, Opts, NdId> {
|
||||
PipelineData {
|
||||
data: self.data,
|
||||
options: opts,
|
||||
dst: self.dst,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
pub mod traits;
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::{AddressedTimedData, PipelinePayload, TimedPayload};
|
||||
|
||||
use crate::common::traits::{WireUnwrappingPipeline, WireWrappingPipeline};
|
||||
|
||||
/// Top-level processing trait for a mix node.
|
||||
///
|
||||
/// Combines [`WireUnwrappingPipeline`] and [`WireWrappingPipeline`] with a blank [`mix`]
|
||||
/// step that the implementor fills in (decrypt, route, re-encrypt, cover traffic, etc.).
|
||||
///
|
||||
/// # Type Parameters
|
||||
/// - `Pkt`: Transport packet type; the same type is consumed and produced.
|
||||
///
|
||||
/// # Associated Types
|
||||
/// - `Options`: Per-message pipeline options carried into the re-wrapping side.
|
||||
/// - `MessageKind`: Message-kind marker returned by the unwrap side.
|
||||
///
|
||||
/// Both are properties of the concrete pipeline rather than something a caller
|
||||
/// varies, so they live as associated types. This keeps consumers (e.g. a
|
||||
/// generic worker driver) free of `Options` / `MessageKind` bounds.
|
||||
///
|
||||
/// Frame types are owned by the wire sub-traits as associated items and do not
|
||||
/// appear in this trait's parameter list.
|
||||
///
|
||||
/// # Required Methods
|
||||
/// - `mix`: Given a reassembled payload and the current timestamp, return zero or more
|
||||
/// [`PipelinePayload`]s carrying their next-hop addresses to be re-wrapped and forwarded.
|
||||
///
|
||||
/// # Provided Methods
|
||||
/// - `process`: Unwraps the incoming packet via [`WireUnwrappingPipeline::wire_unwrap`],
|
||||
/// passes the result to [`mix`], and re-wraps each output payload via
|
||||
/// [`WireWrappingPipeline::wire_wrap`].
|
||||
///
|
||||
/// [`mix`]: NymNodeProcessingPipeline::mix
|
||||
pub trait NymNodeProcessingPipeline<Pkt>:
|
||||
WireUnwrappingPipeline<Pkt, <Self as NymNodeProcessingPipeline<Pkt>>::MessageKind>
|
||||
+ WireWrappingPipeline<Pkt, <Self as NymNodeProcessingPipeline<Pkt>>::Options>
|
||||
{
|
||||
type Options;
|
||||
type MessageKind;
|
||||
|
||||
fn mix(
|
||||
&mut self,
|
||||
message_kind: Self::MessageKind,
|
||||
payload: TimedPayload,
|
||||
timestamp: Instant,
|
||||
) -> Vec<PipelinePayload<Self::Options>>;
|
||||
|
||||
fn process(
|
||||
&mut self,
|
||||
input: Pkt,
|
||||
timestamp: Instant,
|
||||
) -> Result<Vec<AddressedTimedData<Pkt>>, Self::Error> {
|
||||
let Some((payload, kind)) = self.wire_unwrap(input, timestamp)? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mixed = self.mix(kind, payload, timestamp);
|
||||
Ok(mixed
|
||||
.into_iter()
|
||||
.flat_map(|addressed_data| self.wire_wrap(addressed_data).into_iter())
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,31 @@
|
||||
|
||||
use crate::packet::error::MalformedLpPacketError;
|
||||
use bytes::{BufMut, Bytes, BytesMut};
|
||||
use num_enum::{IntoPrimitive, TryFromPrimitive};
|
||||
use num_enum::{FromPrimitive, IntoPrimitive};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
|
||||
/// Represent kind of application data being sent in Transport mode
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, IntoPrimitive, FromPrimitive, Hash)]
|
||||
#[repr(u16)]
|
||||
pub enum LpFrameKind {
|
||||
Opaque = 0,
|
||||
Registration = 1,
|
||||
Forward = 2,
|
||||
SphinxStream = 3,
|
||||
FragmentedData = 4,
|
||||
SphinxPacket = 5, // Sphinx Packet to process, delay and forward
|
||||
OutfoxPacket = 6, // Outfox Packet to process, delay and forward
|
||||
ForwardSphinxPacket = 7, // Sphinx Packet to immediately forward
|
||||
ForwardOutfoxPacket = 8, // Outfox Packet to immediately forward
|
||||
|
||||
#[num_enum(catch_all)]
|
||||
Unknown(u16),
|
||||
}
|
||||
|
||||
/// Raw 14-byte frame attributes field in every [`LpFrameHeader`].
|
||||
/// Interpretation depends on the [`LpFrameKind`].
|
||||
pub type LpFrameAttributes = [u8; 14];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LpFrameHeader {
|
||||
pub kind: LpFrameKind,
|
||||
@@ -15,10 +37,10 @@ pub struct LpFrameHeader {
|
||||
impl LpFrameHeader {
|
||||
pub const SIZE: usize = 16; // message_kind(2) + message_attributes(14)
|
||||
|
||||
pub fn new(kind: LpFrameKind, frame_attributes: LpFrameAttributes) -> Self {
|
||||
pub fn new(kind: LpFrameKind, frame_attributes: impl Into<LpFrameAttributes>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
frame_attributes,
|
||||
frame_attributes: frame_attributes.into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +53,7 @@ impl LpFrameHeader {
|
||||
|
||||
/// Encode directly into a BytesMut buffer
|
||||
pub fn encode(&self, dst: &mut BytesMut) {
|
||||
dst.put_u16_le(self.kind as u16);
|
||||
dst.put_u16_le(self.kind.into());
|
||||
dst.put_slice(&self.frame_attributes);
|
||||
}
|
||||
|
||||
@@ -41,8 +63,7 @@ impl LpFrameHeader {
|
||||
}
|
||||
let raw_kind = u16::from_le_bytes([src[0], src[1]]);
|
||||
|
||||
let kind = LpFrameKind::try_from(raw_kind)
|
||||
.map_err(|_| MalformedLpPacketError::invalid_data_kind(raw_kind))?;
|
||||
let kind = LpFrameKind::from(raw_kind);
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let message_attributes = src[2..16].try_into().unwrap();
|
||||
@@ -60,12 +81,6 @@ pub struct LpFrame {
|
||||
pub content: Bytes,
|
||||
}
|
||||
|
||||
impl AsRef<[u8]> for LpFrame {
|
||||
fn as_ref(&self) -> &[u8] {
|
||||
&self.content
|
||||
}
|
||||
}
|
||||
|
||||
impl LpFrame {
|
||||
pub fn new(kind: LpFrameKind, content: impl Into<Bytes>) -> Self {
|
||||
Self {
|
||||
@@ -74,6 +89,17 @@ impl LpFrame {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_with_attributes(
|
||||
kind: LpFrameKind,
|
||||
attrs: impl Into<LpFrameAttributes>,
|
||||
content: impl Into<Bytes>,
|
||||
) -> Self {
|
||||
Self {
|
||||
header: LpFrameHeader::new(kind, attrs),
|
||||
content: content.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode(&self, dst: &mut BytesMut) {
|
||||
self.header.encode(dst);
|
||||
|
||||
@@ -87,6 +113,12 @@ impl LpFrame {
|
||||
Ok(Self { header, content })
|
||||
}
|
||||
|
||||
pub fn to_bytes(self) -> Vec<u8> {
|
||||
let mut bytes = BytesMut::new();
|
||||
self.encode(&mut bytes);
|
||||
bytes.freeze().to_vec()
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> LpFrameKind {
|
||||
self.header.kind
|
||||
}
|
||||
@@ -110,21 +142,13 @@ impl LpFrame {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
// is_empty in the sense len == 0 doesn't make sense in that case
|
||||
#[allow(clippy::len_without_is_empty)]
|
||||
pub fn len(&self) -> usize {
|
||||
LpFrameHeader::SIZE + self.content.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Represent kind of application data being sent in Transport mode
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, IntoPrimitive, TryFromPrimitive)]
|
||||
#[repr(u16)]
|
||||
pub enum LpFrameKind {
|
||||
Opaque = 0,
|
||||
Registration = 1,
|
||||
Forward = 2,
|
||||
SphinxStream = 3,
|
||||
}
|
||||
|
||||
/// Message type within a `LpFrameKind::SphinxStream` frame.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
@@ -151,10 +175,6 @@ pub struct SphinxStreamFrameAttributes {
|
||||
pub sequence_num: u32,
|
||||
}
|
||||
|
||||
/// Raw 14-byte frame attributes field in every [`LpFrameHeader`].
|
||||
/// Interpretation depends on the [`LpFrameKind`].
|
||||
pub type LpFrameAttributes = [u8; 14];
|
||||
|
||||
impl SphinxStreamFrameAttributes {
|
||||
pub fn encode(&self) -> LpFrameAttributes {
|
||||
let mut buf = [0u8; 14];
|
||||
@@ -165,6 +185,8 @@ impl SphinxStreamFrameAttributes {
|
||||
}
|
||||
|
||||
pub fn parse(attrs: &LpFrameAttributes) -> Result<Self, MalformedLpPacketError> {
|
||||
// SAFETY : 8 bytes slice into 8 bytes array
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let stream_id = u64::from_be_bytes(attrs[0..8].try_into().unwrap());
|
||||
let msg_type = match attrs[8] {
|
||||
0 => SphinxStreamMsgType::Open,
|
||||
@@ -175,6 +197,8 @@ impl SphinxStreamFrameAttributes {
|
||||
)));
|
||||
}
|
||||
};
|
||||
// SAFETY : 4 bytes slice into 4 bytes array
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let sequence_num = u32::from_be_bytes(attrs[9..13].try_into().unwrap());
|
||||
Ok(Self {
|
||||
stream_id,
|
||||
@@ -1,11 +1,13 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use crate::packet::error::MalformedLpPacketError;
|
||||
use crate::packet::version;
|
||||
use crate::{packet::error::MalformedLpPacketError, peer_config::LpReceiverIndex};
|
||||
use bytes::{BufMut, BytesMut};
|
||||
use tracing::warn;
|
||||
|
||||
pub type LpReceiverIndex = u32;
|
||||
|
||||
/// Outer header (12 bytes) - always cleartext, used for routing.
|
||||
///
|
||||
/// This is the first 12 bytes of every LP packet, containing only the fields
|
||||
@@ -118,6 +120,8 @@ pub struct LpHeader {
|
||||
}
|
||||
|
||||
impl LpHeader {
|
||||
pub const SIZE: usize = OuterHeader::SIZE + InnerHeader::SIZE;
|
||||
|
||||
pub fn new(receiver_idx: LpReceiverIndex, counter: u64, protocol_version: u8) -> Self {
|
||||
Self {
|
||||
outer: OuterHeader {
|
||||
@@ -13,7 +13,6 @@ pub use header::{InnerHeader, LpHeader, OuterHeader};
|
||||
pub mod error;
|
||||
pub mod frame;
|
||||
pub mod header;
|
||||
pub mod replay;
|
||||
|
||||
pub mod version {
|
||||
/// The current version of the Lewes Protocol that is put into each new constructed header.
|
||||
@@ -24,8 +23,7 @@ pub mod version {
|
||||
pub(crate) const UDP_HEADER_LEN: usize = 8;
|
||||
#[allow(dead_code)]
|
||||
pub(crate) const IP_HEADER_LEN: usize = 40; // v4 - 20, v6 - 40
|
||||
#[allow(dead_code)]
|
||||
pub(crate) const MTU: usize = 1500;
|
||||
pub const MTU: usize = 1500;
|
||||
#[allow(dead_code)]
|
||||
pub(crate) const UDP_OVERHEAD: usize = UDP_HEADER_LEN + IP_HEADER_LEN;
|
||||
#[allow(dead_code)]
|
||||
@@ -69,6 +67,22 @@ impl EncryptedLpPacket {
|
||||
dst.put_slice(&self.ciphertext)
|
||||
}
|
||||
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
let mut buf = BytesMut::new();
|
||||
self.encode(&mut buf);
|
||||
buf.to_vec()
|
||||
}
|
||||
|
||||
pub fn decode(src: &[u8]) -> Result<Self, MalformedLpPacketError> {
|
||||
let outer_header = OuterHeader::parse(src)?;
|
||||
let ciphertext = src[OuterHeader::SIZE..].to_vec();
|
||||
|
||||
Ok(Self {
|
||||
outer_header,
|
||||
ciphertext,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ciphertext(&self) -> &[u8] {
|
||||
&self.ciphertext
|
||||
}
|
||||
@@ -117,4 +131,34 @@ impl LpPacket {
|
||||
self.header.dbg_encode(dst);
|
||||
self.frame.encode(dst)
|
||||
}
|
||||
|
||||
// SW TMP, while we don't have any encryption
|
||||
pub fn decode(packet: EncryptedLpPacket) -> Result<Self, MalformedLpPacketError> {
|
||||
let plaintext = packet.ciphertext();
|
||||
let inner_header = InnerHeader::parse(plaintext)?;
|
||||
let payload = &plaintext[InnerHeader::SIZE..];
|
||||
let frame = LpFrame::decode(payload)?;
|
||||
|
||||
Ok(Self::new(
|
||||
LpHeader {
|
||||
outer: packet.outer_header(),
|
||||
inner: inner_header,
|
||||
},
|
||||
frame,
|
||||
))
|
||||
}
|
||||
|
||||
// SW TMP, while we don't have any encryption
|
||||
pub fn encode(self) -> EncryptedLpPacket {
|
||||
// Outer header gets serialized by EncryptedLpPacket so we need to not serialize it as part of LpPacket
|
||||
let outer_header = self.header.outer;
|
||||
|
||||
// LpPacket bytes without outerheader
|
||||
let mut bytes = BytesMut::new();
|
||||
self.header.inner.encode(&mut bytes);
|
||||
self.frame.encode(&mut bytes);
|
||||
let ciphertext = bytes.freeze().to_vec();
|
||||
|
||||
EncryptedLpPacket::new(outer_header, ciphertext)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use nym_lp_data::packet::{
|
||||
LpFrame, LpHeader, LpPacket,
|
||||
frame::{LpFrameHeader, LpFrameKind},
|
||||
};
|
||||
|
||||
use nym_lp_data::{
|
||||
AddressedTimedData, PipelinePayload,
|
||||
clients::traits::{Chunking, Obfuscation, Reliability, RoutingSecurity},
|
||||
common::traits::{Framing, Transport},
|
||||
};
|
||||
|
||||
pub type BasicPipelinePayload = PipelinePayload<()>;
|
||||
|
||||
pub struct MockChunking;
|
||||
impl Chunking<()> for MockChunking {
|
||||
fn chunked(
|
||||
&mut self,
|
||||
input: BasicPipelinePayload,
|
||||
chunk_size: usize,
|
||||
timestamp: Instant,
|
||||
) -> Vec<BasicPipelinePayload> {
|
||||
input
|
||||
.data
|
||||
.data
|
||||
.chunks(chunk_size)
|
||||
.map(|chunk| BasicPipelinePayload::new(timestamp, chunk.to_vec(), (), input.dst))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MockReliability;
|
||||
|
||||
impl MockReliability {
|
||||
const HEADER: &[u8; 5] = b"0KCP0";
|
||||
}
|
||||
|
||||
impl Reliability<()> for MockReliability {
|
||||
const OVERHEAD_SIZE: usize = Self::HEADER.len();
|
||||
fn reliable_encode(
|
||||
&mut self,
|
||||
input: Option<BasicPipelinePayload>,
|
||||
_: Instant,
|
||||
) -> Vec<BasicPipelinePayload> {
|
||||
input
|
||||
.map(|data| {
|
||||
vec![data.data_transform(|data| {
|
||||
let mut packet = Self::HEADER.to_vec();
|
||||
packet.extend(data);
|
||||
packet
|
||||
})]
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MockSphinxSecurity {
|
||||
pub nb_frames: usize,
|
||||
}
|
||||
|
||||
impl MockSphinxSecurity {
|
||||
const HEADER: &[u8; 8] = b"0SPHINX0";
|
||||
}
|
||||
|
||||
impl RoutingSecurity<()> for MockSphinxSecurity {
|
||||
const OVERHEAD_SIZE: usize = Self::HEADER.len();
|
||||
|
||||
fn nb_frames(&self) -> usize {
|
||||
self.nb_frames
|
||||
}
|
||||
|
||||
fn encrypt(&mut self, input: BasicPipelinePayload) -> BasicPipelinePayload {
|
||||
input.data_transform(|data| {
|
||||
let mut packet = Self::HEADER.to_vec();
|
||||
packet.extend(data);
|
||||
packet
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct KekwObfuscation;
|
||||
|
||||
impl Obfuscation<()> for KekwObfuscation {
|
||||
fn obfuscate(
|
||||
&mut self,
|
||||
input: Option<BasicPipelinePayload>,
|
||||
_timestamp: Instant,
|
||||
) -> Vec<BasicPipelinePayload> {
|
||||
if let Some(input) = input {
|
||||
let new_timestamp = input.data.timestamp + Duration::from_millis(1);
|
||||
vec![input.with_timestamp(new_timestamp)]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MockLpFraming;
|
||||
|
||||
impl MockLpFraming {
|
||||
const FRAME_ATTRIBUTES: &[u8; 14] = b"0LpFrameAttrs0";
|
||||
}
|
||||
|
||||
impl Framing<()> for MockLpFraming {
|
||||
type Frame = LpFrame;
|
||||
const OVERHEAD_SIZE: usize = LpFrameHeader::SIZE;
|
||||
fn to_frame(
|
||||
&mut self,
|
||||
input: BasicPipelinePayload,
|
||||
frame_size: usize,
|
||||
) -> Vec<AddressedTimedData<LpFrame>> {
|
||||
input
|
||||
.data
|
||||
.data
|
||||
.chunks(frame_size)
|
||||
.map(|frame_payload| {
|
||||
let header = LpFrameHeader::new(LpFrameKind::Opaque, *Self::FRAME_ATTRIBUTES);
|
||||
|
||||
AddressedTimedData::new_addressed(
|
||||
input.data.timestamp,
|
||||
LpFrame {
|
||||
header,
|
||||
content: frame_payload.to_vec().into(),
|
||||
},
|
||||
input.dst,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MockLpTransport;
|
||||
|
||||
impl Transport<LpPacket> for MockLpTransport {
|
||||
type Frame = LpFrame;
|
||||
const OVERHEAD_SIZE: usize = LpHeader::SIZE;
|
||||
fn to_transport_packet(
|
||||
&mut self,
|
||||
input: AddressedTimedData<Self::Frame>,
|
||||
) -> AddressedTimedData<LpPacket> {
|
||||
AddressedTimedData::new_addressed(
|
||||
input.data.timestamp,
|
||||
LpPacket::new(LpHeader::new(7, 7, 7), input.data.data),
|
||||
input.dst,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use nym_lp_data::clients::{traits::ClientWrappingPipeline, types::Pipeline};
|
||||
|
||||
use crate::common::{
|
||||
KekwObfuscation, MockChunking, MockLpFraming, MockLpTransport, MockReliability,
|
||||
MockSphinxSecurity,
|
||||
};
|
||||
|
||||
mod common;
|
||||
|
||||
#[test]
|
||||
fn empty_input_yields_empty_output() {
|
||||
let packet_size = 64;
|
||||
let security_layer_nb_frames = 2;
|
||||
|
||||
let mut mock_pipeline = Pipeline {
|
||||
chunking: MockChunking,
|
||||
reliability: MockReliability,
|
||||
security: MockSphinxSecurity {
|
||||
nb_frames: security_layer_nb_frames,
|
||||
},
|
||||
obfuscation: KekwObfuscation,
|
||||
framing: MockLpFraming,
|
||||
transport: MockLpTransport,
|
||||
packet_size,
|
||||
};
|
||||
|
||||
let output = mock_pipeline.process(None, Instant::now());
|
||||
|
||||
assert!(output.is_empty());
|
||||
}
|
||||
|
||||
// TODO More test to come later
|
||||
@@ -25,10 +25,10 @@ nym-crypto = { workspace = true, features = ["hashing"] }
|
||||
nym-common.workspace = true
|
||||
nym-kkt = { workspace = true }
|
||||
nym-kkt-ciphersuite = { workspace = true }
|
||||
nym-lp-data.workspace = true
|
||||
|
||||
# libcrux dependencies for PSQ (Post-Quantum PSK derivation)
|
||||
libcrux-psq = { workspace = true, features = ["test-utils"] }
|
||||
num_enum = { workspace = true }
|
||||
zeroize = { workspace = true, features = ["zeroize_derive"] }
|
||||
|
||||
|
||||
@@ -48,3 +48,6 @@ mock = ["nym-test-utils"]
|
||||
[[bench]]
|
||||
name = "replay_protection"
|
||||
harness = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main};
|
||||
use nym_lp::replay::ReceivingKeyCounterValidator;
|
||||
use nym_test_utils::helpers::deterministic_rng_09;
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use crate::LpError;
|
||||
use crate::packet::{EncryptedLpPacket, InnerHeader, LpFrame, LpHeader, LpPacket};
|
||||
use bytes::BytesMut;
|
||||
use libcrux_psq::Channel;
|
||||
use nym_lp_data::packet::{EncryptedLpPacket, InnerHeader, LpFrame, LpHeader, LpPacket};
|
||||
|
||||
// needs to be equal or above to the actual overhead
|
||||
pub(crate) const SANE_ENC_OVERHEAD: usize = 32;
|
||||
@@ -82,12 +82,12 @@ pub(crate) fn decrypt_lp_packet(
|
||||
mod tests {
|
||||
use crate::LpError;
|
||||
use crate::codec::{decrypt_data, decrypt_lp_packet, encrypt_data, encrypt_lp_packet};
|
||||
use crate::packet::{EncryptedLpPacket, LpFrame, LpHeader, LpPacket};
|
||||
use crate::peer::mock_peers;
|
||||
use crate::psq::initiator::{build_psq_ciphersuite, build_psq_principal};
|
||||
use crate::psq::{PSQ_MSG2_SIZE, psq_msg1_size, responder};
|
||||
use libcrux_psq::{Channel, IntoSession};
|
||||
use nym_kkt_ciphersuite::KEM;
|
||||
use nym_lp_data::packet::{EncryptedLpPacket, LpFrame, LpHeader, LpPacket};
|
||||
use nym_test_utils::helpers::u64_seeded_rng_09;
|
||||
|
||||
fn mock_transport() -> (
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use crate::packet::MalformedLpPacketError;
|
||||
use crate::peer_config::LpReceiverIndex;
|
||||
use crate::replay::ReplayError;
|
||||
use crate::transport::LpTransportError;
|
||||
use libcrux_psq::handshake::HandshakeError;
|
||||
use libcrux_psq::handshake::builders::BuilderError;
|
||||
use libcrux_psq::session::SessionError;
|
||||
use nym_lp_data::packet::MalformedLpPacketError;
|
||||
use nym_lp_data::packet::header::LpReceiverIndex;
|
||||
// use nym_crypto::asymmetric::ed25519::Ed25519RecoveryError;
|
||||
use nym_kkt::error::KKTError;
|
||||
use nym_kkt_ciphersuite::{HashFunction, KEM};
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
pub mod codec;
|
||||
pub mod error;
|
||||
pub mod packet;
|
||||
pub mod peer;
|
||||
pub mod peer_config;
|
||||
pub mod psq;
|
||||
@@ -43,9 +42,13 @@ pub struct SessionsMock {
|
||||
|
||||
#[cfg(any(feature = "mock", test))]
|
||||
impl SessionsMock {
|
||||
// Unwrap in test is fine
|
||||
#![allow(clippy::unwrap_used)]
|
||||
#![allow(clippy::panic)]
|
||||
|
||||
pub fn mock_seeded_post_handshake(seed: u64, kem: KEM) -> SessionsMock {
|
||||
use crate::peer::mock_peers;
|
||||
use crate::peer_config::LpReceiverIndex;
|
||||
use nym_lp_data::packet::header::LpReceiverIndex;
|
||||
use rand09::Rng;
|
||||
|
||||
let (init, resp) = mock_peers();
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
use crate::{LpError, packet::LpPacket, replay::ReceivingKeyCounterValidator};
|
||||
|
||||
pub trait LpPacketReplayExt {
|
||||
/// Validate packet counter against a replay protection validator
|
||||
///
|
||||
/// This performs a quick check to see if the packet counter is valid before
|
||||
/// any expensive processing is done.
|
||||
fn validate_counter(&self, validator: &ReceivingKeyCounterValidator) -> Result<(), LpError>;
|
||||
|
||||
/// Mark packet as received in the replay protection validator
|
||||
///
|
||||
/// This should be called after a packet has been successfully processed.
|
||||
fn mark_received(&self, validator: &mut ReceivingKeyCounterValidator) -> Result<(), LpError>;
|
||||
}
|
||||
|
||||
impl LpPacketReplayExt for LpPacket {
|
||||
/// Validate packet counter against a replay protection validator
|
||||
///
|
||||
/// This performs a quick check to see if the packet counter is valid before
|
||||
/// any expensive processing is done.
|
||||
fn validate_counter(&self, validator: &ReceivingKeyCounterValidator) -> Result<(), LpError> {
|
||||
validator.will_accept_branchless(self.header().outer.counter)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark packet as received in the replay protection validator
|
||||
///
|
||||
/// This should be called after a packet has been successfully processed.
|
||||
fn mark_received(&self, validator: &mut ReceivingKeyCounterValidator) -> Result<(), LpError> {
|
||||
validator.mark_did_receive_branchless(self.header().outer.counter)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,11 @@ use libcrux_psq::handshake::types::Authenticator;
|
||||
|
||||
use nym_crypto::hkdf::blake3::derive_key_blake3_multi_input;
|
||||
use nym_kkt::keys::EncapsulationKey;
|
||||
use nym_lp_data::packet::header::LpReceiverIndex;
|
||||
use rand09::{self, CryptoRng, Rng};
|
||||
use tls_codec::Serialize;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
pub type LpReceiverIndex = u32;
|
||||
|
||||
pub const MAX_HOPS: u8 = 16;
|
||||
pub const LP_PEER_CONFIG_SIZE: usize = 20;
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use crate::packet::version;
|
||||
use crate::peer::{LpLocalPeer, LpRemotePeer};
|
||||
use crate::transport::traits::LpHandshakeChannel;
|
||||
use nym_kkt_ciphersuite::{HashFunction, IntoEnumIterator, KEM, KEMKeyDigests, SignatureScheme};
|
||||
use nym_lp_data::packet::version;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(crate) mod handshake_message;
|
||||
|
||||
@@ -7,9 +7,44 @@
|
||||
//! replay attacks and ensure packet ordering. It uses a bitmap-based
|
||||
//! approach to track received packets and validate their sequence.
|
||||
|
||||
use crate::LpError;
|
||||
use nym_lp_data::packet::LpPacket;
|
||||
|
||||
pub mod error;
|
||||
pub mod simd;
|
||||
pub mod validator;
|
||||
|
||||
pub use error::ReplayError;
|
||||
pub use validator::ReceivingKeyCounterValidator;
|
||||
|
||||
pub trait LpPacketReplayExt {
|
||||
/// Validate packet counter against a replay protection validator
|
||||
///
|
||||
/// This performs a quick check to see if the packet counter is valid before
|
||||
/// any expensive processing is done.
|
||||
fn validate_counter(&self, validator: &ReceivingKeyCounterValidator) -> Result<(), LpError>;
|
||||
|
||||
/// Mark packet as received in the replay protection validator
|
||||
///
|
||||
/// This should be called after a packet has been successfully processed.
|
||||
fn mark_received(&self, validator: &mut ReceivingKeyCounterValidator) -> Result<(), LpError>;
|
||||
}
|
||||
|
||||
impl LpPacketReplayExt for LpPacket {
|
||||
/// Validate packet counter against a replay protection validator
|
||||
///
|
||||
/// This performs a quick check to see if the packet counter is valid before
|
||||
/// any expensive processing is done.
|
||||
fn validate_counter(&self, validator: &ReceivingKeyCounterValidator) -> Result<(), LpError> {
|
||||
validator.will_accept_branchless(self.header().outer.counter)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark packet as received in the replay protection validator
|
||||
///
|
||||
/// This should be called after a packet has been successfully processed.
|
||||
fn mark_received(&self, validator: &mut ReceivingKeyCounterValidator) -> Result<(), LpError> {
|
||||
validator.mark_did_receive_branchless(self.header().outer.counter)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
//! This module implements session management functionality, including replay protection
|
||||
|
||||
use crate::codec::{decrypt_lp_packet, encrypt_lp_packet};
|
||||
use crate::packet::{EncryptedLpPacket, LpFrame, LpHeader, LpPacket};
|
||||
use crate::peer::{LpLocalPeer, LpRemotePeer};
|
||||
use crate::peer_config::LpReceiverIndex;
|
||||
use crate::psq::initiator::HandshakeMode;
|
||||
use crate::psq::{
|
||||
InitiatorData, PSQHandshakeState, PSQHandshakeStateInitiator, PSQHandshakeStateResponder,
|
||||
@@ -21,6 +19,8 @@ use libcrux_psq::handshake::types::{Authenticator, DHPublicKey};
|
||||
use libcrux_psq::session::{Session, SessionBinding};
|
||||
use nym_kkt::keys::EncapsulationKey;
|
||||
use nym_kkt_ciphersuite::{KEM, KEMKeyDigests};
|
||||
use nym_lp_data::packet::header::LpReceiverIndex;
|
||||
use nym_lp_data::packet::{EncryptedLpPacket, LpFrame, LpHeader, LpPacket};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::{Debug, Formatter};
|
||||
|
||||
@@ -355,7 +355,7 @@ impl LpTransportSession {
|
||||
self.receiving_counter_mark(ctr)?;
|
||||
|
||||
// 4. deliver the message
|
||||
Ok(LpAction::DeliverFrame(packet.frame))
|
||||
Ok(LpAction::DeliverFrame(packet.into_frame()))
|
||||
}
|
||||
LpInput::SendFrame(data) => {
|
||||
// Encrypt and send application data
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::packet::{EncryptedLpPacket, LpFrame};
|
||||
use crate::session::{LpAction, LpInput};
|
||||
use crate::{LpError, SessionManager, SessionsMock};
|
||||
use nym_kkt_ciphersuite::{IntoEnumIterator, KEM};
|
||||
use nym_lp_data::packet::{EncryptedLpPacket, LpFrame};
|
||||
|
||||
// helpers to make tests smaller
|
||||
trait ActionExtract {
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
//! This module implements session lifecycle management functionality, handling
|
||||
//! creation, retrieval, and storage of sessions.
|
||||
|
||||
use crate::packet::{EncryptedLpPacket, LpFrame};
|
||||
use crate::peer_config::LpReceiverIndex;
|
||||
use crate::{LpError, LpTransportSession};
|
||||
use nym_lp_data::packet::header::LpReceiverIndex;
|
||||
use nym_lp_data::packet::{EncryptedLpPacket, LpFrame};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub use crate::replay::validator::PacketCount;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use crate::packet::{EncryptedLpPacket, OuterHeader};
|
||||
use crate::transport::error::LpTransportError;
|
||||
use nym_kkt::context::KKTMode;
|
||||
use nym_kkt_ciphersuite::KEM;
|
||||
use nym_lp_data::packet::{EncryptedLpPacket, OuterHeader};
|
||||
use std::net::SocketAddr;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
@@ -13,9 +13,12 @@ readme.workspace = true
|
||||
publish = true
|
||||
|
||||
[dependencies]
|
||||
blake3 = { workspace = true } # short-fingerprint hash for ClientAddress
|
||||
bs58 = { workspace = true } # base58 string form for ClientAddress
|
||||
nym-crypto = { workspace = true, features = ["asymmetric", "sphinx"] } # all addresses are expressed in terms on their crypto keys
|
||||
nym-sphinx-types = { workspace = true, features = ["sphinx"] } # we need to be able to refer to some types defined inside sphinx crate
|
||||
serde = { workspace = true } # implementing serialization/deserialization for some types, like `Recipient`
|
||||
strum = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// of a helper/utils structure, because before it reaches the gateway
|
||||
// it's already destructed).
|
||||
|
||||
use crate::nodes::{NODE_IDENTITY_SIZE, NodeIdentity};
|
||||
use crate::nodes::{NODE_IDENTITY_SIZE, NodeIdentity, NymNodeRoutingAddress};
|
||||
use nym_crypto::asymmetric::{ed25519, x25519};
|
||||
use nym_sphinx_types::Destination;
|
||||
use serde::de::{Error as SerdeError, SeqAccess, Unexpected, Visitor};
|
||||
@@ -21,7 +21,53 @@ const CLIENT_ENCRYPTION_KEY_SIZE: usize = x25519::PUBLIC_KEY_SIZE;
|
||||
pub type ClientIdentity = ed25519::PublicKey;
|
||||
const CLIENT_IDENTITY_SIZE: usize = ed25519::PUBLIC_KEY_LENGTH;
|
||||
|
||||
pub type RecipientBytes = [u8; Recipient::LEN];
|
||||
/// 20-byte fingerprint of a client's identity, used as the routing handle
|
||||
/// that a gateway dispatches over UDP to the corresponding connected client.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct ClientAddress([u8; ClientAddress::LEN]);
|
||||
|
||||
impl ClientAddress {
|
||||
pub const LEN: usize = 20;
|
||||
|
||||
pub fn from_bytes(bytes: [u8; Self::LEN]) -> Self {
|
||||
Self(bytes)
|
||||
}
|
||||
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
self.0.to_vec()
|
||||
}
|
||||
|
||||
pub fn into_bytes(self) -> [u8; Self::LEN] {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Derive the address from a client's ed25519 identity. Hash is BLAKE3 of the
|
||||
/// identity's 32-byte encoding, truncated to 20 bytes.
|
||||
// Eth addressing uses 20 bytes, 2^80 birthday paradox collision is fine for our use case
|
||||
pub fn from_identity(identity: &ClientIdentity) -> Self {
|
||||
let digest = blake3::hash(&identity.to_bytes());
|
||||
let mut out = [0u8; Self::LEN];
|
||||
out.copy_from_slice(&digest.as_bytes()[..Self::LEN]);
|
||||
Self(out)
|
||||
}
|
||||
|
||||
pub fn to_base58_string(&self) -> String {
|
||||
bs58::encode(&self.0).into_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ClientAddress {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.to_base58_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ClientAddress {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
// use the Display implementation
|
||||
<Self as std::fmt::Display>::fmt(self, f)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RecipientFormattingError {
|
||||
@@ -38,12 +84,20 @@ pub enum RecipientFormattingError {
|
||||
MalformedGatewayError(ed25519::Ed25519RecoveryError),
|
||||
}
|
||||
|
||||
pub type RecipientBytes = [u8; Recipient::LEN];
|
||||
|
||||
// TODO: this should a different home... somewhere, but where?
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Recipient {
|
||||
client_identity: ClientIdentity,
|
||||
|
||||
// x25519 key. Either legacy used for e2e encryption of payload, or for the last sphinx layer
|
||||
client_encryption_key: ClientEncryptionKey,
|
||||
|
||||
gateway: NodeIdentity,
|
||||
// Cached blake3 fingerprint of `client_identity`. Not part of the wire format;
|
||||
// recomputed on every constructor so deserialization stays consistent.
|
||||
client_address: ClientAddress,
|
||||
}
|
||||
|
||||
// Serialize + Deserialize is not really used anymore (it was for a CBOR experiment)
|
||||
@@ -143,10 +197,12 @@ impl Recipient {
|
||||
client_encryption_key: ClientEncryptionKey,
|
||||
gateway: NodeIdentity,
|
||||
) -> Self {
|
||||
let client_address = ClientAddress::from_identity(&client_identity);
|
||||
Recipient {
|
||||
client_identity,
|
||||
client_encryption_key,
|
||||
gateway,
|
||||
client_address,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +218,10 @@ impl Recipient {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn as_sphinx_hop(&self) -> NymNodeRoutingAddress {
|
||||
NymNodeRoutingAddress::Client(self.client_address)
|
||||
}
|
||||
|
||||
pub fn identity(&self) -> &ClientIdentity {
|
||||
&self.client_identity
|
||||
}
|
||||
@@ -174,6 +234,10 @@ impl Recipient {
|
||||
self.gateway
|
||||
}
|
||||
|
||||
pub fn client_address(&self) -> &ClientAddress {
|
||||
&self.client_address
|
||||
}
|
||||
|
||||
pub fn to_bytes(self) -> RecipientBytes {
|
||||
let mut out = [0u8; Self::LEN];
|
||||
out[..CLIENT_IDENTITY_SIZE].copy_from_slice(&self.client_identity.to_bytes());
|
||||
@@ -203,11 +267,11 @@ impl Recipient {
|
||||
Err(err) => return Err(RecipientFormattingError::MalformedGatewayError(err)),
|
||||
};
|
||||
|
||||
Ok(Recipient {
|
||||
Ok(Recipient::new(
|
||||
client_identity,
|
||||
client_encryption_key,
|
||||
gateway,
|
||||
})
|
||||
))
|
||||
}
|
||||
|
||||
pub fn try_from_base58_string<S: Into<String>>(
|
||||
@@ -244,11 +308,11 @@ impl Recipient {
|
||||
Err(err) => return Err(RecipientFormattingError::MalformedGatewayError(err)),
|
||||
};
|
||||
|
||||
Ok(Recipient {
|
||||
Ok(Recipient::new(
|
||||
client_identity,
|
||||
client_encryption_key,
|
||||
gateway,
|
||||
})
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,4 +459,15 @@ mod tests {
|
||||
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_address_is_deterministic_blake3_truncated() {
|
||||
let recipient = mock_recipient();
|
||||
let recomputed = ClientAddress::from_identity(&recipient.client_identity);
|
||||
assert_eq!(recipient.client_address(), &recomputed);
|
||||
|
||||
// also verify cached field survives the bytes round-trip
|
||||
let roundtripped = Recipient::try_from_bytes(recipient.to_bytes()).unwrap();
|
||||
assert_eq!(roundtripped.client_address(), recipient.client_address());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
pub mod clients;
|
||||
pub mod nodes;
|
||||
|
||||
pub use clients::Recipient;
|
||||
pub use clients::{ClientAddress, Recipient};
|
||||
pub use nodes::NodeIdentity;
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
//!
|
||||
//! This module is responsible for encoding and decoding node routing information, so that
|
||||
//! they could be later put into an appropriate field in a sphinx header.
|
||||
//! Currently, that routing information is an IP address, but in principle it can be anything
|
||||
//! for as long as it's going to fit in the field.
|
||||
//! A routing address is either a `SocketAddr` (mix node / gateway socket) or a `ClientAddress`
|
||||
//! (a 20-byte fingerprint or a client's identity key.
|
||||
|
||||
use crate::clients::ClientAddress;
|
||||
use nym_crypto::asymmetric::ed25519;
|
||||
use nym_sphinx_types::{NODE_ADDRESS_LENGTH, NodeAddressBytes};
|
||||
|
||||
@@ -20,7 +21,9 @@ pub type NodeIdentity = ed25519::PublicKey;
|
||||
pub const NODE_IDENTITY_SIZE: usize = ed25519::PUBLIC_KEY_LENGTH;
|
||||
|
||||
/// MAX_UNPADDED_LEN represents maximum length an unpadded address could have.
|
||||
/// In this case it's an ipv6 socket address (with version prefix)
|
||||
/// Reserved for ACK-SURB hop overhead calculations, which only ever target mix nodes,
|
||||
/// so the cap is the IPv6 socket variant (1 + 2 + 16 = 19 bytes).
|
||||
// SW Double check that
|
||||
pub const MAX_NODE_ADDRESS_UNPADDED_LEN: usize = 19;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
@@ -29,18 +32,16 @@ pub enum NymNodeRoutingAddressError {
|
||||
NoBytesProvided,
|
||||
|
||||
#[error(
|
||||
"Provided insufficient amount of few bytes to deserialize a valid NymNodeRoutingAddress for IPv{protocol_version} variant. Received {received} and required {required}"
|
||||
"Provided insufficient amount of few bytes to deserialize a valid NymNodeRoutingAddress for type {address_type}. Received {received} and required {required}"
|
||||
)]
|
||||
TooFewBytesProvided {
|
||||
protocol_version: u8,
|
||||
address_type: AddressType,
|
||||
received: usize,
|
||||
required: usize,
|
||||
},
|
||||
|
||||
#[error(
|
||||
"{received} is not a valid version of the Internet Protocol (IP). Expected either '4' or '6'"
|
||||
)]
|
||||
InvalidIpVersion { received: u8 },
|
||||
#[error("{received:#x} is not a valid NymNodeRoutingAddress address type")]
|
||||
InvalidAddressType { received: u8 },
|
||||
|
||||
#[error(
|
||||
"Could not serialize NymNodeRoutingAddress into NodeAddressBytes as that requires using at least {required} bytes and only {NODE_ADDRESS_LENGTH} are available"
|
||||
@@ -48,14 +49,60 @@ pub enum NymNodeRoutingAddressError {
|
||||
TooSmallBytesRepresentation { required: usize },
|
||||
}
|
||||
|
||||
/// Current representation of Node routing information used in Nym system.
|
||||
/// At this point of time it is a simple `SocketAddr`.
|
||||
/// On-wire variant tag of a [`NymNodeRoutingAddress`]. Always the first byte
|
||||
/// of the encoded routing field.
|
||||
#[repr(u8)]
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, strum::Display)]
|
||||
pub enum AddressType {
|
||||
Ipv4 = 4,
|
||||
Ipv6 = 6,
|
||||
Client = 12,
|
||||
}
|
||||
|
||||
impl AddressType {
|
||||
pub fn as_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
|
||||
/// Number of bytes needed to represent the address type.
|
||||
pub fn bytes_len(&self) -> usize {
|
||||
match self {
|
||||
AddressType::Ipv4 => 1 + 4 + 2, // marker, address, port
|
||||
AddressType::Ipv6 => 1 + 16 + 2, // marker, address, port
|
||||
AddressType::Client => 1 + ClientAddress::LEN, // marker, fingerprint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for AddressType {
|
||||
type Error = NymNodeRoutingAddressError;
|
||||
|
||||
fn try_from(b: u8) -> Result<Self, Self::Error> {
|
||||
match b {
|
||||
x if x == AddressType::Ipv4 as u8 => Ok(AddressType::Ipv4),
|
||||
x if x == AddressType::Ipv6 as u8 => Ok(AddressType::Ipv6),
|
||||
x if x == AddressType::Client as u8 => Ok(AddressType::Client),
|
||||
v => Err(NymNodeRoutingAddressError::InvalidAddressType { received: v }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Routing information that can appear in a sphinx hop's address field.
|
||||
///
|
||||
/// `Node` carries an inter-node socket address (mix node or gateway egress).
|
||||
/// `Client` carries a 20-byte fingerprint of the destination client's identity
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
|
||||
pub struct NymNodeRoutingAddress(SocketAddr);
|
||||
pub enum NymNodeRoutingAddress {
|
||||
Node(SocketAddr),
|
||||
Client(ClientAddress),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for NymNodeRoutingAddress {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.fmt(f)
|
||||
match self {
|
||||
NymNodeRoutingAddress::Node(addr) => addr.fmt(f),
|
||||
NymNodeRoutingAddress::Client(ca) => ca.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,26 +111,29 @@ impl NymNodeRoutingAddress {
|
||||
/// The value has no upper bound as when converted into bytes, it's always
|
||||
/// padded with zeroes to be exactly NODE_ADDRESS_LENGTH long.
|
||||
pub fn bytes_min_len(&self) -> usize {
|
||||
match self.0 {
|
||||
SocketAddr::V4(_) => 7,
|
||||
SocketAddr::V6(_) => 19,
|
||||
}
|
||||
self.addr_type().bytes_len()
|
||||
}
|
||||
|
||||
/// Converts self into a vector of bytes.
|
||||
/// Note, this represents a generic bytes vector, not necessarily a NodeAddressBytes
|
||||
/// and hence is not zero-padded.
|
||||
pub fn as_bytes(&self) -> Vec<u8> {
|
||||
let port_bytes = self.0.port().to_be_bytes();
|
||||
let ip_octets_vec = match self.0.ip() {
|
||||
IpAddr::V4(ip) => ip.octets().to_vec(),
|
||||
IpAddr::V6(ip) => ip.octets().to_vec(),
|
||||
};
|
||||
|
||||
std::iter::once(self.addr_type_as_u8())
|
||||
.chain(port_bytes.iter().cloned())
|
||||
.chain(ip_octets_vec.iter().cloned())
|
||||
.collect()
|
||||
match self {
|
||||
NymNodeRoutingAddress::Node(socket) => {
|
||||
let port_bytes = socket.port().to_be_bytes();
|
||||
let ip_octets_vec = match socket.ip() {
|
||||
IpAddr::V4(ip) => ip.octets().to_vec(),
|
||||
IpAddr::V6(ip) => ip.octets().to_vec(),
|
||||
};
|
||||
std::iter::once(self.addr_type().as_u8())
|
||||
.chain(port_bytes.iter().cloned())
|
||||
.chain(ip_octets_vec.iter().cloned())
|
||||
.collect()
|
||||
}
|
||||
NymNodeRoutingAddress::Client(address) => std::iter::once(self.addr_type().as_u8())
|
||||
.chain(address.to_bytes())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts self into a vector of bytes optionally padded with zeroes to the `expected_len`.
|
||||
@@ -110,80 +160,68 @@ impl NymNodeRoutingAddress {
|
||||
return Err(NymNodeRoutingAddressError::NoBytesProvided);
|
||||
}
|
||||
|
||||
let ip_version = b[0];
|
||||
let ip = match ip_version {
|
||||
4 => {
|
||||
if b.len() < 7 {
|
||||
return Err(NymNodeRoutingAddressError::TooFewBytesProvided {
|
||||
protocol_version: 4,
|
||||
received: b.len(),
|
||||
required: 7,
|
||||
});
|
||||
}
|
||||
IpAddr::V4(Ipv4Addr::new(b[3], b[4], b[5], b[6]))
|
||||
let address_type = AddressType::try_from(b[0])?;
|
||||
if b.len() < address_type.bytes_len() {
|
||||
return Err(NymNodeRoutingAddressError::TooFewBytesProvided {
|
||||
address_type,
|
||||
received: b.len(),
|
||||
required: address_type.bytes_len(),
|
||||
});
|
||||
}
|
||||
|
||||
match address_type {
|
||||
AddressType::Ipv4 => {
|
||||
let port = u16::from_be_bytes([b[1], b[2]]);
|
||||
let ip = IpAddr::V4(Ipv4Addr::new(b[3], b[4], b[5], b[6]));
|
||||
Ok(NymNodeRoutingAddress::Node(SocketAddr::new(ip, port)))
|
||||
}
|
||||
6 => {
|
||||
if b.len() < 19 {
|
||||
return Err(NymNodeRoutingAddressError::TooFewBytesProvided {
|
||||
protocol_version: 6,
|
||||
received: b.len(),
|
||||
required: 19,
|
||||
});
|
||||
}
|
||||
AddressType::Ipv6 => {
|
||||
let port = u16::from_be_bytes([b[1], b[2]]);
|
||||
let mut address_octets = [0u8; 16];
|
||||
address_octets.copy_from_slice(&b[3..19]);
|
||||
IpAddr::V6(Ipv6Addr::from(address_octets))
|
||||
let ip = IpAddr::V6(Ipv6Addr::from(address_octets));
|
||||
Ok(NymNodeRoutingAddress::Node(SocketAddr::new(ip, port)))
|
||||
}
|
||||
v => return Err(NymNodeRoutingAddressError::InvalidIpVersion { received: v }),
|
||||
};
|
||||
|
||||
let port: u16 = u16::from_be_bytes([b[1], b[2]]);
|
||||
|
||||
Ok(Self(SocketAddr::new(ip, port)))
|
||||
AddressType::Client => {
|
||||
let mut address_bytes = [0u8; ClientAddress::LEN];
|
||||
address_bytes.copy_from_slice(&b[1..ClientAddress::LEN + 1]);
|
||||
Ok(NymNodeRoutingAddress::Client(ClientAddress::from_bytes(
|
||||
address_bytes,
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Single byte representation of self ip version.
|
||||
pub fn addr_type_as_u8(&self) -> u8 {
|
||||
match self.0 {
|
||||
SocketAddr::V4(_) => 4,
|
||||
SocketAddr::V6(_) => 6,
|
||||
/// Variant tag of this routing address.
|
||||
pub fn addr_type(&self) -> AddressType {
|
||||
match self {
|
||||
NymNodeRoutingAddress::Node(SocketAddr::V4(_)) => AddressType::Ipv4,
|
||||
NymNodeRoutingAddress::Node(SocketAddr::V6(_)) => AddressType::Ipv6,
|
||||
NymNodeRoutingAddress::Client(_) => AddressType::Client,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Considering `NymNodeRoutingAddress` is equivalent to a `SocketAddr` at this point,
|
||||
/// it makes perfect sense to allow the bilateral transformation.
|
||||
impl From<SocketAddr> for NymNodeRoutingAddress {
|
||||
fn from(addr: SocketAddr) -> Self {
|
||||
Self(addr)
|
||||
NymNodeRoutingAddress::Node(addr)
|
||||
}
|
||||
}
|
||||
|
||||
/// Considering `NymNodeRoutingAddress` is equivalent to a `SocketAddr` at this point,
|
||||
/// it makes perfect sense to allow the bilateral transformation.
|
||||
impl From<NymNodeRoutingAddress> for SocketAddr {
|
||||
fn from(addr: NymNodeRoutingAddress) -> Self {
|
||||
addr.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<SocketAddr> for NymNodeRoutingAddress {
|
||||
fn as_ref(&self) -> &SocketAddr {
|
||||
&self.0
|
||||
impl From<ClientAddress> for NymNodeRoutingAddress {
|
||||
fn from(addr: ClientAddress) -> Self {
|
||||
NymNodeRoutingAddress::Client(addr)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryInto<NodeAddressBytes> for NymNodeRoutingAddress {
|
||||
type Error = NymNodeRoutingAddressError;
|
||||
|
||||
/// `NymNodeRoutingAddress` (as a `SocketAddr`) is represented the following way:
|
||||
/// VersionFlag || port || octets || zeropad
|
||||
/// VersionFlag is one byte representing whether self is ipv4 or ipv6 address,
|
||||
/// port is 16bit big endian representation of port value
|
||||
/// octets is bytes representation of octets making up the ip address of the socket address
|
||||
/// (either 4 bytes for ipv4 or 16 bytes for ipv6)
|
||||
/// zeropad is padding of 0 for the `NymNodeRoutingAddress` to be
|
||||
/// exactly `NODE_ADDRESS_LENGTH` long.
|
||||
/// On-wire encoding of a `NymNodeRoutingAddress` as a fixed-size sphinx routing field.
|
||||
/// VARIANT_TAG || payload || zeropad
|
||||
/// - 0x04: IPv4 socket — payload is `port (2) || octets (4)`
|
||||
/// - 0x06: IPv6 socket — payload is `port (2) || octets (16)`
|
||||
/// - 0x0C: client fingerprint — payload is `client_address (20)`
|
||||
fn try_into(self) -> Result<NodeAddressBytes, Self::Error> {
|
||||
// first check if we have enough bytes to represent `self`:
|
||||
if self.bytes_min_len() > NODE_ADDRESS_LENGTH {
|
||||
@@ -213,9 +251,24 @@ impl TryFrom<NodeAddressBytes> for NymNodeRoutingAddress {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn v4() -> NymNodeRoutingAddress {
|
||||
NymNodeRoutingAddress::Node(SocketAddr::new(IpAddr::from([1, 2, 3, 4]), 42))
|
||||
}
|
||||
|
||||
fn v6() -> NymNodeRoutingAddress {
|
||||
NymNodeRoutingAddress::Node(SocketAddr::new(
|
||||
IpAddr::from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]),
|
||||
42,
|
||||
))
|
||||
}
|
||||
|
||||
fn client() -> NymNodeRoutingAddress {
|
||||
NymNodeRoutingAddress::Client(ClientAddress::from_bytes([7u8; ClientAddress::LEN]))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nym_node_routing_address_can_be_converted_to_and_from_bytes_for_v4_address() {
|
||||
let address = NymNodeRoutingAddress(SocketAddr::new(IpAddr::from([1, 2, 3, 4]), 42));
|
||||
let address = v4();
|
||||
let address_bytes = address.as_bytes();
|
||||
assert_eq!(
|
||||
address,
|
||||
@@ -225,10 +278,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn nym_node_routing_address_can_be_converted_to_and_from_bytes_for_v6_address() {
|
||||
let address = NymNodeRoutingAddress(SocketAddr::new(
|
||||
IpAddr::from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]),
|
||||
42,
|
||||
));
|
||||
let address = v6();
|
||||
let address_bytes = address.as_bytes();
|
||||
assert_eq!(
|
||||
address,
|
||||
NymNodeRoutingAddress::try_from_bytes(&address_bytes).unwrap()
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nym_node_routing_address_can_be_converted_to_and_from_bytes_for_client_address() {
|
||||
let address = client();
|
||||
let address_bytes = address.as_bytes();
|
||||
assert_eq!(
|
||||
address,
|
||||
@@ -238,7 +298,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn nym_node_routing_address_can_be_converted_to_and_from_bytes_for_empty_v4_address() {
|
||||
let address = NymNodeRoutingAddress(SocketAddr::new(IpAddr::from([0, 0, 0, 0]), 42));
|
||||
let address = NymNodeRoutingAddress::Node(SocketAddr::new(IpAddr::from([0, 0, 0, 0]), 42));
|
||||
let address_bytes = address.as_bytes();
|
||||
assert_eq!(
|
||||
address,
|
||||
@@ -248,7 +308,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn nym_node_routing_address_can_be_converted_to_and_from_bytes_for_empty_v6_address() {
|
||||
let address = NymNodeRoutingAddress(SocketAddr::new(
|
||||
let address = NymNodeRoutingAddress::Node(SocketAddr::new(
|
||||
IpAddr::from([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
42,
|
||||
));
|
||||
@@ -262,16 +322,18 @@ mod tests {
|
||||
#[test]
|
||||
fn nym_node_routing_address_can_be_converted_to_and_from_node_address_bytes_with_no_data_loss()
|
||||
{
|
||||
let address_v4 = NymNodeRoutingAddress(SocketAddr::new(IpAddr::from([1, 2, 3, 4]), 42));
|
||||
let address_v6 = NymNodeRoutingAddress(SocketAddr::new(
|
||||
IpAddr::from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]),
|
||||
42,
|
||||
for address in [v4(), v6(), client()] {
|
||||
let node_address: NodeAddressBytes = address.try_into().unwrap();
|
||||
assert_eq!(address, node_address.try_into().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_from_bytes_rejects_unknown_variant_tag() {
|
||||
let bytes = [0xFFu8, 0, 0, 0, 0, 0, 0];
|
||||
assert!(matches!(
|
||||
NymNodeRoutingAddress::try_from_bytes(&bytes),
|
||||
Err(NymNodeRoutingAddressError::InvalidAddressType { received: 0xFF })
|
||||
));
|
||||
|
||||
let node_address1: NodeAddressBytes = address_v4.try_into().unwrap();
|
||||
let node_address2: NodeAddressBytes = address_v6.try_into().unwrap();
|
||||
|
||||
assert_eq!(address_v4, node_address1.try_into().unwrap());
|
||||
assert_eq!(address_v6, node_address2.try_into().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ use nym_sphinx_anonymous_replies::reply_surb::AppliedReplySurb;
|
||||
use nym_sphinx_params::key_rotation::InvalidSphinxKeyRotation;
|
||||
use nym_sphinx_params::packet_sizes::InvalidPacketSize;
|
||||
use nym_sphinx_params::packet_types::InvalidPacketType;
|
||||
use std::net::SocketAddr;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
@@ -81,10 +80,6 @@ impl MixPacket {
|
||||
self.next_hop
|
||||
}
|
||||
|
||||
pub fn next_hop_address(&self) -> SocketAddr {
|
||||
self.next_hop.into()
|
||||
}
|
||||
|
||||
pub fn packet(&self) -> &NymPacket {
|
||||
&self.packet
|
||||
}
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ nym-id = { workspace = true }
|
||||
nym-service-provider-requests-common = { workspace = true }
|
||||
nym-registration-common = { path = "../common/registration" }
|
||||
|
||||
nym-lp = { path = "../common/nym-lp" }
|
||||
nym-lp-data.workspace = true
|
||||
|
||||
defguard_wireguard_rs = { workspace = true }
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::node::wireguard::new_peer_registration::pending::{
|
||||
use crate::node::wireguard::{GatewayWireguardError, PeerRegistrator};
|
||||
use defguard_wireguard_rs::host::Peer;
|
||||
use defguard_wireguard_rs::key::Key;
|
||||
use nym_lp::peer_config::LpReceiverIndex;
|
||||
use nym_lp_data::packet::header::LpReceiverIndex;
|
||||
use nym_registration_common::{LpRegistrationResponse, WireguardRegistrationData};
|
||||
use nym_wireguard::ip_pool::{allocated_ip_pair, IpPair};
|
||||
use nym_wireguard_types::PeerPublicKey;
|
||||
|
||||
@@ -31,7 +31,7 @@ use nym_credentials_interface::{BandwidthCredential, CredentialSpendingData};
|
||||
use nym_crypto::asymmetric::x25519;
|
||||
use nym_gateway_requests::models::CredentialSpendingRequest;
|
||||
use nym_gateway_storage::models::PersistedBandwidth;
|
||||
use nym_lp::peer_config::LpReceiverIndex;
|
||||
use nym_lp_data::packet::header::LpReceiverIndex;
|
||||
use nym_node_metrics::prometheus_wrapper::{PrometheusMetric, PROMETHEUS_METRICS};
|
||||
use nym_registration_common::dvpn::{
|
||||
LpDvpnRegistrationFinalisation, LpDvpnRegistrationInitialRequest,
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::node::wireguard::GatewayWireguardError;
|
||||
use defguard_wireguard_rs::key::Key;
|
||||
use nym_authenticator_requests::AuthenticatorVersion;
|
||||
use nym_crypto::asymmetric::x25519;
|
||||
use nym_lp::peer_config::LpReceiverIndex;
|
||||
use nym_lp_data::packet::header::LpReceiverIndex;
|
||||
use nym_registration_common::{LpRegistrationResponse, WireguardRegistrationData};
|
||||
use nym_sdk::mixnet::Recipient;
|
||||
use nym_wireguard::ip_pool::IpPair;
|
||||
|
||||
@@ -60,6 +60,7 @@ nym-ip-packet-client = { workspace = true }
|
||||
nym-ip-packet-requests = { workspace = true }
|
||||
nym-kkt-ciphersuite = { workspace = true }
|
||||
nym-lp = { path = "../common/nym-lp" }
|
||||
nym-lp-data.workspace = true
|
||||
nym-network-defaults = { path = "../common/network-defaults" }
|
||||
nym-node-requests = { path = "../nym-node/nym-node-requests" }
|
||||
nym-registration-client = { path = "../nym-registration-client" }
|
||||
|
||||
@@ -12,8 +12,8 @@ use nym_bin_common::build_information::BinaryBuildInformationOwned;
|
||||
use nym_http_api_client::UserAgent;
|
||||
use nym_kkt_ciphersuite::Ciphersuite;
|
||||
use nym_kkt_ciphersuite::{KEM, KEMKeyDigests};
|
||||
use nym_lp::packet::version;
|
||||
use nym_lp::peer::{DHPublicKey, LpRemotePeer};
|
||||
use nym_lp_data::packet::version;
|
||||
use nym_network_defaults::DEFAULT_NYM_NODE_HTTP_PORT;
|
||||
use nym_node_requests::api::client::NymNodeApiClientExt;
|
||||
use nym_node_requests::api::v1::node::models::AuxiliaryDetails as NodeAuxiliaryDetails;
|
||||
|
||||
@@ -26,4 +26,4 @@ tracing.workspace = true
|
||||
|
||||
nym-sdk = { workspace = true }
|
||||
nym-ip-packet-requests = { workspace = true }
|
||||
nym-lp = { workspace = true }
|
||||
nym-lp-data = { workspace = true }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use bytes::BytesMut;
|
||||
use nym_ip_packet_requests::SPHINX_STREAM_VERSION_THRESHOLD;
|
||||
use nym_lp::packet::frame::{
|
||||
use nym_lp_data::packet::frame::{
|
||||
LpFrame, LpFrameHeader, LpFrameKind, SphinxStreamFrameAttributes, SphinxStreamMsgType,
|
||||
};
|
||||
use nym_sdk::mixnet::ReconstructedMessage;
|
||||
@@ -65,7 +65,7 @@ pub fn encode_stream_frame(stream_id: u64, sequence_num: u32, payload: Vec<u8>)
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nym_lp::packet::frame::SphinxStreamFrameAttributes;
|
||||
use nym_lp_data::packet::frame::SphinxStreamFrameAttributes;
|
||||
|
||||
#[test]
|
||||
fn stream_frame_roundtrip_unwraps_payload() {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
[package]
|
||||
name = "nym-mix-sim"
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
documentation.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "mix-client"
|
||||
path = "src/bin/mix_client.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "nym-mix-sim"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
rand.workspace = true
|
||||
rand_distr.workspace = true
|
||||
tokio = { workspace = true, features = [
|
||||
"rt-multi-thread",
|
||||
"macros",
|
||||
"time",
|
||||
"signal",
|
||||
] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
strum = { workspace = true, features = ["derive"] }
|
||||
uuid = { workspace = true, features = ["v4", "serde"] }
|
||||
|
||||
nym-bin-common = { workspace = true, features = ["basic_tracing"] }
|
||||
nym-common.workspace = true
|
||||
nym-lp-data.workspace = true
|
||||
nym-node = { path = "../nym-node", features = ["mix-sim"] }
|
||||
nym-sphinx.workspace = true
|
||||
nym-sphinx-addressing.workspace = true
|
||||
nym-sphinx-params.workspace = true
|
||||
nym-topology.workspace = true
|
||||
nym-crypto = { workspace = true, features = ["serde", "rand"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,162 @@
|
||||
# nym-mix-sim
|
||||
|
||||
A discrete-time simulator for the Nym mixnet, intended for local testing and experimentation. It models a small network of mix nodes and clients exchanging UDP packets on localhost, allowing you to observe packet flow, experiment with different drivers, and debug routing behaviour step by step.
|
||||
|
||||
## Overview
|
||||
|
||||
The simulator runs a configurable number of mix nodes and clients on localhost, each bound to its own UDP port. Time advances in **ticks** — each tick runs the client phase, then drains incoming sockets, processes packets through the mixing pipeline, and dispatches outgoing packets.
|
||||
|
||||
Two binaries are provided:
|
||||
|
||||
| Binary | Purpose |
|
||||
|--------|---------|
|
||||
| `nym-mix-sim` | Main simulator: topology generation and tick-loop execution |
|
||||
| `mix-client` | Standalone tool to inject messages into a running simulation |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Generate a topology with 6 nodes and 2 clients
|
||||
cargo run --bin nym-mix-sim -- init-topology
|
||||
|
||||
# 2. Run the simulation (automatic mode, 1ms ticks, default discrete-sphinx driver)
|
||||
cargo run --bin nym-mix-sim -- run
|
||||
|
||||
# 3. In a separate terminal, send a message between the two clients
|
||||
cargo run --bin mix-client -- --src 6 --dst 7
|
||||
# Then type a message and press ENTER
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### `init-topology`
|
||||
|
||||
Generates a `topology.json` file describing nodes and clients.
|
||||
|
||||
```bash
|
||||
cargo run --bin nym-mix-sim -- init-topology [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `--nodes <N>` | `6` | Number of mix nodes |
|
||||
| `--clients <N>` | `2` | Number of clients |
|
||||
| `--output <PATH>` | `topology.json` | Output file path |
|
||||
|
||||
Nodes are assigned sequential ports starting at `127.0.0.1:9000`. Clients get two sockets each: a mix-facing socket starting at `127.0.0.1:9500` and an app-facing socket starting at `127.0.0.1:9600`. Each node gets a freshly generated X25519 key pair (used by Sphinx drivers).
|
||||
|
||||
### `run`
|
||||
|
||||
Starts the simulation loop.
|
||||
|
||||
```bash
|
||||
cargo run --bin nym-mix-sim -- run [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `--topology <PATH>` | `topology.json` | Topology file to load |
|
||||
| `--driver <DRIVER>` | `discrete-sphinx` | Simulation driver (see below) |
|
||||
| `--tick-duration-ms <MS>` | `1` | Milliseconds between automatic ticks |
|
||||
| `--manual` | off | Enable manual stepping mode (ENTER per tick) |
|
||||
| `--no-display-state` | off | Suppress per-phase state dump in manual mode |
|
||||
|
||||
### `mix-client`
|
||||
|
||||
Injects messages into a running simulation from stdin.
|
||||
|
||||
```bash
|
||||
cargo run --bin mix-client -- --src <ID> --dst <ID> [--topology <PATH>]
|
||||
```
|
||||
|
||||
Reads lines from stdin and sends each as a payload routed from client `--src` through the mix network to client `--dst`. Client IDs begin where node IDs end (e.g., with 6 nodes and 2 clients, client IDs are `6` and `7`).
|
||||
|
||||
## Drivers
|
||||
|
||||
The driver controls how packets are formatted, encrypted, and routed.
|
||||
|
||||
| Driver | Timestamp | Encryption | Cover traffic | Reliability | Manual mode |
|
||||
|--------|-----------|------------|---------------|-------------|-------------|
|
||||
| `simple` | Discrete (u32 tick counter) | None | No | No | Yes |
|
||||
| `sphinx` | Wall-clock (`Instant`) | Full Sphinx | Yes (Poisson) | SURB ACKs | No |
|
||||
| `discrete-sphinx` | Discrete (u32 tick counter) | Full Sphinx | Yes (Poisson) | SURB ACKs | Yes |
|
||||
|
||||
**`simple`** — Each packet is a fixed 64-byte frame (16-byte UUID + 48-byte payload). Nodes forward to `node_id + 1`. No cryptography. Best for sanity-checking the topology and observing raw packet flow.
|
||||
|
||||
**`sphinx`** — Uses `nym_sphinx::SphinxPacket` for full onion encryption. Clients build a 3-hop route, generate a SURB ACK reliability layer, and run two Poisson cover-traffic loops. Per-hop delays are extracted from the decrypted packet and scheduled using real wall-clock time. Automatic mode only.
|
||||
|
||||
**`discrete-sphinx`** — Same Sphinx encryption, SURB ACKs, and cover traffic as `sphinx`, but uses a u32 tick counter instead of wall-clock time (1 tick = 1 ms). This makes timing deterministic and compatible with `--manual` mode. Default driver.
|
||||
|
||||
## Tick Mechanics
|
||||
|
||||
Each tick runs four phases across all participants:
|
||||
|
||||
1. **Clients** — every client drains its app socket, runs new payloads through the wrapping pipeline, processes any inbound mix packets, and forwards queued packets whose scheduled timestamp is due.
|
||||
2. **Nodes — incoming** — every node drains its UDP socket (non-blocking) and buffers received packets.
|
||||
3. **Nodes — processing** — buffered packets pass through the mixing pipeline. For Sphinx nodes, this means decryption and routing extraction. Each processed packet is queued with a scheduled dispatch timestamp.
|
||||
4. **Nodes — outgoing** — packets whose timestamp ≤ current tick are serialised and sent via UDP to the next hop.
|
||||
|
||||
In manual mode, the node state is pretty-printed between phases 2 and 3, and again between 3 and 4 (unless `--no-display-state` is set).
|
||||
|
||||
## Speed Controls
|
||||
|
||||
**Tick duration** (`--tick-duration-ms`) controls how fast the simulation runs:
|
||||
|
||||
- `0` — maximum speed, no sleep between ticks
|
||||
- `1` (default) — roughly real-time for discrete drivers
|
||||
- Any value `N > 1` — slows the simulation down linearly; in practice a value of `N` will make the simulation `N` times slower than real time
|
||||
|
||||
**Manual mode** (`--manual`) pauses after every tick and waits for ENTER. Completely deterministic — no timing overhead, step through packet sequences one tick at a time. Only available with the `simple` and `discrete-sphinx` drivers.
|
||||
|
||||
**Discrete vs wall-clock timestamps** — Discrete (u32) timestamps have minimal overhead and allow the simulation to run faster than real time. Wall-clock (`Instant`) timestamps tie delays to real elapsed time, which is more realistic but limits simulation speed.
|
||||
|
||||
## Topology File
|
||||
|
||||
`topology.json` is generated by `init-topology` and consumed by `run` and `mix-client`.
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"node_id": 0,
|
||||
"socket_address": "127.0.0.1:9000",
|
||||
"reliability": 100,
|
||||
"sphinx_private_key": "<bs58-encoded X25519 key>"
|
||||
}
|
||||
],
|
||||
"clients": [
|
||||
{
|
||||
"client_id": 6,
|
||||
"mixnet_address": "127.0.0.1:9506",
|
||||
"app_address": "127.0.0.1:9606"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `reliability` field is reserved for future use.
|
||||
|
||||
## Logging
|
||||
|
||||
Set `RUST_LOG` to control verbosity:
|
||||
|
||||
```bash
|
||||
RUST_LOG=debug cargo run --bin nym-mix-sim -- run
|
||||
RUST_LOG=warn cargo run --bin nym-mix-sim -- run # quiet
|
||||
```
|
||||
|
||||
Default level is `info`. Logs go to stderr; received message content goes to stdout.
|
||||
|
||||
## Example: Manual Sphinx Walk-Through
|
||||
|
||||
```bash
|
||||
# Terminal 1 — run in manual mode, one tick at a time
|
||||
cargo run --bin nym-mix-sim -- run --driver discrete-sphinx --manual
|
||||
|
||||
# Terminal 2 — send a message from client 6 to client 7
|
||||
cargo run --bin mix-client -- --src 6 --dst 7
|
||||
> hello
|
||||
|
||||
# Back in Terminal 1, press ENTER to advance each tick and observe
|
||||
# the encrypted packet hop through each node
|
||||
```
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Standalone client CLI — inject packets into a running mix-sim.
|
||||
//!
|
||||
//! Reads lines from stdin and, on each ENTER, sends the text as a raw payload
|
||||
//! to the app socket of the running client identified by `--src`. The client
|
||||
//! wraps it into the active wire format (e.g. a `SimplePacket` for the simple
|
||||
//! driver, an onion-encrypted Sphinx packet for the Sphinx drivers) and forwards
|
||||
//! it through the mix network to the destination client `--dst`.
|
||||
//!
|
||||
//! ## Message format (app-socket datagram)
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌────────────────────────┬─────────────────────┐
|
||||
//! │ dst_client_id (1 B) │ raw payload bytes │
|
||||
//! └────────────────────────┴─────────────────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! The running client's `tick_app_incoming` parses this datagram on the next tick.
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run --bin mix-client -- --topology topology.json --src 6 --dst 7
|
||||
//! ```
|
||||
|
||||
use std::net::UdpSocket;
|
||||
|
||||
use clap::Parser;
|
||||
use nym_mix_sim::{client::ClientId, topology::Topology};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "mix-client",
|
||||
about = "Send stdin lines into a running nym-mix-sim"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Path to the topology.json file.
|
||||
#[arg(short, long, default_value = "topology.json")]
|
||||
topology: String,
|
||||
|
||||
/// ID of the client (in the topology) to deliver packets through.
|
||||
#[arg(short, long)]
|
||||
src: ClientId,
|
||||
|
||||
/// ID of the destination client packets should be routed toward.
|
||||
#[arg(short, long)]
|
||||
dst: ClientId,
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
nym_bin_common::logging::setup_tracing_logger();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
let topology_data = std::fs::read_to_string(&cli.topology)?;
|
||||
let topology: Topology = serde_json::from_str(&topology_data)?;
|
||||
|
||||
let client = topology
|
||||
.clients
|
||||
.iter()
|
||||
.find(|c| c.client_id == cli.src)
|
||||
.ok_or_else(|| anyhow::anyhow!("no client with id {}", cli.src))?;
|
||||
|
||||
let app_addr = client.app_address;
|
||||
|
||||
// Bind an ephemeral socket to send from.
|
||||
let socket = UdpSocket::bind("127.0.0.1:0")?;
|
||||
|
||||
println!(
|
||||
"Ready — type a message and press ENTER to send to client {} via client {}.",
|
||||
cli.dst, cli.src
|
||||
);
|
||||
println!("(Ctrl-C to quit)");
|
||||
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
if std::io::stdin().read_line(&mut line)? == 0 {
|
||||
break; // EOF
|
||||
}
|
||||
|
||||
let text = line.trim();
|
||||
let bytes = text.as_bytes();
|
||||
|
||||
// Prepend the destination node ID.
|
||||
let mut msg = Vec::with_capacity(1 + bytes.len());
|
||||
msg.push(cli.dst);
|
||||
msg.extend_from_slice(bytes);
|
||||
|
||||
socket.send_to(&msg, app_addr)?;
|
||||
println!(
|
||||
"Sent {} byte(s) of payload to client {} → client {}.",
|
||||
bytes.len(),
|
||||
cli.src,
|
||||
cli.dst
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use std::{
|
||||
fmt::Debug,
|
||||
io::ErrorKind,
|
||||
net::{SocketAddr, UdpSocket},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use nym_lp_data::AddressedTimedData;
|
||||
|
||||
use crate::{node::NodeId, packet::WirePacketFormat};
|
||||
|
||||
pub mod nymnode;
|
||||
pub mod simple;
|
||||
pub mod sphinx;
|
||||
|
||||
/// Compact identifier for a simulated client.
|
||||
pub type ClientId = NodeId;
|
||||
|
||||
/// Driver-facing interface for a simulated client.
|
||||
///
|
||||
/// Erases `Fr`, `Pkt`, and `Mk` so that [`MixSimDriver`] only needs `Ts`.
|
||||
/// Implemented by [`simple::SimpleClient`] and any other concrete client types.
|
||||
///
|
||||
/// [`MixSimDriver`]: crate::driver::MixSimDriver
|
||||
pub trait MixSimClient: Send {
|
||||
fn tick(&mut self, timestamp: Instant);
|
||||
}
|
||||
|
||||
/// Pipeline interface used by [`BaseClient`] to convert raw app payloads into
|
||||
/// wire packets and to unwrap received packets back into plaintext.
|
||||
///
|
||||
/// `SndPkt` is the outgoing packet type (e.g. [`SimplePacket`] or
|
||||
/// [`SimMixPacket`]). `RcvPkt` defaults to `SndPkt` but can differ when the
|
||||
/// inbound and outbound wire formats diverge (e.g. the Sphinx client receives
|
||||
/// raw `Vec<u8>` final-hop payloads from nodes).
|
||||
///
|
||||
/// [`SimplePacket`]: crate::packet::simple::SimplePacket
|
||||
/// [`SimMixPacket`]: crate::packet::sphinx::SimMixPacket
|
||||
pub trait ProcessingClient<SndPkt, RcvPkt = SndPkt>: Send {
|
||||
/// Wrap `input` into one or more outbound packets addressed toward `dst`.
|
||||
fn process(
|
||||
&mut self,
|
||||
input: Vec<u8>,
|
||||
dst: ClientId,
|
||||
timestamp: Instant,
|
||||
) -> Vec<AddressedTimedData<SndPkt>>;
|
||||
|
||||
/// Unwrap an inbound packet received from the mix network.
|
||||
///
|
||||
/// Returns `Ok(Some(plaintext))` for a real message, `Ok(None)` when the
|
||||
/// packet is cover traffic or an incomplete fragment, and `Err` when
|
||||
/// decryption or deserialisation fails.
|
||||
fn unwrap(&mut self, input: RcvPkt, timestamp: Instant) -> anyhow::Result<Option<Vec<u8>>>;
|
||||
}
|
||||
|
||||
/// Shared UDP transport layer for simulated clients.
|
||||
///
|
||||
/// Encapsulates both sockets, the routing directory, and the client id so that
|
||||
/// multiple concrete client types can reuse `send_to_node`, `recv_from_mix`,
|
||||
/// and `recv_from_app` without duplicating that logic. Packet types are
|
||||
/// method-level generics so `BaseClient` itself has no type parameters.
|
||||
pub struct BaseClient<Pc, SndPkt, RcvPkt = SndPkt> {
|
||||
/// Identifier of this client within the topology.
|
||||
id: ClientId,
|
||||
/// Socket bound to the mix-network address; sends to first-hop nodes and
|
||||
/// receives final-hop packets.
|
||||
mix_socket: UdpSocket,
|
||||
/// Socket bound to the app address; receives application payloads from
|
||||
/// external CLIs (e.g. `mix-client`).
|
||||
app_socket: UdpSocket,
|
||||
|
||||
/// Packets that have been processed and are waiting to be forwarded to their
|
||||
/// first-hop node, sorted (loosely) by scheduled send timestamp.
|
||||
outgoing_queue: Vec<AddressedTimedData<SndPkt>>,
|
||||
|
||||
/// Concrete client-processing implementation invoked from each tick phase.
|
||||
processing_client: Pc,
|
||||
|
||||
/// Phantom data to carry the `RcvPkt` type parameter without storing a value.
|
||||
_marker: std::marker::PhantomData<RcvPkt>,
|
||||
}
|
||||
|
||||
impl<Pc, SndPkt, RcvPkt> BaseClient<Pc, SndPkt, RcvPkt> {
|
||||
/// Bind both UDP sockets to the given addresses.
|
||||
pub(crate) fn with_pipeline(
|
||||
client_id: ClientId,
|
||||
mixnet_address: SocketAddr,
|
||||
app_address: SocketAddr,
|
||||
processing_client: Pc,
|
||||
) -> anyhow::Result<Self> {
|
||||
let mix_socket = UdpSocket::bind(mixnet_address)?;
|
||||
mix_socket.set_nonblocking(true)?;
|
||||
|
||||
let app_socket = UdpSocket::bind(app_address)?;
|
||||
app_socket.set_nonblocking(true)?;
|
||||
|
||||
Ok(Self {
|
||||
id: client_id,
|
||||
mix_socket,
|
||||
app_socket,
|
||||
outgoing_queue: Vec::new(),
|
||||
processing_client,
|
||||
_marker: std::marker::PhantomData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<Pc, SndPkt, RcvPkt> BaseClient<Pc, SndPkt, RcvPkt>
|
||||
where
|
||||
SndPkt: WirePacketFormat,
|
||||
RcvPkt: WirePacketFormat,
|
||||
{
|
||||
/// Send `packet` to the mix node identified by `node_id` via `mix_socket`.
|
||||
///
|
||||
/// Resolves `node_id` against the shared [`crate::topology::directory::Directory`], serialises via
|
||||
/// [`WirePacketFormat::to_bytes`], and dispatches with a single `sendto`.
|
||||
/// Errors are logged but not propagated.
|
||||
pub fn send_to_node(&self, node_address: SocketAddr, packet: SndPkt) {
|
||||
if let Err(e) = self.mix_socket.send_to(&packet.to_bytes(), node_address) {
|
||||
tracing::error!(
|
||||
"[Client {}] Failed to send to node @ {node_address}: {e}",
|
||||
self.id
|
||||
);
|
||||
} else {
|
||||
tracing::debug!("[Client {}] Sent packet to node @ {node_address}", self.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to receive one packet from the mix socket and deserialise it.
|
||||
///
|
||||
/// Returns `None` when the socket would block (no datagram waiting).
|
||||
pub fn recv_from_mix(&self) -> Option<anyhow::Result<RcvPkt>> {
|
||||
let mut buf = [0u8; 1500];
|
||||
let (nb, src) = match self.mix_socket.recv_from(&mut buf) {
|
||||
Ok(r) => r,
|
||||
Err(e) if e.kind() == ErrorKind::WouldBlock => return None,
|
||||
Err(e) => {
|
||||
tracing::error!("[Client {}] mix_socket recv error: {e}", self.id);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
tracing::debug!(
|
||||
"[Client {}] Received {nb} byte(s) from mix node {src}",
|
||||
self.id
|
||||
);
|
||||
Some(RcvPkt::try_from_bytes(&buf[..nb]))
|
||||
}
|
||||
|
||||
/// Attempt to receive one raw datagram from the app socket.
|
||||
///
|
||||
/// Returns `None` when the socket would block (no datagram waiting).
|
||||
pub fn recv_from_app(&self) -> Option<anyhow::Result<Vec<u8>>> {
|
||||
let mut buf = [0u8; 15000];
|
||||
let nb = match self.app_socket.recv(&mut buf) {
|
||||
Ok(n) => n,
|
||||
Err(e) if e.kind() == ErrorKind::WouldBlock => return None,
|
||||
Err(e) => {
|
||||
tracing::error!("[Client {}] app_socket recv error: {e}", self.id);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
Some(Ok(buf[..nb].to_vec()))
|
||||
}
|
||||
}
|
||||
|
||||
impl<Pc, SndPkt, RcvPkt> MixSimClient for BaseClient<Pc, SndPkt, RcvPkt>
|
||||
where
|
||||
SndPkt: WirePacketFormat + Debug + Send,
|
||||
RcvPkt: WirePacketFormat + Debug + Send,
|
||||
Pc: ProcessingClient<SndPkt, RcvPkt>,
|
||||
{
|
||||
fn tick(&mut self, timestamp: Instant) {
|
||||
self.tick_app_incoming(timestamp);
|
||||
self.tick_outgoing(timestamp);
|
||||
self.tick_mix_incoming(timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
impl<Pc, SndPkt, RcvPkt> BaseClient<Pc, SndPkt, RcvPkt>
|
||||
where
|
||||
SndPkt: WirePacketFormat + Debug + Send,
|
||||
RcvPkt: WirePacketFormat + Debug + Send,
|
||||
Pc: ProcessingClient<SndPkt, RcvPkt>,
|
||||
{
|
||||
/// **Phase 1 — app incoming**: drain the app socket, run each payload
|
||||
/// through the processing pipeline, and enqueue the resulting packets.
|
||||
fn tick_app_incoming(&mut self, timestamp: Instant) {
|
||||
// Collect (dst, payload) pairs from the app socket.
|
||||
let mut inputs = Vec::new();
|
||||
while let Some(result) = self.recv_from_app() {
|
||||
let bytes = match result {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::error!("[Client {}] app_socket recv error: {e}", self.id);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// We assume format is [dst, payload]
|
||||
if bytes.len() < 2 {
|
||||
tracing::warn!(
|
||||
"[Client {}] app message too short ({} bytes), dropping",
|
||||
self.id,
|
||||
bytes.len()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let dst = bytes[0];
|
||||
let payload = bytes[1..].to_vec();
|
||||
tracing::debug!(
|
||||
"[Client {}] App input: {} byte(s) → client {dst}",
|
||||
self.id,
|
||||
payload.len()
|
||||
);
|
||||
inputs.push((dst, payload));
|
||||
}
|
||||
|
||||
// Always call process at least once; use an empty payload to self when idle.
|
||||
// We need to tick cover traffic
|
||||
if inputs.is_empty() {
|
||||
inputs.push((self.id, vec![]));
|
||||
}
|
||||
|
||||
for (dst, payload) in inputs {
|
||||
let packets = self.processing_client.process(payload, dst, timestamp);
|
||||
self.outgoing_queue.extend(packets);
|
||||
}
|
||||
}
|
||||
|
||||
/// **Phase 2 — outgoing**: send all queued packets whose scheduled
|
||||
/// timestamp is ≤ `timestamp` to their first-hop node.
|
||||
fn tick_outgoing(&mut self, timestamp: Instant) {
|
||||
let to_send = self
|
||||
.outgoing_queue
|
||||
.extract_if(.., |pkt| pkt.data.timestamp <= timestamp)
|
||||
.collect::<Vec<_>>();
|
||||
for pkt in to_send {
|
||||
self.send_to_node(pkt.dst, pkt.data.data);
|
||||
}
|
||||
}
|
||||
|
||||
/// **Phase 3 — mix incoming**: drain the mix socket and pass each packet
|
||||
/// through the unwrapping pipeline.
|
||||
fn tick_mix_incoming(&mut self, timestamp: Instant) {
|
||||
while let Some(result) = self.recv_from_mix() {
|
||||
match result {
|
||||
Ok(pkt) => match self.processing_client.unwrap(pkt, timestamp) {
|
||||
Ok(Some(content)) => {
|
||||
tracing::info!(
|
||||
"[Client {}] Received: {:?}",
|
||||
self.id,
|
||||
String::from_utf8_lossy(&content)
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("[Client {}] Error unwrapping packet : {e}", self.id);
|
||||
}
|
||||
Ok(None) => {}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!("[Client {}] Failed to deserialize mix packet: {e}", self.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! [`SimNymClient`] — simulated client that produces sphinx-in-LP packets
|
||||
//! consumed by the [`SimNymNode`](crate::node::nymnode::SimNymNode).
|
||||
//!
|
||||
//! The wrapping pipeline applies sphinx-style chunking, full Sphinx encryption
|
||||
//! over a 3-hop route, and LP framing/transport. Reliability and obfuscation
|
||||
//! are no-ops to keep the wire trace easy to follow.
|
||||
|
||||
use std::{sync::Arc, time::Instant};
|
||||
|
||||
use nym_crypto::asymmetric::x25519;
|
||||
use nym_lp_data::{
|
||||
AddressedTimedData, PipelinePayload, TimedData, TimedPayload,
|
||||
clients::{
|
||||
helpers::{NoOpObfuscation, NoOpReliability},
|
||||
traits::{Chunking, ClientUnwrappingPipeline, ClientWrappingPipeline, RoutingSecurity},
|
||||
},
|
||||
common::traits::{
|
||||
Framing, FramingUnwrap, Transport, TransportUnwrap, WireUnwrappingPipeline,
|
||||
WireWrappingPipeline,
|
||||
},
|
||||
fragmentation::{fragment::fragment_lp_message, reconstruction::MessageReconstructor},
|
||||
packet::{
|
||||
EncryptedLpPacket, LpFrame, LpHeader, LpPacket, MalformedLpPacketError,
|
||||
frame::{LpFrameHeader, LpFrameKind},
|
||||
version,
|
||||
},
|
||||
};
|
||||
use nym_node::node::lp::data::handler::messages::ForwardSphinxMessage;
|
||||
use nym_sphinx::{
|
||||
Delay, ProcessedPacketData, SphinxPacket, SphinxPacketBuilder,
|
||||
chunking::{
|
||||
fragment::Fragment, reconstruction::MessageReconstructor as SphinxMessageReconstructor,
|
||||
},
|
||||
message::{NymMessage, PaddedMessage},
|
||||
};
|
||||
use nym_sphinx_params::SphinxKeyRotation;
|
||||
use rand::Rng;
|
||||
|
||||
use crate::{
|
||||
client::{BaseClient, ClientId, ProcessingClient},
|
||||
helpers,
|
||||
topology::{
|
||||
TopologyClient,
|
||||
directory::{Directory, DirectoryClient},
|
||||
},
|
||||
};
|
||||
|
||||
// SW To be replaced with actual client implementation
|
||||
|
||||
/// A simulated client that produces sphinx-in-LP packets.
|
||||
///
|
||||
/// `Ts` is fixed to [`Instant`] because the real [`NymNodeDataPipeline`] only
|
||||
/// works on wall-clock time.
|
||||
///
|
||||
/// UDP transport and routing are handled by the embedded [`BaseClient`]; this
|
||||
/// struct adds the outgoing queue and the wrapping/unwrapping pipelines.
|
||||
///
|
||||
/// [`NymNodeDataPipeline`]: nym_node::node::lp::data::handler::pipeline::NymNodeDataPipeline
|
||||
pub type SimNymClient<R> = BaseClient<SimNymProcesssingClient<R>, EncryptedLpPacket>;
|
||||
|
||||
impl<R: Rng + Send> SimNymClient<R> {
|
||||
/// Bind both UDP sockets and return a new client.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if either socket fails to bind or set non-blocking.
|
||||
pub fn new(
|
||||
topology_client: TopologyClient,
|
||||
directory: Arc<Directory>,
|
||||
rng: R,
|
||||
) -> anyhow::Result<Self> {
|
||||
let processing_client = SimNymProcesssingClient {
|
||||
wrapper: SimNymClientWrappingPipeline {
|
||||
directory: directory.clone(),
|
||||
rng,
|
||||
},
|
||||
unwrapper: NymNodeUnwrappingPipeline {
|
||||
message_reconstructor: Default::default(),
|
||||
sphinx_message_reconstructor: SphinxMessageReconstructor::default(),
|
||||
sphinx_secret_key: topology_client.sphinx_private_key,
|
||||
},
|
||||
};
|
||||
BaseClient::with_pipeline(
|
||||
topology_client.client_id,
|
||||
topology_client.mixnet_address,
|
||||
topology_client.app_address,
|
||||
processing_client,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
///
|
||||
/// `dst` is the final destination [`ClientId`] embedded in the sphinx packet's
|
||||
/// destination address. `first_hop` is the [`std::net::SocketAddr`] of the
|
||||
/// first mix node; the client sends the LP packet there.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct SimNymClientInputOptions {
|
||||
pub dst: DirectoryClient,
|
||||
}
|
||||
|
||||
pub struct SimNymProcesssingClient<R: Rng> {
|
||||
wrapper: SimNymClientWrappingPipeline<R>,
|
||||
unwrapper: NymNodeUnwrappingPipeline,
|
||||
}
|
||||
|
||||
impl<R: Rng + Send> ProcessingClient<EncryptedLpPacket> for SimNymProcesssingClient<R> {
|
||||
fn process(
|
||||
&mut self,
|
||||
input: Vec<u8>,
|
||||
dst: ClientId,
|
||||
timestamp: Instant,
|
||||
) -> Vec<AddressedTimedData<EncryptedLpPacket>> {
|
||||
if input.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(&destination_client) = self.wrapper.directory.client(dst) else {
|
||||
tracing::error!("Destination {dst} does not exist in the topology");
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let first_hop = self
|
||||
.wrapper
|
||||
.directory
|
||||
.random_next_hop(&mut self.wrapper.rng);
|
||||
|
||||
let input_options = SimNymClientInputOptions {
|
||||
dst: destination_client,
|
||||
};
|
||||
|
||||
self.wrapper
|
||||
.process(Some((input, input_options, first_hop.addr)), timestamp)
|
||||
}
|
||||
|
||||
fn unwrap(
|
||||
&mut self,
|
||||
input: EncryptedLpPacket,
|
||||
timestamp: Instant,
|
||||
) -> anyhow::Result<Option<Vec<u8>>> {
|
||||
Ok(self.unwrapper.unwrap(input, timestamp)?)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Wrapping pipeline
|
||||
|
||||
/// Full wrapping pipeline for [`SimNymClient`].
|
||||
///
|
||||
/// Applies, in order: sphinx-style chunking, Sphinx onion encryption over a
|
||||
/// random 3-hop route, LP framing (with fragmentation when the encrypted packet
|
||||
/// exceeds the frame size), and LP transport.
|
||||
pub struct SimNymClientWrappingPipeline<R: Rng> {
|
||||
/// Shared routing table; used to sample the 3-hop route in `encrypt`.
|
||||
directory: Arc<Directory>,
|
||||
/// RNG used for route selection, sphinx delays, and LP fragmentation.
|
||||
rng: R,
|
||||
}
|
||||
|
||||
impl<R: Rng> Chunking<SimNymClientInputOptions> for SimNymClientWrappingPipeline<R> {
|
||||
/// Split `input` into sphinx-sized chunks using the standard sphinx
|
||||
/// fragmentation. Every chunk is addressed to the configured first hop so
|
||||
/// the LP packet reaches the network entry node.
|
||||
fn chunked(
|
||||
&mut self,
|
||||
input: PipelinePayload<SimNymClientInputOptions>,
|
||||
chunk_size: usize,
|
||||
timestamp: Instant,
|
||||
) -> Vec<PipelinePayload<SimNymClientInputOptions>> {
|
||||
let fragments = NymMessage::new_plain(input.data.data)
|
||||
.pad_to_full_packet_lengths(chunk_size)
|
||||
.split_into_fragments(&mut self.rng, chunk_size);
|
||||
|
||||
fragments
|
||||
.into_iter()
|
||||
.map(|fragment| {
|
||||
PipelinePayload::new(timestamp, fragment.into_bytes(), input.options, input.dst)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Rng> NoOpReliability for SimNymClientWrappingPipeline<R> {}
|
||||
impl<R: Rng> NoOpObfuscation for SimNymClientWrappingPipeline<R> {}
|
||||
|
||||
impl<R: Rng> RoutingSecurity<SimNymClientInputOptions> for SimNymClientWrappingPipeline<R> {
|
||||
// We are wrapping the sphinx packet in an LpFrame, hence the extra header overhead
|
||||
const OVERHEAD_SIZE: usize =
|
||||
nym_sphinx::HEADER_SIZE + nym_sphinx::PAYLOAD_OVERHEAD_SIZE + LpFrameHeader::SIZE;
|
||||
|
||||
fn nb_frames(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
/// Wrap `input` in a Sphinx onion packet with a 3-hop route.
|
||||
///
|
||||
/// The route is built by taking `options.first_hop` as the first hop and
|
||||
/// choosing two additional hops at random. The final destination address
|
||||
/// is derived from `options.dst`. Per-hop delays come from
|
||||
/// [`crate::helpers::generate_mix_delay`].
|
||||
fn encrypt(
|
||||
&mut self,
|
||||
input: PipelinePayload<SimNymClientInputOptions>,
|
||||
) -> PipelinePayload<SimNymClientInputOptions> {
|
||||
let route = self.directory.random_route(3, &mut self.rng);
|
||||
|
||||
let first_mix_hop = route[0].id;
|
||||
|
||||
let sphinx_route = route
|
||||
.into_iter()
|
||||
.map(|n| n.as_sphinx_node_socket())
|
||||
.chain(std::iter::once(input.options.dst.as_sphinx_node()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let delays = (0..sphinx_route.len())
|
||||
.map(|_| Delay::new_from_millis(helpers::generate_mix_delay(&mut self.rng)))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let plaintext_size = (<Self as WireWrappingPipeline<
|
||||
EncryptedLpPacket,
|
||||
SimNymClientInputOptions,
|
||||
>>::packet_size(self)
|
||||
- <Self as Framing<SimNymClientInputOptions>>::OVERHEAD_SIZE
|
||||
- <Self as Transport<EncryptedLpPacket>>::OVERHEAD_SIZE)
|
||||
* self.nb_frames()
|
||||
- <Self as RoutingSecurity<_>>::OVERHEAD_SIZE;
|
||||
|
||||
let packet_builder = SphinxPacketBuilder::new()
|
||||
.with_payload_size(plaintext_size + nym_sphinx::PAYLOAD_OVERHEAD_SIZE);
|
||||
|
||||
// SAFETY : If the pipeline is built correctly, the packet building should not fail.
|
||||
// If it does, something is wrong with the code. If it crashes it's fine since it's a simulator anyway
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let packet = packet_builder
|
||||
.build_packet(
|
||||
input.data.data,
|
||||
&sphinx_route,
|
||||
&input.options.dst.as_sphinx_destination(),
|
||||
&delays,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let attributes = ForwardSphinxMessage {
|
||||
key_rotation: SphinxKeyRotation::EvenRotation, // Doesn't matter at all
|
||||
next_hop: first_mix_hop as u32,
|
||||
};
|
||||
let framed_packet = LpFrame::new_with_attributes(
|
||||
LpFrameKind::ForwardSphinxPacket,
|
||||
attributes,
|
||||
packet.to_bytes(),
|
||||
);
|
||||
|
||||
PipelinePayload::new(
|
||||
input.data.timestamp,
|
||||
framed_packet.to_bytes(),
|
||||
input.options,
|
||||
input.dst,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Rng> Framing<SimNymClientInputOptions> for SimNymClientWrappingPipeline<R> {
|
||||
type Frame = LpFrame;
|
||||
const OVERHEAD_SIZE: usize = LpFrameHeader::SIZE;
|
||||
|
||||
fn to_frame(
|
||||
&mut self,
|
||||
payload: PipelinePayload<SimNymClientInputOptions>,
|
||||
frame_size: usize,
|
||||
) -> Vec<AddressedTimedData<Self::Frame>> {
|
||||
// SAFETY : we know the inupt is long enough
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let input_frame = LpFrame::decode(&payload.data.data).unwrap();
|
||||
|
||||
fragment_lp_message(&mut self.rng, input_frame, frame_size)
|
||||
.into_iter()
|
||||
.map(|f| f.into_lp_frame())
|
||||
.map(|f| AddressedTimedData::new_addressed(payload.data.timestamp, f, payload.dst))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Rng> Transport<EncryptedLpPacket> for SimNymClientWrappingPipeline<R> {
|
||||
type Frame = LpFrame;
|
||||
const OVERHEAD_SIZE: usize = LpHeader::SIZE;
|
||||
|
||||
fn to_transport_packet(
|
||||
&mut self,
|
||||
frame: AddressedTimedData<Self::Frame>,
|
||||
) -> AddressedTimedData<EncryptedLpPacket> {
|
||||
frame.data_transform(|f| LpPacket::new(LpHeader::new(0, 0, version::CURRENT), f).encode())
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Rng> WireWrappingPipeline<EncryptedLpPacket, SimNymClientInputOptions>
|
||||
for SimNymClientWrappingPipeline<R>
|
||||
{
|
||||
fn packet_size(&self) -> usize {
|
||||
nym_lp_data::packet::MTU
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Rng> ClientWrappingPipeline<EncryptedLpPacket, SimNymClientInputOptions>
|
||||
for SimNymClientWrappingPipeline<R>
|
||||
{
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Unwrapping pipeline
|
||||
//
|
||||
// The NymNodeDataPipeline currently drops final-hop packets, so in practice
|
||||
// this client never receives anything useful. The unwrapper is still wired up
|
||||
// for completeness — it decodes LP packets and would surface reassembled
|
||||
// payloads if delivery were ever enabled.
|
||||
|
||||
/// Unwrapping pipeline for [`SimNymClient`].
|
||||
pub struct NymNodeUnwrappingPipeline {
|
||||
message_reconstructor: MessageReconstructor,
|
||||
sphinx_message_reconstructor: SphinxMessageReconstructor,
|
||||
sphinx_secret_key: x25519::PrivateKey,
|
||||
}
|
||||
|
||||
impl TransportUnwrap<EncryptedLpPacket> for NymNodeUnwrappingPipeline {
|
||||
type Frame = LpFrame;
|
||||
type Error = MalformedLpPacketError;
|
||||
|
||||
fn packet_to_frame(
|
||||
&mut self,
|
||||
packet: EncryptedLpPacket,
|
||||
timestamp: Instant,
|
||||
) -> Result<TimedData<Self::Frame>, Self::Error> {
|
||||
let lp = LpPacket::decode(packet)?;
|
||||
Ok(TimedData::new(timestamp, lp.into_frame()))
|
||||
}
|
||||
}
|
||||
|
||||
impl FramingUnwrap<()> for NymNodeUnwrappingPipeline {
|
||||
type Frame = LpFrame;
|
||||
|
||||
fn frame_to_message(&mut self, frame: TimedData<Self::Frame>) -> Option<(TimedPayload, ())> {
|
||||
let recovered_message = match frame.data.kind() {
|
||||
LpFrameKind::FragmentedData => {
|
||||
let fragment = frame.data.try_into().ok()?; // This should never fail
|
||||
self.message_reconstructor
|
||||
.insert_new_fragment(fragment, frame.timestamp)?
|
||||
.inspect_err(|e| tracing::warn!("Failed to reconstruct message : {e}"))
|
||||
.ok()?
|
||||
}
|
||||
LpFrameKind::SphinxPacket => frame.data,
|
||||
f => {
|
||||
tracing::warn!("Unsupported lp frame : {f:?}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
Some((
|
||||
TimedPayload::new(frame.timestamp, recovered_message.content.to_vec()),
|
||||
(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl WireUnwrappingPipeline<EncryptedLpPacket, ()> for NymNodeUnwrappingPipeline {}
|
||||
|
||||
impl ClientUnwrappingPipeline<EncryptedLpPacket, ()> for NymNodeUnwrappingPipeline {
|
||||
fn process_unwrapped(&mut self, timed_plaintext: TimedPayload, _: ()) -> Option<Vec<u8>> {
|
||||
let sphinx_packet = SphinxPacket::from_bytes(&timed_plaintext.data)
|
||||
.inspect_err(|e| tracing::warn!("Impossible to recover sphinx packet : {e}"))
|
||||
.ok()?;
|
||||
let processed_packet = sphinx_packet
|
||||
.process(self.sphinx_secret_key.inner())
|
||||
.inspect_err(|e| tracing::warn!("Impossible to process sphinx packet : {e}"))
|
||||
.ok()?
|
||||
.data;
|
||||
|
||||
let ProcessedPacketData::FinalHop { payload, .. } = processed_packet else {
|
||||
tracing::warn!("Received a forward hop packet in a client, this shouldn't happen");
|
||||
return None;
|
||||
};
|
||||
|
||||
let plaintext = payload
|
||||
.recover_plaintext()
|
||||
.inspect_err(|e| tracing::warn!("Impossible to recover plaintext : {e}"))
|
||||
.ok()?;
|
||||
|
||||
let fragment = Fragment::try_from_bytes(&plaintext)
|
||||
.inspect_err(|e| tracing::warn!("Failed to deserialize fragment : {e}"))
|
||||
.ok()?;
|
||||
|
||||
if let Some(reconstructed_message) = self
|
||||
.sphinx_message_reconstructor
|
||||
.insert_new_fragment(fragment)
|
||||
{
|
||||
let message = PaddedMessage::from(reconstructed_message.0)
|
||||
.remove_padding()
|
||||
.inspect_err(|e| tracing::warn!("Failed to remove padding : {e}"))
|
||||
.ok()?;
|
||||
Some(message.into_inner_data())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Simulated mix-network client.
|
||||
//!
|
||||
//! A [`SimpleClient`] owns a [`BaseClient`] (which manages both UDP sockets
|
||||
//! and the routing directory) plus the mix and unwrapping pipelines.
|
||||
//!
|
||||
//! ## Tick phases
|
||||
//!
|
||||
//! ```text
|
||||
//! tick_app_incoming ──── app_socket ──▶ processing_pipeline ──▶ outgoing_queue
|
||||
//! tick_outgoing ──── outgoing_queue ──▶ mix_socket ──▶ Node N
|
||||
//! tick_mix_incoming ──── mix_socket ◀── Node N ──▶ unwrapping_pipeline
|
||||
//! ```
|
||||
//!
|
||||
//! ## App-socket message format
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌────────────────────────┬─────────────────────┐
|
||||
//! │ dst_client_id (1 B) │ raw payload bytes │
|
||||
//! └────────────────────────┴─────────────────────┘
|
||||
//! ```
|
||||
|
||||
use std::{net::SocketAddr, sync::Arc, time::Instant};
|
||||
|
||||
use nym_lp_data::{
|
||||
AddressedTimedData, AddressedTimedPayload, TimedData, TimedPayload,
|
||||
clients::{
|
||||
helpers::{NoOpObfuscation, NoOpReliability, NoOpRoutingSecurity},
|
||||
traits::{Chunking, ClientUnwrappingPipeline, ClientWrappingPipeline},
|
||||
},
|
||||
common::traits::{
|
||||
Framing, FramingUnwrap, Transport, TransportUnwrap, WireUnwrappingPipeline,
|
||||
WireWrappingPipeline,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
client::{BaseClient, ClientId, ProcessingClient},
|
||||
packet::simple::{SimpleFrame, SimplePacket, SimpleWireUnwrapper, SimpleWireWrapper},
|
||||
topology::{TopologyClient, directory::Directory},
|
||||
};
|
||||
|
||||
/// A simulated client that injects packets into the mix network.
|
||||
///
|
||||
/// `Ts` is the timestamp / tick-context type. Packet type, frame type, and
|
||||
/// message marker are fixed to the `Simple*` concrete types.
|
||||
///
|
||||
/// UDP transport and routing are handled by the embedded [`BaseClient`]; this
|
||||
/// struct adds the outgoing queue and the wrapping/unwrapping pipelines.
|
||||
pub type SimpleClient = BaseClient<SimpleProcessingClient, SimplePacket>;
|
||||
|
||||
impl SimpleClient {
|
||||
/// Bind both UDP sockets and return a new client.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if either socket fails to bind or set non-blocking.
|
||||
pub fn new(topology_client: TopologyClient, directory: Arc<Directory>) -> anyhow::Result<Self> {
|
||||
// SAFETY : node 0 always exists, otherwise we don't have any nodes
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let first_hop_address = directory.node(0).unwrap().addr;
|
||||
let processing_client = SimpleProcessingClient {
|
||||
first_hop: first_hop_address,
|
||||
wrapper: SimpleClientWrappingPipeline::default(),
|
||||
unwrapper: SimpleClientUnwrapping::default(),
|
||||
};
|
||||
BaseClient::with_pipeline(
|
||||
topology_client.client_id,
|
||||
topology_client.mixnet_address,
|
||||
topology_client.app_address,
|
||||
processing_client,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridges [`BaseClient`] to the simple wrapping and unwrapping pipelines.
|
||||
pub struct SimpleProcessingClient {
|
||||
first_hop: SocketAddr,
|
||||
wrapper: SimpleClientWrappingPipeline,
|
||||
unwrapper: SimpleClientUnwrapping,
|
||||
}
|
||||
|
||||
impl ProcessingClient<SimplePacket> for SimpleProcessingClient {
|
||||
fn process(
|
||||
&mut self,
|
||||
input: Vec<u8>,
|
||||
_: ClientId,
|
||||
timestamp: Instant,
|
||||
) -> Vec<AddressedTimedData<SimplePacket>> {
|
||||
self.wrapper
|
||||
.process(Some((input, (), self.first_hop)), timestamp)
|
||||
}
|
||||
|
||||
fn unwrap(
|
||||
&mut self,
|
||||
input: SimplePacket,
|
||||
timestamp: Instant,
|
||||
) -> anyhow::Result<Option<Vec<u8>>> {
|
||||
self.unwrapper.unwrap(input, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Concrete pipelines
|
||||
|
||||
/// Stub client processing pipeline for [`SimplePacket`].
|
||||
///
|
||||
/// A no-op pass-through: returns the payload as a single packet with no
|
||||
/// Sphinx layering, chunking, reliability encoding, or obfuscation.
|
||||
///
|
||||
/// All required sub-traits of [`ClientWrappingPipeline`] are implemented here;
|
||||
/// [`ClientWrappingPipeline`] is then provided automatically via the blanket
|
||||
/// impl in `nym_lp_data`.
|
||||
pub struct SimpleClientWrappingPipeline(SimpleWireWrapper);
|
||||
|
||||
impl Default for SimpleClientWrappingPipeline {
|
||||
fn default() -> Self {
|
||||
Self(SimpleWireWrapper)
|
||||
}
|
||||
}
|
||||
|
||||
impl Chunking<()> for SimpleClientWrappingPipeline {
|
||||
/// Split `input` into chunks of `chunk_size` bytes, padding the last chunk
|
||||
/// with zero bytes if necessary.
|
||||
///
|
||||
/// A `0x01` marker byte is appended before padding so the unwrapper can
|
||||
/// strip trailing zeros.
|
||||
fn chunked(
|
||||
&mut self,
|
||||
input: AddressedTimedPayload,
|
||||
chunk_size: usize,
|
||||
timestamp: Instant,
|
||||
) -> Vec<AddressedTimedPayload> {
|
||||
let mut input_data = input.data.data;
|
||||
input_data.push(1);
|
||||
if !input_data.len().is_multiple_of(chunk_size) {
|
||||
let padding = vec![0; chunk_size - input_data.len() % chunk_size];
|
||||
input_data.extend_from_slice(&padding);
|
||||
}
|
||||
|
||||
input_data
|
||||
.chunks(chunk_size)
|
||||
.map(|chunk| AddressedTimedPayload::new_addressed(timestamp, chunk.to_vec(), input.dst))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl NoOpReliability for SimpleClientWrappingPipeline {}
|
||||
impl NoOpObfuscation for SimpleClientWrappingPipeline {}
|
||||
impl NoOpRoutingSecurity for SimpleClientWrappingPipeline {}
|
||||
|
||||
// Delegation to SimpleWireWrapper
|
||||
impl Framing<()> for SimpleClientWrappingPipeline {
|
||||
type Frame = SimpleFrame;
|
||||
const OVERHEAD_SIZE: usize = <SimpleWireWrapper as Framing<_>>::OVERHEAD_SIZE;
|
||||
fn to_frame(
|
||||
&mut self,
|
||||
payload: AddressedTimedPayload,
|
||||
frame_size: usize,
|
||||
) -> Vec<AddressedTimedData<SimpleFrame>> {
|
||||
self.0.to_frame(payload, frame_size)
|
||||
}
|
||||
}
|
||||
|
||||
// Delegation to SimpleWireWrapper
|
||||
impl Transport<SimplePacket> for SimpleClientWrappingPipeline {
|
||||
type Frame = SimpleFrame;
|
||||
const OVERHEAD_SIZE: usize = <SimpleWireWrapper as Transport<_>>::OVERHEAD_SIZE;
|
||||
fn to_transport_packet(
|
||||
&mut self,
|
||||
frame: AddressedTimedData<SimpleFrame>,
|
||||
) -> AddressedTimedData<SimplePacket> {
|
||||
self.0.to_transport_packet(frame)
|
||||
}
|
||||
}
|
||||
|
||||
// Delegation to SimpleWireWrapper
|
||||
impl WireWrappingPipeline<SimplePacket, ()> for SimpleClientWrappingPipeline {
|
||||
fn packet_size(&self) -> usize {
|
||||
<SimpleWireWrapper as WireWrappingPipeline<_, _>>::packet_size(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientWrappingPipeline<SimplePacket, ()> for SimpleClientWrappingPipeline {}
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Unwrapping pipeline for [`SimpleClient`]: strips the frame header and
|
||||
/// removes padding from the recovered payload.
|
||||
pub struct SimpleClientUnwrapping(SimpleWireUnwrapper);
|
||||
|
||||
impl Default for SimpleClientUnwrapping {
|
||||
fn default() -> Self {
|
||||
Self(SimpleWireUnwrapper)
|
||||
}
|
||||
}
|
||||
|
||||
// Delegation to SimpleWireUnwrapper
|
||||
impl FramingUnwrap<()> for SimpleClientUnwrapping {
|
||||
type Frame = SimpleFrame;
|
||||
fn frame_to_message(&mut self, frame: TimedData<SimpleFrame>) -> Option<(TimedPayload, ())> {
|
||||
self.0.frame_to_message(frame)
|
||||
}
|
||||
}
|
||||
|
||||
// Delegation to SimpleWireUnwrapper
|
||||
impl TransportUnwrap<SimplePacket> for SimpleClientUnwrapping {
|
||||
type Frame = SimpleFrame;
|
||||
type Error = anyhow::Error;
|
||||
fn packet_to_frame(
|
||||
&mut self,
|
||||
packet: SimplePacket,
|
||||
timestamp: Instant,
|
||||
) -> anyhow::Result<TimedData<SimpleFrame>> {
|
||||
self.0.packet_to_frame(packet, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
impl WireUnwrappingPipeline<SimplePacket, ()> for SimpleClientUnwrapping {}
|
||||
|
||||
impl ClientUnwrappingPipeline<SimplePacket, ()> for SimpleClientUnwrapping {
|
||||
fn process_unwrapped(&mut self, payload: TimedPayload, _: ()) -> Option<Vec<u8>> {
|
||||
let mut data = payload.data;
|
||||
if let Some(pos) = data.iter().rposition(|&b| b == 1) {
|
||||
data.truncate(pos);
|
||||
}
|
||||
Some(data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! [`SphinxClient`] — simulated client using full Sphinx encryption.
|
||||
//!
|
||||
//! The wrapping pipeline applies chunking, Sphinx encryption (routing security),
|
||||
//! and Poisson cover traffic obfuscation. The unwrapping pipeline reconstructs
|
||||
//! fragmented messages and filters out cover traffic.
|
||||
|
||||
use std::{sync::Arc, time::Instant};
|
||||
|
||||
use nym_lp_data::{
|
||||
AddressedTimedData, PipelinePayload, TimedPayload,
|
||||
clients::traits::{
|
||||
Chunking, ClientUnwrappingPipeline, ClientWrappingPipeline, Obfuscation, Reliability,
|
||||
RoutingSecurity,
|
||||
},
|
||||
common::{
|
||||
helpers::{NoOpWireUnwrapper, NoOpWireWrapper},
|
||||
traits::{Framing, Transport, WireWrappingPipeline},
|
||||
},
|
||||
};
|
||||
use nym_sphinx::{
|
||||
Delay, SphinxPacketBuilder,
|
||||
chunking::{fragment::Fragment, reconstruction::MessageReconstructor},
|
||||
message::{NymMessage, PaddedMessage},
|
||||
};
|
||||
use rand::Rng;
|
||||
|
||||
use crate::{
|
||||
client::{
|
||||
BaseClient, ClientId, ProcessingClient,
|
||||
sphinx::{poisson_cover_traffic::PoissonCoverTraffic, surb_acks::SurbAcksReliability},
|
||||
},
|
||||
helpers,
|
||||
packet::sphinx::{SimMixPacket, SurbAck},
|
||||
topology::{
|
||||
TopologyClient,
|
||||
directory::{Directory, DirectoryClient, DirectoryNode},
|
||||
},
|
||||
};
|
||||
|
||||
mod poisson_cover_traffic;
|
||||
mod surb_acks;
|
||||
|
||||
/// A simulated client that injects packets into the mix network.
|
||||
///
|
||||
/// `Ts` is the timestamp / tick-context type. Packet type, frame type, and
|
||||
/// message marker are fixed to the `Sphinx*` concrete types.
|
||||
///
|
||||
/// UDP transport and routing are handled by the embedded [`BaseClient`]; this
|
||||
/// struct adds the outgoing queue and the wrapping/unwrapping pipelines.
|
||||
pub type SphinxClient<R> = BaseClient<SphinxProcessingClient<R>, SimMixPacket, Vec<u8>>;
|
||||
|
||||
impl<R: Rng + Clone + Send> SphinxClient<R> {
|
||||
/// Bind both UDP sockets and return a new client.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if either socket fails to bind or set non-blocking.
|
||||
pub fn new(
|
||||
topology_client: TopologyClient,
|
||||
directory: Arc<Directory>,
|
||||
current_timestamp: Instant,
|
||||
rng: R,
|
||||
) -> anyhow::Result<Self> {
|
||||
let processing_client = SphinxProcessingClient {
|
||||
wrapper: SphinxClientWrappingPipeline {
|
||||
cover_traffic: PoissonCoverTraffic::new(
|
||||
(&topology_client).into(),
|
||||
directory.clone(),
|
||||
current_timestamp,
|
||||
rng.clone(),
|
||||
),
|
||||
reliability: SurbAcksReliability::new(
|
||||
rng.clone(),
|
||||
(&topology_client).into(),
|
||||
directory.clone(),
|
||||
),
|
||||
directory,
|
||||
rng,
|
||||
},
|
||||
unwrapper: SphinxClientUnwrapping::default(),
|
||||
};
|
||||
BaseClient::with_pipeline(
|
||||
topology_client.client_id,
|
||||
topology_client.mixnet_address,
|
||||
topology_client.app_address,
|
||||
processing_client,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct SphinxInputOptions {
|
||||
/// Destination client
|
||||
dst: DirectoryClient,
|
||||
first_hop: DirectoryNode,
|
||||
}
|
||||
|
||||
/// Bridges [`BaseClient`] to the Sphinx wrapping and unwrapping pipelines.
|
||||
pub struct SphinxProcessingClient<R: Rng> {
|
||||
wrapper: SphinxClientWrappingPipeline<R>,
|
||||
unwrapper: SphinxClientUnwrapping,
|
||||
}
|
||||
|
||||
impl<R: Rng + Send> ProcessingClient<SimMixPacket, Vec<u8>> for SphinxProcessingClient<R> {
|
||||
fn process(
|
||||
&mut self,
|
||||
input: Vec<u8>,
|
||||
dst: ClientId,
|
||||
timestamp: Instant,
|
||||
) -> Vec<AddressedTimedData<SimMixPacket>> {
|
||||
let first_hop = self
|
||||
.wrapper
|
||||
.directory
|
||||
.random_next_hop(&mut self.wrapper.rng);
|
||||
|
||||
let Some(&destination_client) = self.wrapper.directory.client(dst) else {
|
||||
tracing::error!("Destination {dst} does not exist in the topology");
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let input_options = SphinxInputOptions {
|
||||
dst: destination_client,
|
||||
first_hop,
|
||||
};
|
||||
self.wrapper
|
||||
.process(Some((input, input_options, first_hop.addr)), timestamp)
|
||||
}
|
||||
|
||||
fn unwrap(&mut self, input: Vec<u8>, timestamp: Instant) -> anyhow::Result<Option<Vec<u8>>> {
|
||||
Ok(self.unwrapper.unwrap(input, timestamp)?)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Concrete pipelines
|
||||
|
||||
/// Full wrapping pipeline for [`SphinxClient`].
|
||||
///
|
||||
/// Applies, in order: chunking (using standard Sphinx fragmentation), SURB-ACK
|
||||
/// reliability prefix, Poisson cover traffic obfuscation, Sphinx onion
|
||||
/// encryption, and a no-op wire wrapper (a Sphinx packet is already its own
|
||||
/// wire unit).
|
||||
pub struct SphinxClientWrappingPipeline<R: Rng> {
|
||||
/// Poisson cover traffic generator providing the [`Obfuscation`] stage.
|
||||
cover_traffic: PoissonCoverTraffic<R>,
|
||||
/// SURB-ACK reliability layer providing the [`Reliability`] stage.
|
||||
reliability: SurbAcksReliability<R>,
|
||||
/// Shared routing table; used to sample the 3-hop Sphinx route in `encrypt`.
|
||||
directory: Arc<Directory>,
|
||||
/// RNG used for random route selection and Sphinx delay sampling.
|
||||
rng: R,
|
||||
}
|
||||
|
||||
impl<R: Rng> Chunking<SphinxInputOptions> for SphinxClientWrappingPipeline<R> {
|
||||
fn chunked(
|
||||
&mut self,
|
||||
input: PipelinePayload<SphinxInputOptions>,
|
||||
chunk_size: usize,
|
||||
timestamp: Instant,
|
||||
) -> Vec<PipelinePayload<SphinxInputOptions>> {
|
||||
let input_data = input.data.data;
|
||||
if input_data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// This is using standard sphinx chunking. Proper LP should use a different one
|
||||
let fragments = NymMessage::new_plain(input_data)
|
||||
.pad_to_full_packet_lengths(chunk_size)
|
||||
.split_into_fragments(&mut self.rng, chunk_size);
|
||||
|
||||
fragments
|
||||
.into_iter()
|
||||
.map(|fragment| {
|
||||
PipelinePayload::new(timestamp, fragment.into_bytes(), input.options, input.dst)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Rng> Reliability<SphinxInputOptions> for SphinxClientWrappingPipeline<R> {
|
||||
const OVERHEAD_SIZE: usize =
|
||||
<SurbAcksReliability<R> as Reliability<SphinxInputOptions>>::OVERHEAD_SIZE;
|
||||
fn reliable_encode(
|
||||
&mut self,
|
||||
input: Option<PipelinePayload<SphinxInputOptions>>,
|
||||
timestamp: Instant,
|
||||
) -> Vec<PipelinePayload<SphinxInputOptions>> {
|
||||
self.reliability.reliable_encode(input, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Rng> Obfuscation<SphinxInputOptions> for SphinxClientWrappingPipeline<R> {
|
||||
fn obfuscate(
|
||||
&mut self,
|
||||
input: Option<PipelinePayload<SphinxInputOptions>>,
|
||||
timestamp: Instant,
|
||||
) -> Vec<PipelinePayload<SphinxInputOptions>> {
|
||||
self.cover_traffic.obfuscate(input, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Rng> RoutingSecurity<SphinxInputOptions> for SphinxClientWrappingPipeline<R> {
|
||||
const OVERHEAD_SIZE: usize = nym_sphinx::HEADER_SIZE + nym_sphinx::PAYLOAD_OVERHEAD_SIZE;
|
||||
fn nb_frames(&self) -> usize {
|
||||
1
|
||||
}
|
||||
/// Wrap `input` in a Sphinx onion packet with a 3-hop route.
|
||||
///
|
||||
/// The route is built by taking `input_options.next_hop` as the first hop
|
||||
/// and choosing two additional hops at random from the directory (repeats are
|
||||
/// allowed). The final destination is the client identified by
|
||||
/// `input_options.dst`. Per-hop delays are drawn from
|
||||
/// [`crate::helpers::generate_mix_delay`].
|
||||
fn encrypt(
|
||||
&mut self,
|
||||
input: PipelinePayload<SphinxInputOptions>,
|
||||
) -> PipelinePayload<SphinxInputOptions> {
|
||||
let first_hop = input.options.first_hop.as_sphinx_node_socket();
|
||||
|
||||
let route = std::iter::once(first_hop)
|
||||
.chain(
|
||||
self.directory
|
||||
.random_route(2, &mut self.rng)
|
||||
.iter()
|
||||
.map(|n| n.as_sphinx_node_socket()),
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let destination = input.options.dst.as_sphinx_destination();
|
||||
|
||||
let delays = (0..route.len())
|
||||
.map(|_| Delay::new_from_millis(helpers::generate_mix_delay(&mut self.rng)))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Useful payload size is packet size - transport overhead - framing overhead - routing overhead
|
||||
let plaintext_size =
|
||||
<Self as WireWrappingPipeline<SimMixPacket, SphinxInputOptions>>::packet_size(self)
|
||||
- <Self as Framing<SphinxInputOptions>>::OVERHEAD_SIZE
|
||||
- <Self as Transport<SimMixPacket>>::OVERHEAD_SIZE
|
||||
- <Self as RoutingSecurity<_>>::OVERHEAD_SIZE;
|
||||
|
||||
// Packet builder's size includes the payload overhead so we have to add it
|
||||
let packet_builder = SphinxPacketBuilder::new()
|
||||
.with_payload_size(plaintext_size + nym_sphinx::PAYLOAD_OVERHEAD_SIZE);
|
||||
|
||||
// SAFETY : If the pipeline is built correctly, the packet building should not fail.
|
||||
// If it does, something is wrong with the code. If it crashes it's fine since it's a simulator anyway
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let packet = packet_builder
|
||||
.build_packet(input.data.data, &route, &destination, &delays)
|
||||
.unwrap();
|
||||
|
||||
PipelinePayload::new(
|
||||
input.data.timestamp,
|
||||
packet.to_bytes(),
|
||||
input.options,
|
||||
input.dst,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Rng> NoOpWireWrapper for SphinxClientWrappingPipeline<R> {}
|
||||
|
||||
impl<R: Rng> ClientWrappingPipeline<SimMixPacket, SphinxInputOptions>
|
||||
for SphinxClientWrappingPipeline<R>
|
||||
{
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Unwrapping pipeline for [`SphinxClient`].
|
||||
///
|
||||
/// Receives the raw final-hop payload (the last Sphinx layer has already been
|
||||
/// stripped by the terminal mix node), recovers the plaintext, filters cover
|
||||
/// traffic, and reassembles Sphinx fragments into complete messages.
|
||||
#[derive(Default)]
|
||||
pub struct SphinxClientUnwrapping {
|
||||
message_reconstructor: MessageReconstructor,
|
||||
}
|
||||
|
||||
impl NoOpWireUnwrapper for SphinxClientUnwrapping {}
|
||||
|
||||
impl ClientUnwrappingPipeline<Vec<u8>, ()> for SphinxClientUnwrapping {
|
||||
fn process_unwrapped(&mut self, timed_plaintext: TimedPayload, _: ()) -> Option<Vec<u8>> {
|
||||
let plaintext = timed_plaintext.data;
|
||||
|
||||
// Ditch cover traffic
|
||||
if nym_sphinx::cover::is_cover(&plaintext) {
|
||||
tracing::debug!("Received cover traffic packet");
|
||||
return None;
|
||||
}
|
||||
|
||||
// TODO Route acks elsewhere HERE
|
||||
if SurbAck::is_surb_ack(&plaintext) {
|
||||
// SAFETY : casting slice of len 8 into array of len 8
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let id = u64::from_le_bytes(plaintext[8..16].try_into().unwrap());
|
||||
tracing::debug!("Received a SURB_ACK for id : {id}");
|
||||
return None;
|
||||
}
|
||||
|
||||
let fragment = Fragment::try_from_bytes(&plaintext)
|
||||
.inspect_err(|e| tracing::warn!("Failed to deserialize fragment : {e}"))
|
||||
.ok()?;
|
||||
|
||||
if let Some(reconstructed_message) =
|
||||
self.message_reconstructor.insert_new_fragment(fragment)
|
||||
{
|
||||
let message = PaddedMessage::from(reconstructed_message.0)
|
||||
.remove_padding()
|
||||
.inspect_err(|e| tracing::warn!("Failed to remove padding : {e}"))
|
||||
.ok()?;
|
||||
Some(message.into_inner_data())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Poisson cover traffic generator.
|
||||
//!
|
||||
//! Implements the [`Obfuscation`] trait for [`SphinxClient`] using two
|
||||
//! independent Poisson processes:
|
||||
//!
|
||||
//! * **Main loop** — schedules one slot per inter-arrival time drawn from an
|
||||
//! exponential distribution. Real messages are injected into these slots; if
|
||||
//! no real message is ready when a slot fires, a cover-traffic payload is sent
|
||||
//! instead.
|
||||
//! * **Secondary loop** — independently fires cover-traffic packets at a lower
|
||||
//! rate, providing additional traffic volume that is independent of the main
|
||||
//! loop's cadence.
|
||||
//!
|
||||
//! Together the two loops ensure that an observer cannot determine from traffic
|
||||
//! patterns alone whether the client is actively sending real messages.
|
||||
//!
|
||||
//! [`SphinxClient`]: super::SphinxClient
|
||||
|
||||
use std::{sync::Arc, time::Instant};
|
||||
|
||||
use nym_lp_data::{PipelinePayload, clients::traits::Obfuscation};
|
||||
use nym_sphinx::cover::LOOP_COVER_MESSAGE_PAYLOAD;
|
||||
use rand::Rng;
|
||||
|
||||
use crate::{
|
||||
client::sphinx::SphinxInputOptions,
|
||||
helpers,
|
||||
topology::directory::{Directory, DirectoryClient},
|
||||
};
|
||||
|
||||
/// Two-loop Poisson cover traffic generator.
|
||||
///
|
||||
/// Maintains two independent next-fire timestamps — one for the main sending
|
||||
/// loop and one for the secondary cover loop — and advances them by independent
|
||||
/// exponential delays on each firing.
|
||||
pub struct PoissonCoverTraffic<R>
|
||||
where
|
||||
R: Rng,
|
||||
{
|
||||
cover_dst: DirectoryClient,
|
||||
directory: Arc<Directory>,
|
||||
/// Timestamp at which the main loop next fires (real or cover packet).
|
||||
main_loop_next_timestamp: Instant,
|
||||
/// Timestamp at which the secondary cover loop next fires.
|
||||
secondary_loop_next_timestamp: Instant,
|
||||
/// Random number generator used for exponential delay sampling.
|
||||
rng: R,
|
||||
}
|
||||
|
||||
impl<R> PoissonCoverTraffic<R>
|
||||
where
|
||||
R: Rng,
|
||||
{
|
||||
/// Construct a new cover traffic generator.
|
||||
///
|
||||
/// Both loops are initialised to fire immediately at `current_timestamp` so
|
||||
/// that cover traffic begins on the very first tick.
|
||||
pub fn new(
|
||||
cover_dst: DirectoryClient,
|
||||
directory: Arc<Directory>,
|
||||
current_timestamp: Instant,
|
||||
rng: R,
|
||||
) -> Self {
|
||||
Self {
|
||||
cover_dst,
|
||||
directory,
|
||||
main_loop_next_timestamp: current_timestamp,
|
||||
secondary_loop_next_timestamp: current_timestamp,
|
||||
rng,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build [`SphinxInputOptions`] for a self-addressed cover-traffic packet.
|
||||
///
|
||||
/// The destination is set to this client's own address and the first hop is
|
||||
/// chosen at random from the directory, matching the real-message behaviour.
|
||||
pub fn cover_traffic_options(&mut self) -> SphinxInputOptions {
|
||||
SphinxInputOptions {
|
||||
dst: self.cover_dst,
|
||||
first_hop: self.directory.random_next_hop(&mut self.rng),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<R> Obfuscation<SphinxInputOptions> for PoissonCoverTraffic<R>
|
||||
where
|
||||
R: Rng,
|
||||
{
|
||||
/// Produce the set of payloads to send at `timestamp`.
|
||||
///
|
||||
/// Called once per tick with an optional real message (`input`). May
|
||||
/// return zero, one, or two payloads depending on which loops fire and
|
||||
/// whether a real message is available.
|
||||
fn obfuscate(
|
||||
&mut self,
|
||||
input: Option<PipelinePayload<SphinxInputOptions>>,
|
||||
timestamp: Instant,
|
||||
) -> Vec<PipelinePayload<SphinxInputOptions>> {
|
||||
let mut output = Vec::new();
|
||||
|
||||
// Secondary cover traffic loop
|
||||
// We should not schedule those in advance, because backpressure can't tell if it has real or cover traffic.
|
||||
if timestamp >= self.secondary_loop_next_timestamp {
|
||||
let cover_options = self.cover_traffic_options();
|
||||
output.push(PipelinePayload::new(
|
||||
timestamp,
|
||||
LOOP_COVER_MESSAGE_PAYLOAD.to_vec(),
|
||||
cover_options,
|
||||
cover_options.first_hop.addr,
|
||||
));
|
||||
self.secondary_loop_next_timestamp +=
|
||||
helpers::generate_cover_traffic_delay(&mut self.rng);
|
||||
}
|
||||
|
||||
// Main cover traffic loop
|
||||
|
||||
match input {
|
||||
// If we have a message, schedule it for the next timestamp, prepare the following one
|
||||
Some(real_message) => {
|
||||
output.push(real_message.with_timestamp(self.main_loop_next_timestamp));
|
||||
self.main_loop_next_timestamp += helpers::generate_sending_delay(&mut self.rng);
|
||||
}
|
||||
// No message, but we need to send something => Send cover traffic right away, prepare next timestamp
|
||||
None if timestamp >= self.main_loop_next_timestamp => {
|
||||
let cover_options = self.cover_traffic_options();
|
||||
output.push(PipelinePayload::new(
|
||||
timestamp,
|
||||
LOOP_COVER_MESSAGE_PAYLOAD.to_vec(),
|
||||
cover_options,
|
||||
cover_options.first_hop.addr,
|
||||
));
|
||||
self.main_loop_next_timestamp += helpers::generate_sending_delay(&mut self.rng);
|
||||
}
|
||||
// No message, not the time to send anything, nothing to do
|
||||
None => {}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! SURB-ACK reliability layer for [`SphinxClient`].
|
||||
//!
|
||||
//! Implements the [`Reliability`] trait by prepending a single-use reply block
|
||||
//! (SURB) acknowledgement to every outgoing payload. The SURB allows the
|
||||
//! recipient to send a compact acknowledgement back to the sender through the
|
||||
//! mix network without revealing the sender's address to intermediate nodes.
|
||||
//!
|
||||
//! [`SphinxClient`]: super::SphinxClient
|
||||
|
||||
use std::{sync::Arc, time::Instant};
|
||||
|
||||
use crate::{
|
||||
client::sphinx::SphinxInputOptions,
|
||||
packet::sphinx::SurbAck,
|
||||
topology::directory::{Directory, DirectoryClient},
|
||||
};
|
||||
|
||||
use nym_lp_data::{PipelinePayload, clients::traits::Reliability};
|
||||
|
||||
use rand::Rng;
|
||||
|
||||
/// Prepends a freshly-constructed SURB acknowledgement to every outgoing packet.
|
||||
///
|
||||
/// Each call to [`Reliability::reliable_encode`] builds a new [`SurbAck`] keyed
|
||||
/// to a random 64-bit packet identifier, prepends it to the payload, and returns
|
||||
/// the augmented packet. If `input` is `None` (cover-traffic slot) no packet
|
||||
/// is produced.
|
||||
pub struct SurbAcksReliability<R>
|
||||
where
|
||||
R: Rng,
|
||||
{
|
||||
ack_dst: DirectoryClient,
|
||||
directory: Arc<Directory>,
|
||||
rng: R,
|
||||
}
|
||||
|
||||
impl<R> SurbAcksReliability<R>
|
||||
where
|
||||
R: Rng,
|
||||
{
|
||||
/// Create a new SURB-ACK reliability layer.
|
||||
///
|
||||
/// `address` is used as the SURB reply destination so that ACKs are routed
|
||||
/// back to this client. `directory` is used to sample the 3-hop SURB route.
|
||||
pub fn new(rng: R, ack_dst: DirectoryClient, directory: Arc<Directory>) -> Self {
|
||||
Self {
|
||||
ack_dst,
|
||||
directory,
|
||||
rng,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<R> Reliability<SphinxInputOptions> for SurbAcksReliability<R>
|
||||
where
|
||||
R: Rng,
|
||||
{
|
||||
const OVERHEAD_SIZE: usize = SurbAck::len();
|
||||
|
||||
/// Prepend a SURB ACK to `input`, or return an empty vec for cover slots.
|
||||
///
|
||||
/// A fresh [`SurbAck`] is constructed for each real packet so that every
|
||||
/// in-flight packet carries a unique acknowledgement path.
|
||||
fn reliable_encode(
|
||||
&mut self,
|
||||
input: Option<PipelinePayload<SphinxInputOptions>>,
|
||||
_: Instant,
|
||||
) -> Vec<PipelinePayload<SphinxInputOptions>> {
|
||||
if let Some(packet) = input {
|
||||
let random_id = self.rng.next_u64();
|
||||
tracing::debug!("Generating SURB Ack with ID {random_id}");
|
||||
let surb_ack =
|
||||
SurbAck::construct::<R>(&mut self.rng, self.ack_dst, random_id, &self.directory)
|
||||
.prepare_for_sending()
|
||||
.1;
|
||||
let reliable_packet = packet.data_transform(|payload| {
|
||||
surb_ack.iter().copied().chain(payload).collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
vec![reliable_packet]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Top-level simulation orchestrator.
|
||||
//!
|
||||
//! [`MixSimDriver`] owns the complete list of [`MixSimNode`]s and
|
||||
//! [`MixSimClient`]s and is the single entry point for running the simulation.
|
||||
//! It is responsible for:
|
||||
//!
|
||||
//! 1. **Bootstrapping** — building the shared [`Directory`](crate::topology::directory::Directory)
|
||||
//! from pre-constructed nodes and clients, then distributing it to every participant.
|
||||
//! 2. **Ticking** — advancing every node and client through the phases of a
|
||||
//! simulation step (client tick → incoming → processing → outgoing).
|
||||
//! 3. **Driving** — either automatically (sleeping between ticks) or manually
|
||||
//! (waiting for the user to press ENTER).
|
||||
//!
|
||||
//! Nodes and clients are built externally (e.g. in [`SimpleMixDriver`]) and
|
||||
//! passed to [`MixSimDriver::new`] as boxed trait objects, so the driver only
|
||||
//! needs to know the timestamp type `Ts`.
|
||||
//!
|
||||
//! To inject packets into a running simulation, use the standalone `mix-client`
|
||||
//! binary, which sends payloads to a client's app socket.
|
||||
|
||||
use std::{
|
||||
fmt::Debug,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use tracing::info;
|
||||
|
||||
use crate::{client::MixSimClient, node::MixSimNode};
|
||||
|
||||
mod nymnode;
|
||||
mod simple;
|
||||
mod sphinx;
|
||||
|
||||
pub use nymnode::NymNodeMixDriver;
|
||||
pub use simple::SimpleMixDriver;
|
||||
pub use sphinx::SphinxMixDriver;
|
||||
|
||||
/// Top-level orchestrator for the mix-network simulation.
|
||||
///
|
||||
/// Holds ordered lists of type-erased [`MixSimNode`]s and [`MixSimClient`]s.
|
||||
/// Only the timestamp type `Ts` is visible at this level; packet format, frame
|
||||
/// type, and message marker are encapsulated inside each concrete node/client.
|
||||
pub struct MixSimDriver {
|
||||
nodes: Vec<Box<dyn MixSimNode + Send>>,
|
||||
clients: Vec<Box<dyn MixSimClient + Send>>,
|
||||
clock_base: Instant,
|
||||
}
|
||||
|
||||
impl MixSimDriver {
|
||||
/// Construct the driver from pre-built nodes and clients.
|
||||
///
|
||||
/// Topology parsing and socket binding are the caller's responsibility.
|
||||
pub fn new(
|
||||
nodes: Vec<Box<dyn MixSimNode + Send>>,
|
||||
clients: Vec<Box<dyn MixSimClient + Send>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
nodes,
|
||||
clients,
|
||||
clock_base: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_tick(&self, tick: Instant) -> u128 {
|
||||
tick.duration_since(self.clock_base).as_millis()
|
||||
}
|
||||
|
||||
/// Pretty-print the current state of every node at `tick`.
|
||||
pub fn display_state(&self, tick: Instant) {
|
||||
println!(
|
||||
"┌─── Tick {:─<3} ms─────────────────────────────────────────────────────────┐",
|
||||
self.display_tick(tick)
|
||||
);
|
||||
for node in &self.nodes {
|
||||
node.display_state();
|
||||
println!("|------------------------------------------------------------------------|")
|
||||
}
|
||||
println!("└────────────────────────────────────────────────────────────────────────┘");
|
||||
}
|
||||
|
||||
/// Advance the simulation by one tick.
|
||||
///
|
||||
/// ## Phases
|
||||
///
|
||||
/// 1. **Client** - clients tick.
|
||||
/// 2. **Incoming** — every node drains its UDP socket into `packets_to_process`.
|
||||
/// 3. *(optional state display)*
|
||||
/// 4. **Processing** — every node mixes buffered packets.
|
||||
/// 5. *(optional state display)*
|
||||
/// 6. **Outgoing** — nodes forward due packets;
|
||||
pub fn tick(&mut self, timestamp: Instant, display_state: bool) {
|
||||
for client in &mut self.clients {
|
||||
client.tick(timestamp);
|
||||
}
|
||||
// Phase 1 — incoming
|
||||
for node in &mut self.nodes {
|
||||
node.tick_incoming();
|
||||
}
|
||||
|
||||
if display_state {
|
||||
self.display_state(timestamp);
|
||||
}
|
||||
|
||||
// Phase 2 — processing
|
||||
for node in &mut self.nodes {
|
||||
node.tick_processing(timestamp);
|
||||
}
|
||||
|
||||
if display_state {
|
||||
self.display_state(timestamp);
|
||||
}
|
||||
|
||||
// Phase 3 — outgoing
|
||||
for node in &mut self.nodes {
|
||||
node.tick_outgoing(timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the simulation in either manual or automatic mode.
|
||||
pub async fn run(
|
||||
self,
|
||||
manual_mode: bool,
|
||||
display_state: bool,
|
||||
tick_duration_ms: u64,
|
||||
) -> anyhow::Result<()> {
|
||||
if manual_mode {
|
||||
self.run_manual(tick_duration_ms, display_state)
|
||||
} else {
|
||||
self.run_automatic(tick_duration_ms).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the simulation automatically, advancing one tick every
|
||||
/// `tick_duration_ms` milliseconds until Ctrl-C is received.
|
||||
pub async fn run_automatic(mut self, tick_duration_ms: u64) -> anyhow::Result<()> {
|
||||
info!("Automatic mode: tick duration : {tick_duration_ms} ms");
|
||||
let tick_duration = Duration::from_millis(tick_duration_ms);
|
||||
let handle = tokio::spawn(async move {
|
||||
loop {
|
||||
let current_tick = Instant::now();
|
||||
self.tick(current_tick, false);
|
||||
tokio::time::sleep(tick_duration).await;
|
||||
}
|
||||
});
|
||||
tokio::signal::ctrl_c().await?;
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the simulation interactively: one tick per ENTER key press.
|
||||
pub fn run_manual(mut self, tick_duration_ms: u64, display_state: bool) -> anyhow::Result<()> {
|
||||
info!("Manual mode: press ENTER to advance a tick, Ctrl-C to quit");
|
||||
info!("One tick represent {tick_duration_ms}ms");
|
||||
let tick_duration = Duration::from_millis(tick_duration_ms);
|
||||
let mut current_tick = self.clock_base;
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
std::io::stdin().read_line(&mut line)?;
|
||||
info!("Tick {}ms", self.display_tick(current_tick));
|
||||
self.tick(current_tick, display_state);
|
||||
current_tick += tick_duration;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which simulation driver to use.
|
||||
#[derive(Clone, Debug, Default, strum::Display, strum::EnumString)]
|
||||
#[strum(serialize_all = "kebab-case")]
|
||||
pub enum SimDriver {
|
||||
/// Simple pass-through packets.
|
||||
Simple,
|
||||
/// Full Sphinx encryption with SURBACKs and cover traffic
|
||||
Sphinx,
|
||||
/// Real [`NymNodeDataPipeline`] processing sphinx-in-LP packets.
|
||||
///
|
||||
/// [`NymNodeDataPipeline`]: nym_node::node::lp::data::handler::pipeline::NymNodeDataPipeline
|
||||
#[default]
|
||||
NymNode,
|
||||
}
|
||||
|
||||
impl SimDriver {
|
||||
/// Dispatch to the appropriate concrete driver and start the simulation.
|
||||
pub async fn run(
|
||||
self,
|
||||
topology: String,
|
||||
manual: bool,
|
||||
display_state: bool,
|
||||
tick_duration_ms: u64,
|
||||
) -> anyhow::Result<()> {
|
||||
match self {
|
||||
SimDriver::Simple => {
|
||||
SimpleMixDriver::new(topology)?
|
||||
.run(manual, display_state, tick_duration_ms)
|
||||
.await
|
||||
}
|
||||
SimDriver::Sphinx => {
|
||||
SphinxMixDriver::new(topology)?
|
||||
.run(manual, display_state, tick_duration_ms)
|
||||
.await
|
||||
}
|
||||
SimDriver::NymNode => {
|
||||
NymNodeMixDriver::new(topology)?
|
||||
.run(manual, display_state, tick_duration_ms)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! [`NymNodeMixDriver`] — concrete driver running the real
|
||||
//! [`NymNodeDataPipeline`] for each mix node, with sphinx-in-LP clients.
|
||||
//!
|
||||
//! Uses wall-clock [`Instant`] timestamps because [`NymNodeDataPipeline`] is
|
||||
//! hardcoded to that timestamp type. Manual stepping is therefore disabled.
|
||||
//!
|
||||
//! [`NymNodeDataPipeline`]: nym_node::node::lp::data::handler::pipeline::NymNodeDataPipeline
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use rand::rngs::OsRng;
|
||||
|
||||
use crate::{
|
||||
client::{MixSimClient, nymnode::SimNymClient},
|
||||
driver::MixSimDriver,
|
||||
node::{MixSimNode, nymnode::SimNymNode},
|
||||
topology::{Topology, directory::Directory},
|
||||
};
|
||||
|
||||
/// Concrete [`MixSimDriver`] instantiation that runs the real
|
||||
/// [`NymNodeDataPipeline`] inside every node and produces sphinx-in-LP packets
|
||||
/// from every client.
|
||||
///
|
||||
/// [`NymNodeDataPipeline`]: nym_node::node::lp::data::handler::pipeline::NymNodeDataPipeline
|
||||
pub struct NymNodeMixDriver(MixSimDriver);
|
||||
|
||||
impl NymNodeMixDriver {
|
||||
/// Load a topology JSON file and initialise the driver with one
|
||||
/// [`SimNymNode`] per topology node and one [`SimNymClient`] per
|
||||
/// topology client.
|
||||
///
|
||||
/// [`SimNymNode`]: crate::node::nymnode::SimNymNode
|
||||
/// [`SimNymClient`]: crate::client::nymnode::SimNymClient
|
||||
pub fn new(topology: String) -> anyhow::Result<Self> {
|
||||
let topology_data =
|
||||
std::fs::read_to_string(&topology).context("Failed to read topology file")?;
|
||||
let topology: Topology =
|
||||
serde_json::from_str(&topology_data).context("Topology file malformed")?;
|
||||
|
||||
let directory: Arc<Directory> = Arc::new((&topology).into());
|
||||
|
||||
let mut nodes: Vec<Box<dyn MixSimNode + Send>> = Vec::with_capacity(topology.nodes.len());
|
||||
for top_node in topology.nodes {
|
||||
let node = SimNymNode::new(top_node, directory.clone(), OsRng)?;
|
||||
nodes.push(Box::new(node));
|
||||
}
|
||||
|
||||
let mut clients: Vec<Box<dyn MixSimClient + Send>> =
|
||||
Vec::with_capacity(topology.clients.len());
|
||||
for top_client in topology.clients {
|
||||
let client = SimNymClient::new(top_client, directory.clone(), OsRng)?;
|
||||
clients.push(Box::new(client));
|
||||
}
|
||||
|
||||
Ok(NymNodeMixDriver(MixSimDriver::new(nodes, clients)))
|
||||
}
|
||||
|
||||
/// Run the simulation; delegates to [`MixSimDriver::run`].
|
||||
///
|
||||
pub async fn run(
|
||||
self,
|
||||
manual_mode: bool,
|
||||
display_state: bool,
|
||||
tick_duration_ms: u64,
|
||||
) -> anyhow::Result<()> {
|
||||
self.0
|
||||
.run(manual_mode, display_state, tick_duration_ms)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! [`SimpleMixDriver`] — concrete driver using the simple (non-Sphinx) packet pipeline.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
|
||||
use crate::{
|
||||
client::{MixSimClient, simple::SimpleClient},
|
||||
driver::MixSimDriver,
|
||||
node::{MixSimNode, simple::SimpleNode},
|
||||
topology::{Topology, directory::Directory},
|
||||
};
|
||||
|
||||
/// Concrete [`MixSimDriver`] instantiation that uses
|
||||
/// [`SimplePacket`](crate::packet::simple::SimplePacket)s and a pass-through
|
||||
/// processing pipeline.
|
||||
///
|
||||
/// Each mix node runs a [`SimpleProcessingNode`] that forwards packets
|
||||
/// unchanged to the next node in the topology; each client uses a
|
||||
/// [`SimpleClientWrappingPipeline`] with no Sphinx layering, reliability
|
||||
/// encoding, or obfuscation.
|
||||
///
|
||||
/// [`SimpleProcessingNode`]: crate::node::simple::SimpleProcessingNode
|
||||
/// [`SimpleClientWrappingPipeline`]: crate::client::simple::SimpleClientWrappingPipeline
|
||||
pub struct SimpleMixDriver(MixSimDriver);
|
||||
|
||||
impl SimpleMixDriver {
|
||||
/// Load a topology JSON file and initialise the driver with simple pipelines.
|
||||
pub fn new(topology: String) -> anyhow::Result<Self> {
|
||||
let topology_data =
|
||||
std::fs::read_to_string(&topology).context("Failed to read topology file")?;
|
||||
let topology: Topology =
|
||||
serde_json::from_str(&topology_data).context("Topology file malformed")?;
|
||||
|
||||
let directory: Arc<Directory> = Arc::new((&topology).into());
|
||||
|
||||
let mut nodes: Vec<Box<dyn MixSimNode + Send>> = Vec::with_capacity(topology.nodes.len());
|
||||
for top_node in topology.nodes {
|
||||
let node = SimpleNode::new(top_node, directory.clone())?;
|
||||
nodes.push(Box::new(node));
|
||||
}
|
||||
|
||||
let mut clients: Vec<Box<dyn MixSimClient + Send>> =
|
||||
Vec::with_capacity(topology.clients.len());
|
||||
for top_client in topology.clients {
|
||||
let client = SimpleClient::new(top_client, directory.clone())?;
|
||||
clients.push(Box::new(client));
|
||||
}
|
||||
|
||||
Ok(SimpleMixDriver(MixSimDriver::new(nodes, clients)))
|
||||
}
|
||||
|
||||
/// Run the simulation; delegates to [`MixSimDriver::run`].
|
||||
pub async fn run(
|
||||
self,
|
||||
manual_mode: bool,
|
||||
display_state: bool,
|
||||
tick_duration_ms: u64,
|
||||
) -> anyhow::Result<()> {
|
||||
self.0
|
||||
.run(manual_mode, display_state, tick_duration_ms)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Sphinx-based driver variants.
|
||||
//!
|
||||
//! Two flavours are provided:
|
||||
//!
|
||||
//! * [`SphinxMixDriver`] — wall-clock ([`Instant`]) timestamps; automatic mode only.
|
||||
//! * [`DiscreteSphinxMixDriver`] — discrete `u32` tick counter (1 tick = 1 ms);
|
||||
//! supports both automatic and manual stepping modes.
|
||||
|
||||
use std::{sync::Arc, time::Instant};
|
||||
|
||||
use anyhow::Context;
|
||||
use rand::rngs::OsRng;
|
||||
|
||||
use crate::{
|
||||
client::{MixSimClient, sphinx::SphinxClient},
|
||||
driver::MixSimDriver,
|
||||
node::{MixSimNode, sphinx::SphinxNode},
|
||||
topology::{Topology, directory::Directory},
|
||||
};
|
||||
|
||||
/// Concrete [`MixSimDriver`] instantiation that uses [`SphinxPacket`](nym_sphinx::SphinxPacket)s.
|
||||
pub struct SphinxMixDriver(MixSimDriver);
|
||||
|
||||
impl SphinxMixDriver {
|
||||
/// Load a topology JSON file and initialise the driver with Sphinx pipelines.
|
||||
pub fn new(topology: String) -> anyhow::Result<Self> {
|
||||
let topology_data =
|
||||
std::fs::read_to_string(&topology).context("Failed to read topology file")?;
|
||||
let topology: Topology =
|
||||
serde_json::from_str(&topology_data).context("Topology file malformed")?;
|
||||
|
||||
let directory: Arc<Directory> = Arc::new((&topology).into());
|
||||
|
||||
let mut nodes: Vec<Box<dyn MixSimNode + Send>> = Vec::with_capacity(topology.nodes.len());
|
||||
for top_node in topology.nodes {
|
||||
let node = SphinxNode::new(top_node, directory.clone())?;
|
||||
nodes.push(Box::new(node));
|
||||
}
|
||||
|
||||
let mut clients: Vec<Box<dyn MixSimClient + Send>> =
|
||||
Vec::with_capacity(topology.clients.len());
|
||||
for top_client in topology.clients {
|
||||
let client = SphinxClient::new(top_client, directory.clone(), Instant::now(), OsRng)?;
|
||||
clients.push(Box::new(client));
|
||||
}
|
||||
|
||||
Ok(SphinxMixDriver(MixSimDriver::new(nodes, clients)))
|
||||
}
|
||||
|
||||
/// Run the simulation; delegates to [`MixSimDriver::run`].
|
||||
///
|
||||
/// `manual_mode` is ignored: [`Instant`]-based drivers cannot be stepped
|
||||
/// manually because wall-clock time cannot be advanced by keypress.
|
||||
pub async fn run(
|
||||
self,
|
||||
manual_mode: bool,
|
||||
display_state: bool,
|
||||
tick_duration_ms: u64,
|
||||
) -> anyhow::Result<()> {
|
||||
self.0
|
||||
.run(manual_mode, display_state, tick_duration_ms)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use rand::Rng;
|
||||
use rand_distr::{Distribution, Exp};
|
||||
|
||||
/// Exponential with mean 50 ms.
|
||||
pub fn generate_mix_delay(rng: &mut impl Rng) -> u64 {
|
||||
// SAFETY : hardcoded > 0 value
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let exp: Exp<f64> = Exp::new(1.0 / 50.0).unwrap();
|
||||
exp.sample(rng).round() as u64
|
||||
}
|
||||
|
||||
/// Exponential with mean 20 ms.
|
||||
pub fn generate_sending_delay(rng: &mut impl Rng) -> Duration {
|
||||
// SAFETY : hardcoded > 0 value
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let exp: Exp<f64> = Exp::new(1.0 / 20.0).unwrap();
|
||||
Duration::from_millis(exp.sample(rng).round() as u64)
|
||||
}
|
||||
|
||||
/// Exponential with mean 200 ms.
|
||||
pub fn generate_cover_traffic_delay(rng: &mut impl Rng) -> Duration {
|
||||
// SAFETY : hardcoded > 0 value
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let exp: Exp<f64> = Exp::new(1.0 / 200.0).unwrap();
|
||||
Duration::from_millis(exp.sample(rng).round() as u64)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! # nym-mix-sim
|
||||
//!
|
||||
//! A discrete-time simulator for a Nym mixnet, intended for local testing and
|
||||
//! experimentation. The simulator models a network of mix nodes that exchange
|
||||
//! UDP packets on localhost.
|
||||
//!
|
||||
//! ## Architecture overview
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌──────────────┐ JSON ┌───────────────────────────────┐
|
||||
//! │ topology.json│ ─────────────▶ │ MixSimDriver │
|
||||
//! └──────────────┘ │ ├─ Node 0 (UDP :9000) │
|
||||
//! │ ├─ Node N (UDP :900N) │
|
||||
//! │ ├─ Client 0 (UDP :9500/:9600)│
|
||||
//! │ └─ Client C (UDP :950C/:960C)│
|
||||
//! └───────────────────────────────┘
|
||||
//!
|
||||
//! Each simulation tick:
|
||||
//! 1. client tick – every client drains its app socket, queues outgoing
|
||||
//! packets, and processes inbound mix packets
|
||||
//! 2. tick_incoming – every node drains its UDP socket into an inbound buffer
|
||||
//! 3. tick_processing – every node transforms buffered packets (mix operation)
|
||||
//! 4. tick_outgoing – every node forwards processed packets to the next hop
|
||||
//! ```
|
||||
//!
|
||||
//! ## Crate layout
|
||||
//!
|
||||
//! | Module | Purpose |
|
||||
//! |--------|---------|
|
||||
//! | [`driver`] | Top-level orchestrator; owns all nodes and clients, drives simulation ticks |
|
||||
//! | [`node`] | Individual mix node: UDP socket, inbound/outbound packet buffers |
|
||||
//! | [`client`] | Simulated client: injects application payloads into the mix network |
|
||||
//! | [`packet`] | Wire format types and the [`packet::WirePacketFormat`] trait |
|
||||
//! | [`topology`] | Topology file types and the in-memory [`topology::directory::Directory`] |
|
||||
|
||||
pub mod client;
|
||||
pub mod driver;
|
||||
pub mod helpers;
|
||||
pub mod node;
|
||||
pub mod packet;
|
||||
pub mod topology;
|
||||
@@ -0,0 +1,130 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Binary entry point for the `nym-mix-sim` CLI tool.
|
||||
//!
|
||||
//! Provides two subcommands:
|
||||
//!
|
||||
//! * **`init-topology`** — generate a `topology.json` file describing N
|
||||
//! localhost mix nodes and C clients, with sequential UDP ports.
|
||||
//! * **`run`** — load a topology, spin up the chosen [`SimDriver`], and drive
|
||||
//! the simulation until Ctrl-C. Supports automatic tick mode (configurable
|
||||
//! interval via `--tick-duration-ms`) or manual RETURN-driven stepping
|
||||
//! (`--manual`). Use the standalone `mix-client` binary to inject packets
|
||||
//! while the simulation is running.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use nym_mix_sim::{
|
||||
driver::SimDriver,
|
||||
topology::{Topology, TopologyClient, TopologyNode},
|
||||
};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "nym-mix-sim", about = "Nym mix network simulator")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Generate a topology.json file with a given number of nodes and one client
|
||||
InitTopology {
|
||||
/// Number of mix nodes to generate.
|
||||
///
|
||||
/// Each node receives an auto-assigned ID (0..N-1) and a sequential
|
||||
/// localhost address starting at `127.0.0.1:9000`.
|
||||
#[arg(short, long, default_value_t = 3)]
|
||||
nodes: u8,
|
||||
|
||||
/// Number of clients to generate.
|
||||
///
|
||||
/// Each client receives an auto-assigned ID (`N..N+C`) and two
|
||||
/// sequential localhost addresses: a mix-network socket starting at
|
||||
/// `127.0.0.1:9500` and an app socket starting at `127.0.0.1:9600`.
|
||||
#[arg(short, long, default_value_t = 2)]
|
||||
clients: u8,
|
||||
|
||||
/// Output file path
|
||||
#[arg(short, long, default_value = "topology.json")]
|
||||
output: String,
|
||||
},
|
||||
|
||||
/// Run the mix simulation with a given topology file
|
||||
Run {
|
||||
/// Path to the topology.json file
|
||||
#[arg(short, long, default_value = "topology.json")]
|
||||
topology: String,
|
||||
|
||||
/// Use manual (RETURN-driven) mode instead of automatic ticks.
|
||||
#[arg(short, long)]
|
||||
manual: bool,
|
||||
|
||||
/// Suppress node state display after each tick phase (manual mode only).
|
||||
#[arg(long)]
|
||||
no_display_state: bool,
|
||||
|
||||
/// Tick duration in milliseconds.
|
||||
#[arg(short = 'd', long, default_value = "10")]
|
||||
tick_duration_ms: u64,
|
||||
|
||||
/// Simulation driver to use: simple | sphinx | nym-node (default).
|
||||
#[arg(long, default_value_t = SimDriver::NymNode)]
|
||||
driver: SimDriver,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
nym_bin_common::logging::setup_tracing_logger();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Commands::InitTopology {
|
||||
nodes,
|
||||
clients,
|
||||
output,
|
||||
} => {
|
||||
info!("Generating topology with {nodes} node(s) and {clients} client(s)");
|
||||
let node_list = (0..nodes)
|
||||
.map(|id| {
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], 9000 + id as u16));
|
||||
TopologyNode::new(id, 100, addr)
|
||||
})
|
||||
.collect();
|
||||
// Client binds to the next port after all nodes.
|
||||
let client_list = (nodes..nodes + clients)
|
||||
.map(|id| {
|
||||
let mix_addr = SocketAddr::from(([127, 0, 0, 1], 9500 + id as u16));
|
||||
let app_addr = SocketAddr::from(([127, 0, 0, 1], 9600 + id as u16));
|
||||
TopologyClient::new(id, mix_addr, app_addr)
|
||||
})
|
||||
.collect();
|
||||
let topology = Topology {
|
||||
nodes: node_list,
|
||||
clients: client_list,
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&topology)?;
|
||||
std::fs::write(&output, &json)?;
|
||||
info!("Topology written to {output}");
|
||||
}
|
||||
Commands::Run {
|
||||
topology,
|
||||
manual,
|
||||
no_display_state,
|
||||
tick_duration_ms,
|
||||
driver,
|
||||
} => {
|
||||
info!("Loading topology from {topology} with driver={driver}");
|
||||
driver
|
||||
.run(topology, manual, !no_display_state, tick_duration_ms)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use std::{
|
||||
fmt::Debug,
|
||||
io::ErrorKind,
|
||||
net::{SocketAddr, UdpSocket},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use nym_lp_data::{AddressedTimedData, nymnodes::traits::NymNodeProcessingPipeline};
|
||||
|
||||
use crate::packet::WirePacketFormat;
|
||||
|
||||
pub mod nymnode;
|
||||
pub mod simple;
|
||||
pub mod sphinx;
|
||||
|
||||
/// Compact identifier for a mix node in the simulation topology.
|
||||
///
|
||||
/// `u8` keeps the IDs small (max 255 nodes) and is large enough for any
|
||||
/// realistic simulated topology.
|
||||
pub type NodeId = u8;
|
||||
|
||||
/// Driver-facing interface for a mix node.
|
||||
///
|
||||
/// Erases `Pkt` and `Pn` so that [`MixSimDriver`] only needs `Ts`.
|
||||
/// Implemented by [`BaseNode<Ts, Pkt, Pn>`] for any compatible `Pkt` and
|
||||
/// `Pn`.
|
||||
///
|
||||
/// [`MixSimDriver`]: crate::driver::MixSimDriver
|
||||
pub trait MixSimNode: Send {
|
||||
/// **Phase 1** — drain the UDP socket into the inbound buffer
|
||||
fn tick_incoming(&mut self);
|
||||
|
||||
/// **Phase 2** — pass every buffered packet through the mix pipeline and
|
||||
/// move the results into the outbound queue.
|
||||
fn tick_processing(&mut self, timestamp: Instant);
|
||||
|
||||
/// **Phase 3** — forward all outbound packets whose scheduled timestamp is
|
||||
/// ≤ `timestamp` to their next-hop address.
|
||||
fn tick_outgoing(&mut self, timestamp: Instant);
|
||||
|
||||
/// Pretty-print the node's current buffer state to stdout (used in manual mode).
|
||||
fn display_state(&self);
|
||||
}
|
||||
|
||||
/// Full mix-node state: UDP transport, routing directory, packet buffers, and
|
||||
/// processing pipeline.
|
||||
///
|
||||
/// `Pkt` is the wire packet type (e.g. [`SimplePacket`] or [`SimMixPacket`]).
|
||||
/// `Pn` is any type that implements [`NymNodeProcessingPipeline<Pkt>`].
|
||||
///
|
||||
/// Concrete node variants (`SimpleNode`, `SphinxNode`, …) are type aliases
|
||||
/// over this struct and only need to supply a `new()` constructor that wires
|
||||
/// up the right pipeline.
|
||||
///
|
||||
/// [`SimplePacket`]: crate::packet::simple::SimplePacket
|
||||
/// [`SimMixPacket`]: crate::packet::sphinx::SimMixPacket
|
||||
pub struct BaseNode<Pkt, Pn> {
|
||||
/// Identifier of this node within the topology.
|
||||
pub(crate) id: NodeId,
|
||||
/// Notional reliability percentage; not yet used by the simulator but kept
|
||||
/// so future tests can drive the reliability layer.
|
||||
_reliability: u8,
|
||||
/// UDP address this node is bound to.
|
||||
pub(crate) socket_address: SocketAddr,
|
||||
/// Non-blocking UDP socket used for both receive and send.
|
||||
socket: UdpSocket,
|
||||
|
||||
/// Inbound buffer: raw packets drained from the socket in `tick_incoming`,
|
||||
/// ready to be fed through the mix pipeline in `tick_processing`.
|
||||
packets_to_process: Vec<Pkt>,
|
||||
/// Outbound buffer: packets produced by the mix pipeline, each tagged with
|
||||
/// the timestamp at which it should be released by `tick_outgoing`.
|
||||
processed_packets: Vec<AddressedTimedData<Pkt>>,
|
||||
|
||||
/// Concrete mix-processing implementation invoked by `tick_processing`.
|
||||
processing_node: Pn,
|
||||
}
|
||||
|
||||
impl<Pkt, Pn> BaseNode<Pkt, Pn> {
|
||||
/// Bind a non-blocking UDP socket to `socket_address` and initialise the
|
||||
/// node with the given `pipeline`.
|
||||
pub(crate) fn with_pipeline(
|
||||
id: NodeId,
|
||||
reliability: u8,
|
||||
socket_address: SocketAddr,
|
||||
processing_node: Pn,
|
||||
) -> anyhow::Result<Self> {
|
||||
let socket = UdpSocket::bind(socket_address)?;
|
||||
socket.set_nonblocking(true)?;
|
||||
Ok(Self {
|
||||
id,
|
||||
_reliability: reliability,
|
||||
socket_address,
|
||||
socket,
|
||||
packets_to_process: Vec::new(),
|
||||
processed_packets: Vec::new(),
|
||||
processing_node,
|
||||
})
|
||||
}
|
||||
|
||||
/// Send `packet` to the destination identified by `address`.
|
||||
///
|
||||
/// Serialises via [`WirePacketFormat::to_bytes`], and dispatches with a
|
||||
/// single `sendto`. Errors are logged but not propagated.
|
||||
pub fn send_to(&self, address: SocketAddr, packet: Pkt)
|
||||
where
|
||||
Pkt: WirePacketFormat,
|
||||
{
|
||||
if let Err(e) = self.socket.send_to(&packet.to_bytes(), address) {
|
||||
tracing::error!("[Node {}] Failed to send data to {address} : {e}", self.id);
|
||||
} else {
|
||||
tracing::debug!("[Node {}] Successfully sent a packet to {address}", self.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to receive one UDP datagram and deserialise it as `Pkt`.
|
||||
///
|
||||
/// Returns `None` when the socket would block (no datagram waiting).
|
||||
pub fn recv_packet(&self) -> Option<anyhow::Result<Pkt>>
|
||||
where
|
||||
Pkt: WirePacketFormat,
|
||||
{
|
||||
let mut buf = [0; 1500];
|
||||
let (nb_bytes, src_address) = match self.socket.recv_from(&mut buf) {
|
||||
Ok(result) => result,
|
||||
Err(e) if e.kind() == ErrorKind::WouldBlock => return None,
|
||||
Err(e) => {
|
||||
tracing::error!("Error receiving packet : {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
tracing::debug!(
|
||||
"[Node {}] Received {nb_bytes} bytes from {src_address}",
|
||||
self.id
|
||||
);
|
||||
Some(Pkt::try_from_bytes(&buf[..nb_bytes]))
|
||||
}
|
||||
}
|
||||
|
||||
impl<Pkt, Pn> MixSimNode for BaseNode<Pkt, Pn>
|
||||
where
|
||||
Pkt: WirePacketFormat + Debug + Send,
|
||||
Pn: NymNodeProcessingPipeline<Pkt> + Send,
|
||||
<Pn as nym_lp_data::common::traits::TransportUnwrap<Pkt>>::Error: Debug,
|
||||
{
|
||||
fn tick_incoming(&mut self) {
|
||||
while let Some(maybe_packet) = self.recv_packet() {
|
||||
match maybe_packet {
|
||||
Ok(packet) => self.packets_to_process.push(packet),
|
||||
Err(e) => tracing::error!("[Node {}] Failed to deserialize packet : {e}", self.id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tick_processing(&mut self, timestamp: Instant) {
|
||||
while let Some(packet) = self.packets_to_process.pop() {
|
||||
match self.processing_node.process(packet, timestamp) {
|
||||
Ok(processed_packets) => self.processed_packets.extend(processed_packets),
|
||||
Err(e) => {
|
||||
tracing::error!("[Node {}] Failed to process packet : {e:?}", self.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tick_outgoing(&mut self, timestamp: Instant) {
|
||||
let to_send = self
|
||||
.processed_packets
|
||||
.extract_if(.., |pkt| pkt.data.timestamp <= timestamp)
|
||||
.collect::<Vec<_>>();
|
||||
for pkt in to_send {
|
||||
self.send_to(pkt.dst, pkt.data.data);
|
||||
}
|
||||
}
|
||||
|
||||
fn display_state(&self) {
|
||||
println!("│ Node {:2} @ {}", self.id, self.socket_address);
|
||||
if self.packets_to_process.is_empty() {
|
||||
println!("│ to_process buffer: (empty)");
|
||||
} else {
|
||||
println!(
|
||||
"│ to_process buffer: {} packet(s)",
|
||||
self.packets_to_process.len()
|
||||
);
|
||||
for (i, pkt) in self.packets_to_process.iter().enumerate() {
|
||||
println!("│ [{i}] {pkt:#?}");
|
||||
}
|
||||
}
|
||||
|
||||
if self.processed_packets.is_empty() {
|
||||
println!("│ processed buffer: (empty)");
|
||||
} else {
|
||||
println!(
|
||||
"│ processed buffer: {} packet(s)",
|
||||
self.processed_packets.len()
|
||||
);
|
||||
for (i, pkt) in self.processed_packets.iter().enumerate() {
|
||||
println!("│ [{i}] {pkt:#?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! [`SimNymNode`] — mix node that runs the real [`NymNodeDataPipeline`].
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use nym_lp_data::packet::EncryptedLpPacket;
|
||||
use nym_node::node::lp::data::{
|
||||
handler::pipeline::NymNodeDataPipeline,
|
||||
shared::{SharedGatewayLpDataState, SharedLpDataState},
|
||||
};
|
||||
use rand::Rng;
|
||||
|
||||
use crate::{
|
||||
node::BaseNode,
|
||||
topology::{TopologyNode, directory::Directory},
|
||||
};
|
||||
|
||||
/// A simulated mix node driven by the real [`NymNodeDataPipeline`].
|
||||
///
|
||||
/// This is a type alias for [`BaseNode`] specialised to [`EncryptedLpPacket`]
|
||||
/// and [`NymNodeDataPipeline`]. All tick logic lives in the generic
|
||||
/// [`MixSimNode`] impl on `BaseNode`; routing produces [`std::net::SocketAddr`]s
|
||||
/// directly (no Directory lookup is needed).
|
||||
///
|
||||
/// [`MixSimNode`]: crate::node::MixSimNode
|
||||
pub type SimNymNode<R> = BaseNode<EncryptedLpPacket, NymNodeDataPipeline<R>>;
|
||||
|
||||
impl<R: Rng + Send> SimNymNode<R> {
|
||||
/// Create a [`SimNymNode`] from a [`TopologyNode`] description by binding
|
||||
/// a non-blocking UDP socket to `node.socket_address` and constructing a
|
||||
/// simulation-ready [`NymNodeDataPipeline`] with the node's sphinx key.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the UDP socket cannot be bound or set non-blocking.
|
||||
pub fn new(
|
||||
topology_node: TopologyNode,
|
||||
directory: Arc<Directory>,
|
||||
rng: R,
|
||||
) -> anyhow::Result<Self> {
|
||||
let shared = Arc::new(SharedLpDataState::new_for_simulation(
|
||||
topology_node.sphinx_private_key,
|
||||
));
|
||||
let gateway = Arc::new(SharedGatewayLpDataState::new_for_simulation(
|
||||
directory.as_nym_topology(),
|
||||
directory.as_client_map(),
|
||||
));
|
||||
let pipeline = NymNodeDataPipeline::new(shared, gateway, rng);
|
||||
BaseNode::with_pipeline(
|
||||
topology_node.node_id,
|
||||
topology_node.reliability,
|
||||
topology_node.socket_address,
|
||||
pipeline,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! [`SimpleNode`] — mix node using the simple (non-Sphinx) packet pipeline.
|
||||
|
||||
use std::{net::SocketAddr, sync::Arc, time::Instant};
|
||||
|
||||
use nym_lp_data::{
|
||||
AddressedTimedData, AddressedTimedPayload, TimedData, TimedPayload,
|
||||
common::traits::{
|
||||
Framing, FramingUnwrap, Transport, TransportUnwrap, WireUnwrappingPipeline,
|
||||
WireWrappingPipeline,
|
||||
},
|
||||
nymnodes::traits::NymNodeProcessingPipeline,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
node::{BaseNode, NodeId},
|
||||
packet::simple::{SimpleFrame, SimplePacket, SimpleWireUnwrapper, SimpleWireWrapper},
|
||||
topology::{TopologyNode, directory::Directory},
|
||||
};
|
||||
|
||||
/// A mix-node that uses the simple (non-Sphinx) packet pipeline.
|
||||
///
|
||||
/// This is a type alias for [`BaseNode`] specialised to [`SimplePacket`] and
|
||||
/// [`SimpleProcessingNode`]. All tick logic lives in the generic
|
||||
/// [`MixSimNode`] impl on `BaseNode`.
|
||||
///
|
||||
/// [`MixSimNode`]: crate::node::MixSimNode
|
||||
pub type SimpleNode = BaseNode<SimplePacket, SimpleProcessingNode>;
|
||||
|
||||
impl SimpleNode {
|
||||
/// Create a [`SimpleNode`] from a [`TopologyNode`] description by binding a
|
||||
/// non-blocking UDP socket to `node.socket_address`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the UDP socket cannot be bound or set non-blocking.
|
||||
pub fn new(topology_node: TopologyNode, directory: Arc<Directory>) -> anyhow::Result<Self> {
|
||||
let pipeline = SimpleProcessingNode::new(topology_node.node_id, directory);
|
||||
BaseNode::with_pipeline(
|
||||
topology_node.node_id,
|
||||
topology_node.reliability,
|
||||
topology_node.socket_address,
|
||||
pipeline,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A simple [`NymNodeProcessingPipeline`] for [`SimplePacket`].
|
||||
///
|
||||
/// Demonstrates the full pipeline: unwraps the incoming packet through the
|
||||
/// wire layer (transport → frame → payload), applies a routing decision in
|
||||
/// [`NymNodeProcessingPipeline::mix`] (forwards to `self.id + 1`), then
|
||||
/// re-wraps the outgoing payload (payload → frame → transport) before sending.
|
||||
pub struct SimpleProcessingNode {
|
||||
next_hop: SocketAddr,
|
||||
wrapper: SimpleWireWrapper,
|
||||
unwrapper: SimpleWireUnwrapper,
|
||||
}
|
||||
|
||||
impl SimpleProcessingNode {
|
||||
/// Construct a pipeline for the node identified by `id`.
|
||||
pub fn new(id: NodeId, directory: Arc<Directory>) -> Self {
|
||||
// SAFETY : clients have the highest ID so there will be something ad id+1
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let next_hop = directory
|
||||
.node(id + 1)
|
||||
.map(|n| n.addr)
|
||||
.or(directory.client(id + 1).map(|c| c.addr))
|
||||
.unwrap();
|
||||
Self {
|
||||
next_hop,
|
||||
wrapper: SimpleWireWrapper,
|
||||
unwrapper: SimpleWireUnwrapper,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NymNodeProcessingPipeline<SimplePacket> for SimpleProcessingNode {
|
||||
type Options = ();
|
||||
type MessageKind = ();
|
||||
|
||||
/// Route the payload to the next node in the chain (`self.id + 1`).
|
||||
///
|
||||
/// This is a trivial fixed routing rule used for simulation testing.
|
||||
fn mix(
|
||||
&mut self,
|
||||
_: (),
|
||||
payload: TimedPayload,
|
||||
_timestamp: Instant,
|
||||
) -> Vec<AddressedTimedPayload> {
|
||||
vec![AddressedTimedPayload::new_addressed(
|
||||
payload.timestamp,
|
||||
payload.data,
|
||||
self.next_hop,
|
||||
)]
|
||||
}
|
||||
}
|
||||
|
||||
// Delegation of subtraits
|
||||
impl Framing<()> for SimpleProcessingNode {
|
||||
type Frame = SimpleFrame;
|
||||
const OVERHEAD_SIZE: usize = <SimpleWireWrapper as Framing<_>>::OVERHEAD_SIZE;
|
||||
fn to_frame(
|
||||
&mut self,
|
||||
payload: AddressedTimedPayload,
|
||||
frame_size: usize,
|
||||
) -> Vec<AddressedTimedData<SimpleFrame>> {
|
||||
self.wrapper.to_frame(payload, frame_size)
|
||||
}
|
||||
}
|
||||
|
||||
impl Transport<SimplePacket> for SimpleProcessingNode {
|
||||
type Frame = SimpleFrame;
|
||||
const OVERHEAD_SIZE: usize = <SimpleWireWrapper as Transport<_>>::OVERHEAD_SIZE;
|
||||
fn to_transport_packet(
|
||||
&mut self,
|
||||
frame: AddressedTimedData<SimpleFrame>,
|
||||
) -> AddressedTimedData<SimplePacket> {
|
||||
self.wrapper.to_transport_packet(frame)
|
||||
}
|
||||
}
|
||||
|
||||
impl WireWrappingPipeline<SimplePacket, ()> for SimpleProcessingNode {
|
||||
fn packet_size(&self) -> usize {
|
||||
<SimpleWireWrapper as WireWrappingPipeline<_, _>>::packet_size(&self.wrapper)
|
||||
}
|
||||
}
|
||||
|
||||
impl FramingUnwrap<()> for SimpleProcessingNode {
|
||||
type Frame = SimpleFrame;
|
||||
fn frame_to_message(&mut self, frame: TimedData<SimpleFrame>) -> Option<(TimedPayload, ())> {
|
||||
self.unwrapper.frame_to_message(frame)
|
||||
}
|
||||
}
|
||||
|
||||
impl TransportUnwrap<SimplePacket> for SimpleProcessingNode {
|
||||
type Frame = SimpleFrame;
|
||||
type Error = anyhow::Error;
|
||||
fn packet_to_frame(
|
||||
&mut self,
|
||||
packet: SimplePacket,
|
||||
timestamp: Instant,
|
||||
) -> anyhow::Result<TimedData<SimpleFrame>> {
|
||||
self.unwrapper.packet_to_frame(packet, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
impl WireUnwrappingPipeline<SimplePacket, ()> for SimpleProcessingNode {}
|
||||
@@ -0,0 +1,202 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! [`SphinxNode`] — mix node using the full Sphinx packet pipeline.
|
||||
|
||||
use std::{sync::Arc, time::Instant};
|
||||
|
||||
use nym_crypto::asymmetric::x25519;
|
||||
use nym_lp_data::{
|
||||
AddressedTimedData, AddressedTimedPayload, TimedPayload,
|
||||
common::helpers::{NoOpWireUnwrapper, NoOpWireWrapper},
|
||||
nymnodes::traits::NymNodeProcessingPipeline,
|
||||
};
|
||||
use nym_sphinx::SphinxPacket;
|
||||
use nym_sphinx_addressing::nodes::NymNodeRoutingAddress;
|
||||
|
||||
use crate::{
|
||||
node::{BaseNode, NodeId},
|
||||
packet::{
|
||||
WirePacketFormat,
|
||||
sphinx::{SimMixPacket, SurbAck},
|
||||
},
|
||||
topology::{TopologyNode, directory::Directory},
|
||||
};
|
||||
|
||||
/// A mix-node that uses the Sphinx packet pipeline.
|
||||
///
|
||||
/// This is a type alias for [`BaseNode`] specialised to [`SimMixPacket`] and
|
||||
/// [`SphinxProcessingNode`]. All tick logic lives in the generic
|
||||
/// [`MixSimNode`] impl on `BaseNode`.
|
||||
///
|
||||
/// [`MixSimNode`]: crate::node::MixSimNode
|
||||
pub type SphinxNode = BaseNode<SimMixPacket, SphinxProcessingNode>;
|
||||
|
||||
impl SphinxNode {
|
||||
/// Create a [`SphinxNode`] from a [`TopologyNode`] description by binding a
|
||||
/// non-blocking UDP socket to `node.socket_address`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the UDP socket cannot be bound or set non-blocking.
|
||||
pub fn new(topology_node: TopologyNode, directory: Arc<Directory>) -> anyhow::Result<Self> {
|
||||
let pipeline = SphinxProcessingNode::new(
|
||||
topology_node.node_id,
|
||||
topology_node.sphinx_private_key,
|
||||
directory,
|
||||
);
|
||||
BaseNode::with_pipeline(
|
||||
topology_node.node_id,
|
||||
topology_node.reliability,
|
||||
topology_node.socket_address,
|
||||
pipeline,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A [`NymNodeProcessingPipeline`] for [`SphinxPacket`].
|
||||
///
|
||||
/// Uses no-op framing and transport wrappers because a Sphinx packet is already
|
||||
/// its own self-contained wire unit — no additional framing or transport header
|
||||
/// is needed. The real work happens in [`mix`](SphinxProcessingNode::mix), which
|
||||
/// peels one onion layer with the node's private key and extracts the next-hop
|
||||
/// address and per-hop delay.
|
||||
pub struct SphinxProcessingNode {
|
||||
id: NodeId,
|
||||
sphinx_secret: x25519::PrivateKey,
|
||||
directory: Arc<Directory>,
|
||||
}
|
||||
|
||||
impl SphinxProcessingNode {
|
||||
/// Construct a pipeline for the node identified by `node_id`, using
|
||||
/// `sphinx_secret` to decrypt incoming Sphinx packets.
|
||||
pub fn new(
|
||||
node_id: NodeId,
|
||||
sphinx_secret: x25519::PrivateKey,
|
||||
directory: Arc<Directory>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: node_id,
|
||||
sphinx_secret,
|
||||
directory,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NymNodeProcessingPipeline<SimMixPacket> for SphinxProcessingNode {
|
||||
type MessageKind = ();
|
||||
type Options = ();
|
||||
|
||||
/// Peel one Sphinx layer and forward or deliver the result.
|
||||
///
|
||||
/// - **ForwardHop**: extracts the next-hop packet, address (byte 0 of the
|
||||
/// 32-byte address field encodes the [`NodeId`]), and per-hop delay; schedules
|
||||
/// the re-wrapped packet at `timestamp + delay`.
|
||||
/// - **FinalHop**: delivers the plaintext payload directly to the destination
|
||||
/// client (identified by byte 0 of the destination address).
|
||||
fn mix(
|
||||
&mut self,
|
||||
_: (),
|
||||
payload: TimedPayload,
|
||||
timestamp: Instant,
|
||||
) -> Vec<AddressedTimedPayload> {
|
||||
// SAFETY: Given the no-op unwrapper used here, payload.data is always a
|
||||
// valid serialised SphinxPacket at this point.
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let sphinx_packet = SphinxPacket::from_bytes(&payload.data).unwrap();
|
||||
|
||||
match sphinx_packet.process(self.sphinx_secret.inner()) {
|
||||
Ok(packet) => match packet.data {
|
||||
nym_sphinx::ProcessedPacketData::ForwardHop {
|
||||
next_hop_packet,
|
||||
next_hop_address,
|
||||
delay,
|
||||
} => {
|
||||
let Ok(routing_address) = next_hop_address.try_into() else {
|
||||
tracing::warn!("[Node {}] Cannot recover routing address", self.id);
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let NymNodeRoutingAddress::Node(next_hop_address) = routing_address else {
|
||||
tracing::warn!(
|
||||
"[Node {}] Received a sphinx packet with a Client routing address",
|
||||
self.id
|
||||
);
|
||||
return Vec::new();
|
||||
};
|
||||
let timed_sphinx = AddressedTimedData::new_addressed(
|
||||
timestamp + delay.to_duration(),
|
||||
next_hop_packet.to_bytes(),
|
||||
next_hop_address,
|
||||
);
|
||||
vec![timed_sphinx]
|
||||
}
|
||||
nym_sphinx::ProcessedPacketData::FinalHop {
|
||||
destination,
|
||||
identifier: _,
|
||||
payload,
|
||||
} => {
|
||||
let mut packets_to_forward = Vec::new();
|
||||
|
||||
if let Ok(plaintext) = payload
|
||||
.recover_plaintext()
|
||||
.inspect_err(|e| tracing::warn!("Impossible to recover plaintext : {e}"))
|
||||
{
|
||||
let (surb_ack_bytes, message) = SurbAck::extract_ack_and_message(plaintext);
|
||||
|
||||
// Client packet handling
|
||||
if let Some(client_socket_address) = self
|
||||
.directory
|
||||
.client(destination.as_bytes()[0])
|
||||
.map(|n| n.addr)
|
||||
{
|
||||
packets_to_forward.push(AddressedTimedData::new_addressed(
|
||||
timestamp,
|
||||
message,
|
||||
client_socket_address,
|
||||
));
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"[Node {}] Client {} not found in the directory",
|
||||
self.id,
|
||||
destination.as_bytes()[0]
|
||||
);
|
||||
};
|
||||
|
||||
// SURB_ACK handling
|
||||
if !surb_ack_bytes.is_empty()
|
||||
&& let Ok((next_hop, surb_ack)) = SurbAck::try_recover_first_hop_packet(
|
||||
&surb_ack_bytes,
|
||||
)
|
||||
.inspect_err(|e| tracing::warn!("Fail to deserialize SURB Ack : {e}"))
|
||||
{
|
||||
if let Some(next_hop_socket_address) =
|
||||
self.directory.node(next_hop).map(|n| n.addr)
|
||||
{
|
||||
packets_to_forward.push(AddressedTimedData::new_addressed(
|
||||
timestamp,
|
||||
surb_ack.to_bytes(),
|
||||
next_hop_socket_address,
|
||||
));
|
||||
} else {
|
||||
tracing::warn!("Node {next_hop} not found in the directory",);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
packets_to_forward
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!("[Node {}] Failed to process a sphinx packet : {e}", self.id);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Boilerplate subtraits delegation
|
||||
impl NoOpWireWrapper for SphinxProcessingNode {}
|
||||
impl NoOpWireUnwrapper for SphinxProcessingNode {}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Packet types and the generic wire-format trait used by the simulation.
|
||||
//!
|
||||
//! The central abstraction is [`WirePacketFormat`]: a trait that any packet
|
||||
//! type must implement to participate in a simulation. It covers only
|
||||
//! wire serialisation; mix logic is handled separately by
|
||||
//! [`nym_lp_data::nymnodes::traits::NymNodeProcessingPipeline`].
|
||||
//!
|
||||
|
||||
pub mod nymnode;
|
||||
pub mod simple;
|
||||
pub mod sphinx;
|
||||
|
||||
/// Trait that every packet type must implement to participate in the simulation.
|
||||
///
|
||||
pub trait WirePacketFormat: Sized {
|
||||
/// Deserialise a packet from the raw bytes received off the wire.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Should return an error on length mismatch, invalid magic bytes, or any
|
||||
/// other malformed-datagram condition.
|
||||
fn try_from_bytes(bytes: &[u8]) -> anyhow::Result<Self>;
|
||||
|
||||
/// Serialise the packet to its on-wire byte representation, ready to be
|
||||
/// sent via UDP.
|
||||
fn to_bytes(&self) -> Vec<u8>;
|
||||
}
|
||||
|
||||
impl WirePacketFormat for Vec<u8> {
|
||||
fn try_from_bytes(bytes: &[u8]) -> anyhow::Result<Self> {
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
fn to_bytes(&self) -> Vec<u8> {
|
||||
self.clone()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use nym_lp_data::packet::EncryptedLpPacket;
|
||||
|
||||
use crate::packet::WirePacketFormat;
|
||||
|
||||
impl WirePacketFormat for EncryptedLpPacket {
|
||||
fn to_bytes(&self) -> Vec<u8> {
|
||||
self.to_bytes()
|
||||
}
|
||||
|
||||
fn try_from_bytes(bytes: &[u8]) -> anyhow::Result<Self> {
|
||||
Ok(EncryptedLpPacket::decode(bytes)?)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use std::{fmt::Debug, time::Instant};
|
||||
|
||||
use nym_common::debug::format_debug_bytes;
|
||||
use nym_lp_data::{
|
||||
AddressedTimedData, AddressedTimedPayload, TimedData, TimedPayload,
|
||||
common::traits::{
|
||||
Framing, FramingUnwrap, Transport, TransportUnwrap, WireUnwrappingPipeline,
|
||||
WireWrappingPipeline,
|
||||
},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::packet::WirePacketFormat;
|
||||
|
||||
/// A minimal, fixed-size packet used by the simulation.
|
||||
///
|
||||
/// ## Wire format
|
||||
///
|
||||
/// ```text
|
||||
/// ┌──────────────────┬──────────────────────────────────────────────────┐
|
||||
/// │ UUID (16 bytes) │ payload (48 bytes) │
|
||||
/// │ little-endian │ │
|
||||
/// └──────────────────┴──────────────────────────────────────────────────┘
|
||||
/// byte 0 16 64
|
||||
/// ```
|
||||
///
|
||||
/// The total on-wire size is always exactly [`SimplePacket::SIZE`] = 64 bytes.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct SimplePacket {
|
||||
/// Universally unique identifier assigned at creation time (UUID v4).
|
||||
/// Used to correlate a packet across hops for debugging and tracing.
|
||||
pub id: Uuid,
|
||||
|
||||
/// Variable-length payload buffer.
|
||||
///
|
||||
/// Despite the type being `Vec<u8>`, the simulation always creates and
|
||||
/// expects exactly 48 bytes here (i.e. `SIZE - 16`). The `Vec` is used
|
||||
/// rather than a fixed array to keep serialisation simple.
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Debug for SimplePacket {
|
||||
/// Pretty-prints the packet ID followed by a hex dump of the payload via
|
||||
/// [`format_debug_bytes`].
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
writeln!(f, "SimplePacket {{")?;
|
||||
writeln!(f, " id: {:?},", self.id)?;
|
||||
writeln!(f, " data:")?;
|
||||
for line in format_debug_bytes(&self.data)?.lines() {
|
||||
writeln!(f, " {line}")?;
|
||||
}
|
||||
write!(f, "}}")
|
||||
}
|
||||
}
|
||||
|
||||
impl SimplePacket {
|
||||
/// On-wire size of a serialised [`SimplePacket`] in bytes.
|
||||
///
|
||||
/// Layout: 16 bytes UUID (LE) + 48 bytes payload = 64 bytes total.
|
||||
const SIZE: usize = 64;
|
||||
const UUID_SIZE: usize = 16;
|
||||
|
||||
/// Create a new [`SimplePacket`] with a freshly generated UUID v4 and the
|
||||
/// provided 48-byte payload.
|
||||
///
|
||||
/// The payload array is exactly `SIZE - 16 = 48` bytes so that the packet
|
||||
/// serialises to exactly [`SimplePacket::SIZE`] bytes.
|
||||
pub fn new(data: [u8; Self::SIZE - Self::UUID_SIZE]) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
data: data.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the packet's UUID identifier.
|
||||
pub fn id(&self) -> Uuid {
|
||||
self.id
|
||||
}
|
||||
|
||||
/// Return a clone of the raw payload bytes.
|
||||
pub fn data(&self) -> Vec<u8> {
|
||||
self.data.clone()
|
||||
}
|
||||
|
||||
/// Serialise the packet to its fixed-size wire representation.
|
||||
///
|
||||
/// Layout: UUID as 16 little-endian bytes, followed by the 48-byte payload.
|
||||
/// The returned `Vec` is always exactly [`SimplePacket::SIZE`] bytes long.
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
// fixed-size serialization: 16-byte UUID followed by 48-byte payload
|
||||
let mut bytes = Vec::with_capacity(Self::SIZE);
|
||||
|
||||
bytes.extend_from_slice(&self.id.to_bytes_le()); // 16 bytes
|
||||
bytes.extend_from_slice(&self.data); // 48 bytes
|
||||
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Deserialise a [`SimplePacket`] from a raw byte slice.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if `bytes.len() != SIZE` (64). Any other slice length
|
||||
/// indicates a truncated or corrupted UDP datagram.
|
||||
pub fn try_from_bytes(bytes: &[u8]) -> anyhow::Result<Self> {
|
||||
if bytes.len() != Self::SIZE {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Length mismatch to deserialize a SimplePacket : Expected {}, got {}",
|
||||
Self::SIZE,
|
||||
bytes.len()
|
||||
));
|
||||
}
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let uuid = Uuid::from_bytes_le(bytes[0..Self::UUID_SIZE].try_into().unwrap());
|
||||
let data = bytes[Self::UUID_SIZE..Self::SIZE].to_vec();
|
||||
Ok(SimplePacket { id: uuid, data })
|
||||
}
|
||||
}
|
||||
|
||||
/// [`WirePacketFormat`] implementation for [`SimplePacket`].
|
||||
impl WirePacketFormat for SimplePacket {
|
||||
fn try_from_bytes(bytes: &[u8]) -> anyhow::Result<Self> {
|
||||
Self::try_from_bytes(bytes)
|
||||
}
|
||||
|
||||
fn to_bytes(&self) -> Vec<u8> {
|
||||
self.to_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
/// Intermediate frame type used by the simple client pipeline.
|
||||
///
|
||||
/// A `SimpleFrame` wraps a chunk of payload bytes with a fixed 7-byte magic
|
||||
/// header (`b"0FRAME0"`). It is produced by the [`Framing`] stage and
|
||||
/// consumed by the [`Transport`] stage, which packs it into a [`SimplePacket`].
|
||||
pub struct SimpleFrame {
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl SimpleFrame {
|
||||
/// Magic header prepended to every serialised frame.
|
||||
pub const HEADER: &[u8; 7] = b"0FRAME0";
|
||||
|
||||
/// Serialise the frame: magic header followed by the payload bytes.
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
let mut bytes = Vec::new();
|
||||
|
||||
bytes.extend_from_slice(Self::HEADER);
|
||||
bytes.extend_from_slice(&self.data);
|
||||
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Deserialise a [`SimpleFrame`] by stripping the leading magic header.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if `bytes` is shorter than the 7-byte header.
|
||||
pub fn try_from_bytes(bytes: &[u8]) -> anyhow::Result<Self> {
|
||||
if bytes.len() < Self::HEADER.len() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Length mismatch to deserialize a SimpleFrame : Expected at least {}, got {}",
|
||||
Self::HEADER.len(),
|
||||
bytes.len()
|
||||
));
|
||||
}
|
||||
let data = bytes[Self::HEADER.len()..].to_vec();
|
||||
Ok(SimpleFrame { data })
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Building blocks
|
||||
|
||||
/// Wrapping building block: `SimpleFrame` → `SimplePacket`.
|
||||
///
|
||||
/// Implements [`Framing`], [`Transport`], and [`WireWrappingPipeline`] for the
|
||||
/// `SimpleFrame`/`SimplePacket` pair in one place. Compose this into any
|
||||
/// pipeline that needs wire-wrapping by delegating to `SimpleWireWrapper`.
|
||||
pub struct SimpleWireWrapper;
|
||||
|
||||
impl Framing<()> for SimpleWireWrapper {
|
||||
type Frame = SimpleFrame;
|
||||
const OVERHEAD_SIZE: usize = SimpleFrame::HEADER.len();
|
||||
fn to_frame(
|
||||
&mut self,
|
||||
payload: AddressedTimedPayload,
|
||||
frame_size: usize,
|
||||
) -> Vec<AddressedTimedData<SimpleFrame>> {
|
||||
payload
|
||||
.data
|
||||
.data
|
||||
.chunks(frame_size)
|
||||
.map(|chunk| {
|
||||
AddressedTimedData::new_addressed(
|
||||
payload.data.timestamp,
|
||||
SimpleFrame {
|
||||
data: chunk.to_vec(),
|
||||
},
|
||||
payload.dst,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Transport wraps a [`SimpleFrame`] into a [`SimplePacket`].
|
||||
/// Overhead = 16 bytes (UUID), so effective payload = 48 bytes.
|
||||
impl Transport<SimplePacket> for SimpleWireWrapper {
|
||||
type Frame = SimpleFrame;
|
||||
const OVERHEAD_SIZE: usize = 16;
|
||||
fn to_transport_packet(
|
||||
&mut self,
|
||||
frame: AddressedTimedData<SimpleFrame>,
|
||||
) -> AddressedTimedData<SimplePacket> {
|
||||
// SAFETY: If the pipeline is implemented properly, frames perfectly fit in a packet
|
||||
#[allow(clippy::unwrap_used)]
|
||||
frame.data_transform(|inner| SimplePacket::new(inner.to_bytes().try_into().unwrap()))
|
||||
}
|
||||
}
|
||||
|
||||
impl WireWrappingPipeline<SimplePacket, ()> for SimpleWireWrapper {
|
||||
fn packet_size(&self) -> usize {
|
||||
SimplePacket::SIZE
|
||||
}
|
||||
}
|
||||
|
||||
/// Unwrapping building block: `SimplePacket` → payload.
|
||||
///
|
||||
/// Implements [`TransportUnwrap`], [`FramingUnwrap`], and
|
||||
/// [`WireUnwrappingPipeline`] for the `SimpleFrame`/`SimplePacket` pair.
|
||||
/// Compose into any pipeline that needs frame-unwrapping by delegating to
|
||||
/// `SimpleWireUnwrapper`.
|
||||
pub struct SimpleWireUnwrapper;
|
||||
|
||||
impl FramingUnwrap<()> for SimpleWireUnwrapper {
|
||||
type Frame = SimpleFrame;
|
||||
fn frame_to_message(&mut self, frame: TimedData<SimpleFrame>) -> Option<(TimedPayload, ())> {
|
||||
Some((
|
||||
TimedPayload {
|
||||
data: frame.data.data,
|
||||
timestamp: frame.timestamp,
|
||||
},
|
||||
(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl TransportUnwrap<SimplePacket> for SimpleWireUnwrapper {
|
||||
type Frame = SimpleFrame;
|
||||
type Error = anyhow::Error;
|
||||
fn packet_to_frame(
|
||||
&mut self,
|
||||
packet: SimplePacket,
|
||||
timestamp: Instant,
|
||||
) -> anyhow::Result<TimedData<SimpleFrame>> {
|
||||
// packet.data holds the framed bytes (HEADER + payload)
|
||||
Ok(TimedData::new(
|
||||
timestamp,
|
||||
SimpleFrame::try_from_bytes(&packet.data)?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl WireUnwrappingPipeline<SimplePacket, ()> for SimpleWireUnwrapper {}
|
||||
@@ -0,0 +1,206 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use nym_common::debug::format_debug_bytes;
|
||||
use nym_sphinx::{Delay, SphinxPacketBuilder};
|
||||
|
||||
use rand::Rng;
|
||||
|
||||
use std::fmt::Debug;
|
||||
|
||||
use crate::{
|
||||
helpers,
|
||||
node::NodeId,
|
||||
packet::WirePacketFormat,
|
||||
topology::directory::{Directory, DirectoryClient},
|
||||
};
|
||||
|
||||
/// On-wire packet exchanged between mix nodes in the Sphinx pipeline.
|
||||
///
|
||||
/// Wraps a serialised Sphinx packet as a `Vec<u8>` and supplies a
|
||||
/// [`WirePacketFormat`] impl plus a trimmed [`Debug`] implementation that shows
|
||||
/// only the first 32 bytes of the serialised form to avoid flooding logs.
|
||||
pub struct SimMixPacket(Vec<u8>);
|
||||
|
||||
impl Debug for SimMixPacket {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
writeln!(f, "SimMixPacket {{")?;
|
||||
writeln!(f, " data start:")?;
|
||||
if self.0.len() > 32 {
|
||||
for line in format_debug_bytes(&self.0.to_bytes()[..32])?.lines() {
|
||||
writeln!(f, " {line}")?;
|
||||
}
|
||||
} else {
|
||||
for line in format_debug_bytes(&self.0.to_bytes())?.lines() {
|
||||
writeln!(f, " {line}")?;
|
||||
}
|
||||
}
|
||||
write!(f, "}}")
|
||||
}
|
||||
}
|
||||
|
||||
impl WirePacketFormat for SimMixPacket {
|
||||
fn to_bytes(&self) -> Vec<u8> {
|
||||
self.0.clone()
|
||||
}
|
||||
|
||||
fn try_from_bytes(bytes: &[u8]) -> anyhow::Result<Self> {
|
||||
Ok(SimMixPacket(bytes.to_vec()))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<u8>> for SimMixPacket {
|
||||
fn from(value: Vec<u8>) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SimMixPacket> for Vec<u8> {
|
||||
fn from(value: SimMixPacket) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
/// A pre-built Sphinx packet that the recipient sends back as an acknowledgement.
|
||||
///
|
||||
/// A `SurbAck` bundles the serialised Sphinx packet together with the first-hop
|
||||
/// node ID and the expected total mix delay so that the sender can compute the
|
||||
/// latest time by which the ACK should arrive.
|
||||
#[derive(Debug)]
|
||||
pub struct SurbAck {
|
||||
surb_ack_packet: SimMixPacket,
|
||||
first_hop_id: NodeId,
|
||||
expected_total_delay: Delay,
|
||||
}
|
||||
|
||||
impl SurbAck {
|
||||
/// Magic bytes written at the start of every SURB ACK payload so that the
|
||||
/// final-hop node can identify them and route them separately.
|
||||
pub const MARKER: &[u8; 8] = b"SURB_ACK";
|
||||
const ACK_SIZE: usize = 8 + 8; // u64 ID and MARKER
|
||||
const PAYLOAD_SIZE: usize = Self::ACK_SIZE + nym_sphinx::PAYLOAD_OVERHEAD_SIZE;
|
||||
|
||||
/// Build a fresh SURB ACK addressed to `recipient` with unique `packet_id`.
|
||||
///
|
||||
/// Samples a 3-hop route from `directory`, draws per-hop Sphinx delays using
|
||||
/// `Ts::generate_mix_delay`, and constructs a Sphinx packet whose payload is
|
||||
/// `MARKER || packet_id.to_le_bytes()`.
|
||||
pub fn construct<R>(
|
||||
rng: &mut R,
|
||||
recipient: DirectoryClient,
|
||||
packet_id: u64,
|
||||
directory: &Directory,
|
||||
) -> Self
|
||||
where
|
||||
R: Rng,
|
||||
{
|
||||
let route = directory
|
||||
.random_route(3, rng)
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
// SAFETY : We just sampled 3 nodes, the vec isn't empty
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let first_hop_id = route.first().unwrap().id;
|
||||
let sphinx_route = route
|
||||
.into_iter()
|
||||
.map(|n| n.as_sphinx_node_socket())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let destination = recipient.as_sphinx_destination();
|
||||
|
||||
let delays = (0..sphinx_route.len())
|
||||
.map(|_| Delay::new_from_millis(helpers::generate_mix_delay(rng)))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let ack_payload = Self::MARKER
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(packet_id.to_le_bytes())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let builder = SphinxPacketBuilder::new().with_payload_size(Self::PAYLOAD_SIZE);
|
||||
|
||||
// SAFETY : We're living in a simulation, if it crashes, it crashes
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let surb_ack_packet = builder
|
||||
.build_packet(ack_payload, &sphinx_route, &destination, &delays)
|
||||
.unwrap()
|
||||
.to_bytes();
|
||||
|
||||
// in our case, the last hop is a gateway that does NOT do any delays
|
||||
let expected_total_delay = delays.iter().take(delays.len() - 1).sum();
|
||||
|
||||
SurbAck {
|
||||
surb_ack_packet: surb_ack_packet.into(),
|
||||
first_hop_id,
|
||||
expected_total_delay,
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte length of a serialised SURB ACK as prepended to outgoing payloads.
|
||||
///
|
||||
/// Format: `first_hop_id (1 byte) || sphinx_header || ack_payload`.
|
||||
pub const fn len() -> usize {
|
||||
Self::PAYLOAD_SIZE + nym_sphinx::HEADER_SIZE + 1 // SURB_FIRST_HOP || SURB_ACK
|
||||
}
|
||||
|
||||
/// Return the sum of per-hop delays embedded in the SURB packet header.
|
||||
///
|
||||
/// The terminal (gateway) hop is excluded because it applies no mix delay in
|
||||
/// the simulation.
|
||||
pub fn expected_total_delay(&self) -> Delay {
|
||||
self.expected_total_delay
|
||||
}
|
||||
|
||||
/// Serialise the SURB ACK into the wire format prepended to outgoing packets.
|
||||
///
|
||||
/// Returns `(total_delay, first_hop_id || sphinx_packet_bytes)`. The caller
|
||||
/// hands the byte vector to the reliability layer and the delay to the
|
||||
/// scheduler.
|
||||
pub fn prepare_for_sending(self) -> (Delay, Vec<u8>) {
|
||||
// SURB_FIRST_HOP || SURB_ACK
|
||||
let surb_bytes: Vec<_> = std::iter::once(self.first_hop_id)
|
||||
.chain(self.surb_ack_packet.to_bytes())
|
||||
.collect();
|
||||
(self.expected_total_delay, surb_bytes)
|
||||
}
|
||||
|
||||
/// Recover the first-hop node ID and the Sphinx ACK packet from the raw bytes
|
||||
/// produced by [`prepare_for_sending`](Self::prepare_for_sending).
|
||||
///
|
||||
/// This is the partial inverse of `prepare_for_sending`, performed by the
|
||||
/// gateway (final-hop node) when it dispatches the SURB back into the network.
|
||||
pub fn try_recover_first_hop_packet(b: &[u8]) -> anyhow::Result<(NodeId, SimMixPacket)> {
|
||||
let first_hop_id = b[0];
|
||||
let packet = SimMixPacket::try_from_bytes(&b[1..])?;
|
||||
|
||||
Ok((first_hop_id, packet))
|
||||
}
|
||||
|
||||
/// Split a final-hop plaintext into `(surb_ack_bytes, message_bytes)`.
|
||||
///
|
||||
/// If `extracted_data` is shorter than [`SurbAck::len`] (e.g. cover-traffic
|
||||
/// packets carry no SURB), the ACK slice is empty and the full buffer is
|
||||
/// returned as the message.
|
||||
pub fn extract_ack_and_message(mut extracted_data: Vec<u8>) -> (Vec<u8>, Vec<u8>) {
|
||||
let ack_len = SurbAck::len();
|
||||
|
||||
if extracted_data.len() < ack_len {
|
||||
// No SURB Ack in packet, in the sim this will be the case for cover traffic
|
||||
return (Vec::new(), extracted_data);
|
||||
}
|
||||
|
||||
let message = extracted_data.split_off(ack_len);
|
||||
let ack_data = extracted_data;
|
||||
(ack_data, message)
|
||||
}
|
||||
|
||||
/// Return `true` if `data` starts with the [`MARKER`](SurbAck::MARKER) bytes.
|
||||
pub fn is_surb_ack(data: &[u8]) -> bool {
|
||||
if data.len() < Self::MARKER.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
data[..Self::MARKER.len()] == *Self::MARKER
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! In-memory network directory used by nodes to resolve [`NodeId`]s to socket
|
||||
//! addresses at send time.
|
||||
//!
|
||||
//! The [`Directory`] is built once during driver initialisation (after all UDP
|
||||
//! sockets have been bound) and then shared immutably across every node via an
|
||||
//! [`Arc`](std::sync::Arc). This means routing lookups are lock-free and
|
||||
//! allocation-free after startup.
|
||||
|
||||
use std::{collections::HashMap, net::SocketAddr};
|
||||
|
||||
use nym_crypto::asymmetric::{ed25519, x25519};
|
||||
use nym_sphinx::{Destination, DestinationAddressBytes, Node as SphinxNode};
|
||||
use nym_sphinx_addressing::{ClientAddress, nodes::NymNodeRoutingAddress};
|
||||
use nym_topology::{NymTopology, RoutingNode, SupportedRoles};
|
||||
use rand::{SeedableRng, rngs::StdRng, seq::IteratorRandom};
|
||||
|
||||
use crate::{
|
||||
client::ClientId,
|
||||
node::NodeId,
|
||||
topology::{Topology, TopologyClient, TopologyNode},
|
||||
};
|
||||
|
||||
/// Shared, immutable routing table for the simulation.
|
||||
///
|
||||
/// Maps every [`NodeId`] that is part of the current topology to a
|
||||
/// [`DirectoryNode`] entry containing the node's configuration and reachable
|
||||
/// [`SocketAddr`].
|
||||
#[derive(Default, Debug)]
|
||||
pub struct Directory {
|
||||
/// Keyed routing map: node ID → directory entry.
|
||||
nodes: HashMap<NodeId, DirectoryNode>,
|
||||
/// Mix-network socket address for each client, keyed by [`ClientId`].
|
||||
///
|
||||
/// Used by nodes to deliver final-hop packets directly to the target client's
|
||||
/// mix socket rather than forwarding to another node.
|
||||
clients: HashMap<ClientId, DirectoryClient>,
|
||||
}
|
||||
|
||||
impl Directory {
|
||||
/// Look up a node by its [`NodeId`].
|
||||
///
|
||||
/// Returns `None` when `id` is not present in the directory
|
||||
pub fn node(&self, id: NodeId) -> Option<&DirectoryNode> {
|
||||
self.nodes.get(&id)
|
||||
}
|
||||
|
||||
/// Look up a client by its [`ClientId`].
|
||||
///
|
||||
/// Returns `None` when `id` is not present in the directory
|
||||
pub fn client(&self, id: ClientId) -> Option<&DirectoryClient> {
|
||||
self.clients.get(&id)
|
||||
}
|
||||
|
||||
/// Pick a random node from the directory and return its [`NodeId`].
|
||||
///
|
||||
/// Used by Sphinx clients to choose a first-hop node when the simulation has
|
||||
/// no explicit gateway concept.
|
||||
pub fn random_next_hop(&self, rng: &mut impl rand::Rng) -> DirectoryNode {
|
||||
// SAFETY: The directory always contains at least one node in a valid simulation.
|
||||
#[allow(clippy::unwrap_used)]
|
||||
*self.nodes.values().choose(rng).unwrap()
|
||||
}
|
||||
|
||||
/// Sample `length` random hops (with replacement) for a Sphinx route.
|
||||
///
|
||||
/// The same node may appear more than once in the returned vector; the
|
||||
/// simulator does not enforce distinct hops.
|
||||
pub fn random_route(&self, length: usize, rng: &mut impl rand::Rng) -> Vec<DirectoryNode> {
|
||||
// SAFETY: The directory always contains at least one node in a valid simulation.
|
||||
#[allow(clippy::unwrap_used)]
|
||||
std::iter::repeat_with(|| *self.nodes.values().choose(rng).unwrap())
|
||||
.take(length)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build a [`NymTopology`] view of the directory for the real nym-node data
|
||||
/// pipeline's gateway state.
|
||||
pub fn as_nym_topology(&self) -> NymTopology {
|
||||
let mut topology = NymTopology::default();
|
||||
for node in self.nodes.values() {
|
||||
topology.insert_node_details(node.as_routing_node());
|
||||
}
|
||||
topology
|
||||
}
|
||||
|
||||
pub fn as_client_map(&self) -> HashMap<ClientAddress, SocketAddr> {
|
||||
let mut map = HashMap::new();
|
||||
for client in self.clients.values() {
|
||||
map.insert(client.client_address(), client.addr);
|
||||
}
|
||||
map
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Topology> for Directory {
|
||||
/// Build a [`Directory`] from a full [`Topology`], extracting only the
|
||||
/// public routing information (addresses and public keys) from each entry.
|
||||
fn from(value: &Topology) -> Self {
|
||||
let mut directory = Directory::default();
|
||||
for node in &value.nodes {
|
||||
directory.nodes.insert(node.node_id, node.into());
|
||||
}
|
||||
for client in &value.clients {
|
||||
directory.clients.insert(client.client_id, client.into());
|
||||
}
|
||||
directory
|
||||
}
|
||||
}
|
||||
|
||||
/// Public routing information for a single mix node, stored in the [`Directory`].
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct DirectoryNode {
|
||||
/// Unique identifier for this node within the topology.
|
||||
///
|
||||
/// Used as the key in the [`Directory`] when resolving routing targets.
|
||||
pub id: NodeId,
|
||||
|
||||
/// UDP socket address on which this node listens for incoming packets.
|
||||
pub addr: SocketAddr,
|
||||
|
||||
/// Sphinx (X25519) public key used to encrypt packets destined for this node.
|
||||
pub sphinx_public_key: x25519::PublicKey,
|
||||
}
|
||||
|
||||
impl From<&TopologyNode> for DirectoryNode {
|
||||
/// Derive the public [`DirectoryNode`] entry from a [`TopologyNode`] by
|
||||
/// computing the corresponding X25519 public key from the private key.
|
||||
fn from(value: &TopologyNode) -> Self {
|
||||
DirectoryNode {
|
||||
id: value.node_id,
|
||||
addr: value.socket_address,
|
||||
sphinx_public_key: x25519::PublicKey::from(&value.sphinx_private_key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DirectoryNode {
|
||||
pub fn as_sphinx_node_socket(&self) -> SphinxNode {
|
||||
let address = NymNodeRoutingAddress::Node(self.addr);
|
||||
// SAFETY : our addressing scheme can fit in a sphinx packet
|
||||
#[allow(clippy::unwrap_used)]
|
||||
SphinxNode::new(address.try_into().unwrap(), *self.sphinx_public_key)
|
||||
}
|
||||
|
||||
/// Derive the [`RoutingNode`] entry used by the real nym-node gateway state.
|
||||
/// The id key is unused
|
||||
fn as_routing_node(&self) -> RoutingNode {
|
||||
let mut rng = StdRng::seed_from_u64(self.id as u64);
|
||||
let identity_key = ed25519::PrivateKey::new(&mut rng).public_key();
|
||||
RoutingNode {
|
||||
node_id: self.id as u32,
|
||||
mix_host: self.addr,
|
||||
entry: None,
|
||||
identity_key,
|
||||
sphinx_key: self.sphinx_public_key,
|
||||
supported_roles: SupportedRoles {
|
||||
mixnode: true,
|
||||
mixnet_entry: true,
|
||||
mixnet_exit: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Public routing information for a client, stored in the [`Directory`].
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct DirectoryClient {
|
||||
/// Unique identifier for this client within the topology.
|
||||
///
|
||||
/// Used as the key in the [`Directory`] when resolving routing targets.
|
||||
pub id: ClientId,
|
||||
|
||||
/// UDP socket address on which this client listens for incoming packets.
|
||||
pub addr: SocketAddr,
|
||||
|
||||
/// Sphinx (X25519) public key used to encrypt packets destined for this client.
|
||||
pub sphinx_public_key: x25519::PublicKey,
|
||||
}
|
||||
|
||||
impl From<&TopologyClient> for DirectoryClient {
|
||||
/// Derive the public [`DirectoryClient`] entry from a [`TopologyClient`] by
|
||||
/// computing the corresponding X25519 public key from the private key.
|
||||
fn from(value: &TopologyClient) -> Self {
|
||||
DirectoryClient {
|
||||
id: value.client_id,
|
||||
addr: value.mixnet_address,
|
||||
sphinx_public_key: x25519::PublicKey::from(&value.sphinx_private_key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DirectoryClient {
|
||||
pub fn as_sphinx_node(&self) -> SphinxNode {
|
||||
// For the simulation, just repeat the id in lieu of client address
|
||||
let address = NymNodeRoutingAddress::Client(self.client_address());
|
||||
// SAFETY : our addressing scheme can fit in a sphinx packet
|
||||
#[allow(clippy::unwrap_used)]
|
||||
SphinxNode::new(address.try_into().unwrap(), *self.sphinx_public_key)
|
||||
}
|
||||
|
||||
pub fn client_address(&self) -> ClientAddress {
|
||||
ClientAddress::from_bytes([self.id; 20])
|
||||
}
|
||||
|
||||
pub fn as_sphinx_destination(&self) -> Destination {
|
||||
// For the simulation, just repeat the ID
|
||||
Destination::new(
|
||||
DestinationAddressBytes::from_bytes([self.id; 32]),
|
||||
[self.id; 16],
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Topology file types and the in-memory network directory.
|
||||
//!
|
||||
//! The topology is loaded from `topology.json` and contains everything needed
|
||||
//! to construct a node or client (including private config such as keys).
|
||||
//! The [`directory::Directory`] holds only the public-facing routing information
|
||||
//! visible to other participants in the network.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use nym_crypto::asymmetric::x25519;
|
||||
use nym_crypto::asymmetric::x25519::serde_helpers::bs58_x25519_private_key;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{client::ClientId, node::NodeId};
|
||||
|
||||
pub mod directory;
|
||||
|
||||
/// Per-node configuration stored in `topology.json`.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct TopologyNode {
|
||||
/// Unique identifier for this node within the topology.
|
||||
pub node_id: NodeId,
|
||||
/// UDP address on which the node listens for incoming packets.
|
||||
pub socket_address: SocketAddr,
|
||||
/// Notional reliability percentage (0–100); reserved for future use.
|
||||
pub reliability: u8,
|
||||
/// Sphinx (X25519) private key used by this node to unwrap packets.
|
||||
#[serde(with = "bs58_x25519_private_key")]
|
||||
pub sphinx_private_key: x25519::PrivateKey,
|
||||
}
|
||||
|
||||
impl TopologyNode {
|
||||
/// Construct a [`TopologyNode`] with a freshly generated Sphinx keypair.
|
||||
///
|
||||
/// Intended for use by `init-topology` to generate a topology file for the
|
||||
/// simulation.
|
||||
pub fn new(node_id: NodeId, reliability: u8, socket_address: SocketAddr) -> Self {
|
||||
let sphinx_private_key = x25519::PrivateKey::new(&mut rand::thread_rng());
|
||||
Self {
|
||||
node_id,
|
||||
socket_address,
|
||||
reliability,
|
||||
sphinx_private_key,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-client configuration stored in `topology.json`.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct TopologyClient {
|
||||
/// Unique identifier for this client within the topology.
|
||||
pub client_id: ClientId,
|
||||
/// UDP address the client uses to talk to the mix network.
|
||||
pub mixnet_address: SocketAddr,
|
||||
/// UDP address where the client listens for messages from user applications
|
||||
/// (e.g. the standalone `client` binary). Not included in the
|
||||
/// [`Directory`](directory::Directory).
|
||||
pub app_address: SocketAddr,
|
||||
/// Sphinx (X25519) private key used by this client to unwrap packets.
|
||||
#[serde(with = "bs58_x25519_private_key")]
|
||||
pub sphinx_private_key: x25519::PrivateKey,
|
||||
}
|
||||
|
||||
impl TopologyClient {
|
||||
/// Construct a [`TopologyClient`] with the given addresses.
|
||||
///
|
||||
/// Intended for use by `init-topology` to generate a topology file for the
|
||||
/// simulation.
|
||||
pub fn new(client_id: ClientId, mixnet_address: SocketAddr, app_address: SocketAddr) -> Self {
|
||||
let sphinx_private_key = x25519::PrivateKey::new(&mut rand::thread_rng());
|
||||
Self {
|
||||
client_id,
|
||||
mixnet_address,
|
||||
app_address,
|
||||
sphinx_private_key,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Root topology file structure, deserialised from `topology.json`.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Topology {
|
||||
/// Every mix node participating in the simulation.
|
||||
pub nodes: Vec<TopologyNode>,
|
||||
/// Every simulated client with sockets bound to localhost.
|
||||
pub clients: Vec<TopologyClient>,
|
||||
}
|
||||
@@ -117,6 +117,7 @@ nym-ip-packet-router = { path = "../service-providers/ip-packet-router" }
|
||||
|
||||
# LP dependencies
|
||||
nym-lp = { workspace = true }
|
||||
nym-lp-data.workspace = true
|
||||
nym-registration-common = { path = "../common/registration" }
|
||||
bincode = { workspace = true }
|
||||
|
||||
@@ -147,6 +148,7 @@ nym-test-utils = { workspace = true }
|
||||
[features]
|
||||
tokio-console = ["console-subscriber", "nym-task/tokio-tracing"]
|
||||
otel = ["nym-bin-common/otel-otlp", "dep:opentelemetry", "dep:opentelemetry_sdk"]
|
||||
mix-sim = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -15,6 +15,9 @@ pub struct MixingStats {
|
||||
// updated on each packet
|
||||
pub egress: EgressMixingStats,
|
||||
|
||||
// updated on each packet handled by the LP data plane
|
||||
pub lp: LpMixingStats,
|
||||
|
||||
// updated on a timer
|
||||
pub legacy: LegacyMixingStats,
|
||||
}
|
||||
@@ -141,6 +144,93 @@ impl MixingStats {
|
||||
.or_default()
|
||||
.dropped += 1;
|
||||
}
|
||||
|
||||
// ===== LP =====
|
||||
|
||||
pub fn lp_packet_received(&self, src: SocketAddr) {
|
||||
self.lp.packets_received.fetch_add(1, Ordering::Relaxed);
|
||||
*self.lp.packets_received_per_src.entry(src).or_default() += 1;
|
||||
}
|
||||
|
||||
pub fn lp_packet_forwarded(&self, dst: SocketAddr) {
|
||||
self.lp.packets_forwarded.fetch_add(1, Ordering::Relaxed);
|
||||
*self.lp.packets_forwarded_per_dst.entry(dst).or_default() += 1;
|
||||
}
|
||||
|
||||
pub fn lp_routing_filter_dropped(&self, dst: SocketAddr) {
|
||||
self.lp
|
||||
.routing_filter_dropped
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
*self
|
||||
.lp
|
||||
.routing_filter_dropped_per_dst
|
||||
.entry(dst)
|
||||
.or_default() += 1;
|
||||
}
|
||||
|
||||
pub fn lp_message_received(&self, kind: PacketKind) {
|
||||
self.lp.messages_received.fetch_add(1, Ordering::Relaxed);
|
||||
*self.lp.messages_received_per_kind.entry(kind).or_default() += 1;
|
||||
}
|
||||
|
||||
pub fn lp_processed_message(&self, kind: PacketKind) {
|
||||
self.lp.messages_processed.fetch_add(1, Ordering::Relaxed);
|
||||
*self.lp.messages_processed_per_kind.entry(kind).or_default() += 1;
|
||||
}
|
||||
|
||||
pub fn lp_malformed_packet(&self) {
|
||||
self.lp.malformed_packets.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn lp_excessive_delay_packet(&self) {
|
||||
self.lp
|
||||
.excessive_delay_packets
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn lp_processing_replayed_packet(&self) {
|
||||
self.lp.replayed_packets.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn lp_processing_dropped_final_hop_packet(&self) {
|
||||
self.lp
|
||||
.final_hop_packets_dropped
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn lp_processing_misc_error(&self) {
|
||||
self.lp
|
||||
.processing_misc_errors
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn lp_pipeline_overloaded_dropped_packets(&self) {
|
||||
self.lp
|
||||
.pipeline_overloaded_dropped_packets
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn lp_worker_pool_overloaded_dropped_packets(&self) {
|
||||
self.lp
|
||||
.worker_pool_overloaded_dropped_packets
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn lp_egress_overloaded_packets_dropped_packets(&self) {
|
||||
self.lp
|
||||
.egress_overloaded_dropped_packets
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn lp_client_forwarding_disabled_dropped(&self) {
|
||||
self.lp
|
||||
.client_forwarding_disabled_dropped
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn lp_internal_sp_routed(&self) {
|
||||
self.lp.internal_sp_routed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, PartialEq)]
|
||||
@@ -213,6 +303,8 @@ pub enum PacketKind {
|
||||
Unknown,
|
||||
Outfox,
|
||||
Sphinx(u16),
|
||||
LpSphinx,
|
||||
LpOutfox,
|
||||
}
|
||||
|
||||
impl Display for PacketKind {
|
||||
@@ -223,6 +315,8 @@ impl Display for PacketKind {
|
||||
PacketKind::Sphinx(sphinx_version) => {
|
||||
write!(f, "sphinx_{sphinx_version}")
|
||||
}
|
||||
PacketKind::LpSphinx => "lp_sphinx".fmt(f),
|
||||
PacketKind::LpOutfox => "lp_outfox".fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -337,3 +431,162 @@ impl LegacyMixingStats {
|
||||
self.dropped_since_last_update.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Flat stats for the LP data plane.
|
||||
///
|
||||
/// Each per-peer / per-kind counter has both an aggregate atomic (read by
|
||||
/// prometheus and rate computations) and a DashMap with the per-key
|
||||
/// breakdown - mirroring how `IngressMixingStats` pairs e.g.
|
||||
/// `forward_hop_packets_received` with `senders`.
|
||||
#[derive(Default)]
|
||||
pub struct LpMixingStats {
|
||||
/// Total UDP datagrams received (every datagram, before LP decode).
|
||||
packets_received: AtomicUsize,
|
||||
/// Per-source breakdown of `packets_received`.
|
||||
packets_received_per_src: DashMap<SocketAddr, usize>,
|
||||
|
||||
/// Total UDP datagrams successfully sent.
|
||||
packets_forwarded: AtomicUsize,
|
||||
/// Per-destination breakdown of `packets_forwarded`.
|
||||
packets_forwarded_per_dst: DashMap<SocketAddr, usize>,
|
||||
|
||||
/// Total drops by the routing filter (next hop unknown to the network).
|
||||
routing_filter_dropped: AtomicUsize,
|
||||
/// Per-destination breakdown of `routing_filter_dropped`.
|
||||
routing_filter_dropped_per_dst: DashMap<SocketAddr, usize>,
|
||||
|
||||
/// Total reassembled messages.
|
||||
messages_received: AtomicUsize,
|
||||
/// Per-mix-message-kind breakdown of `messages_received`.
|
||||
messages_received_per_kind: DashMap<PacketKind, usize>,
|
||||
|
||||
/// Total successfully post-processed (mixed) messages.
|
||||
messages_processed: AtomicUsize,
|
||||
/// Per-mix-message-kind breakdown of `messages_processed`.
|
||||
messages_processed_per_kind: DashMap<PacketKind, usize>,
|
||||
|
||||
/// LP packets that failed to decode/parse anywhere in the pipeline.
|
||||
malformed_packets: AtomicUsize,
|
||||
/// Forward-hop packets whose declared delay exceeded the maximum (clamped).
|
||||
excessive_delay_packets: AtomicUsize,
|
||||
/// Sphinx-level replays caught by the bloomfilter.
|
||||
replayed_packets: AtomicUsize,
|
||||
/// Final-hop packets dropped (LP nodes don't deliver final hop).
|
||||
final_hop_packets_dropped: AtomicUsize,
|
||||
/// Other / unclassified processing errors.
|
||||
processing_misc_errors: AtomicUsize,
|
||||
|
||||
/// Packets dropped because the listener->handler pipeline queue was full.
|
||||
pipeline_overloaded_dropped_packets: AtomicUsize,
|
||||
/// Packets dropped because all worker queues were saturated.
|
||||
worker_pool_overloaded_dropped_packets: AtomicUsize,
|
||||
/// Packets dropped because the handler->listener egress channel was full.
|
||||
egress_overloaded_dropped_packets: AtomicUsize,
|
||||
|
||||
/// Client-destined packets dropped because this node has client forwarding disabled
|
||||
/// (i.e. not a gateway). Receiving these indicates upstream misrouting or abuse.
|
||||
client_forwarding_disabled_dropped: AtomicUsize,
|
||||
/// Packets routed to an internal service provider channel (delivered off-wire,
|
||||
/// so they don't show up in `packets_forwarded`).
|
||||
internal_sp_routed: AtomicUsize,
|
||||
}
|
||||
|
||||
impl LpMixingStats {
|
||||
pub fn packets_received(&self) -> usize {
|
||||
self.packets_received.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn packets_received_per_src(&self) -> &DashMap<SocketAddr, usize> {
|
||||
&self.packets_received_per_src
|
||||
}
|
||||
|
||||
pub fn packets_forwarded(&self) -> usize {
|
||||
self.packets_forwarded.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn packets_forwarded_per_dst(&self) -> &DashMap<SocketAddr, usize> {
|
||||
&self.packets_forwarded_per_dst
|
||||
}
|
||||
|
||||
pub fn routing_filter_dropped(&self) -> usize {
|
||||
self.routing_filter_dropped.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn routing_filter_dropped_per_dst(&self) -> &DashMap<SocketAddr, usize> {
|
||||
&self.routing_filter_dropped_per_dst
|
||||
}
|
||||
|
||||
pub fn messages_received(&self) -> usize {
|
||||
self.messages_received.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn messages_received_per_kind(&self) -> &DashMap<PacketKind, usize> {
|
||||
&self.messages_received_per_kind
|
||||
}
|
||||
|
||||
pub fn messages_received_for(&self, kind: PacketKind) -> usize {
|
||||
self.messages_received_per_kind
|
||||
.get(&kind)
|
||||
.map(|v| *v)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn messages_processed(&self) -> usize {
|
||||
self.messages_processed.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn messages_processed_per_kind(&self) -> &DashMap<PacketKind, usize> {
|
||||
&self.messages_processed_per_kind
|
||||
}
|
||||
|
||||
pub fn messages_processed_for(&self, kind: PacketKind) -> usize {
|
||||
self.messages_processed_per_kind
|
||||
.get(&kind)
|
||||
.map(|v| *v)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn malformed_packets(&self) -> usize {
|
||||
self.malformed_packets.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn excessive_delay_packets(&self) -> usize {
|
||||
self.excessive_delay_packets.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn replayed_packets(&self) -> usize {
|
||||
self.replayed_packets.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn final_hop_packets_dropped(&self) -> usize {
|
||||
self.final_hop_packets_dropped.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn processing_misc_errors(&self) -> usize {
|
||||
self.processing_misc_errors.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn pipeline_overloaded_dropped_packets(&self) -> usize {
|
||||
self.pipeline_overloaded_dropped_packets
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn worker_pool_overloaded_dropped_packets(&self) -> usize {
|
||||
self.worker_pool_overloaded_dropped_packets
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn egress_overloaded_dropped_packets(&self) -> usize {
|
||||
self.egress_overloaded_dropped_packets
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn client_forwarding_disabled_dropped(&self) -> usize {
|
||||
self.client_forwarding_disabled_dropped
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn internal_sp_routed(&self) -> usize {
|
||||
self.internal_sp_routed.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,123 @@ pub enum PrometheusMetric {
|
||||
#[strum(props(help = "The current rate of dropping egress forward hop sphinx packets"))]
|
||||
MixnetEgressForwardPacketsDroppedRate,
|
||||
|
||||
// # LP DATA PLANE
|
||||
#[strum(props(help = "Total number of LP UDP datagrams received (sum across sources)"))]
|
||||
MixnetLpPacketsReceived,
|
||||
|
||||
#[strum(props(help = "Total number of LP UDP datagrams forwarded (sum across destinations)"))]
|
||||
MixnetLpPacketsForwarded,
|
||||
|
||||
#[strum(props(
|
||||
help = "Total number of LP packets dropped by the routing filter (sum across destinations)"
|
||||
))]
|
||||
MixnetLpRoutingFilterDropped,
|
||||
|
||||
#[strum(to_string = "mixnet_lp_messages_received_{kind}")]
|
||||
#[strum(props(help = "The number of LP messages reassembled, by mix-message kind"))]
|
||||
MixnetLpMessagesReceived { kind: PacketKind },
|
||||
|
||||
#[strum(to_string = "mixnet_lp_messages_processed_{kind}")]
|
||||
#[strum(props(help = "The number of LP messages successfully processed, by mix-message kind"))]
|
||||
MixnetLpMessagesProcessed { kind: PacketKind },
|
||||
|
||||
#[strum(props(help = "The number of malformed LP packets"))]
|
||||
MixnetLpMalformedPackets,
|
||||
|
||||
#[strum(props(
|
||||
help = "The number of LP forward-hop packets whose declared delay exceeded the maximum (clamped)"
|
||||
))]
|
||||
MixnetLpExcessiveDelayPackets,
|
||||
|
||||
#[strum(props(help = "The number of replayed sphinx packets caught by the bloomfilter"))]
|
||||
MixnetLpReplayedPackets,
|
||||
|
||||
#[strum(props(help = "The number of LP final-hop packets dropped"))]
|
||||
MixnetLpFinalHopPacketsDropped,
|
||||
|
||||
#[strum(props(help = "The number of unclassified LP processing errors"))]
|
||||
MixnetLpProcessingMiscErrors,
|
||||
|
||||
#[strum(props(
|
||||
help = "The number of LP packets dropped because the listener->handler queue was full"
|
||||
))]
|
||||
MixnetLpPipelineOverloadedDropped,
|
||||
|
||||
#[strum(props(
|
||||
help = "The number of LP packets dropped because all worker queues were saturated"
|
||||
))]
|
||||
MixnetLpWorkerPoolOverloadedDropped,
|
||||
|
||||
#[strum(props(
|
||||
help = "The number of LP packets dropped because the handler->listener egress channel was full"
|
||||
))]
|
||||
MixnetLpEgressOverloadedDropped,
|
||||
|
||||
#[strum(props(
|
||||
help = "The number of LP client-destined packets dropped because client forwarding is disabled on this node"
|
||||
))]
|
||||
MixnetLpClientForwardingDisabledDropped,
|
||||
|
||||
#[strum(props(
|
||||
help = "The number of LP packets routed to an internal service provider (delivered off-wire)"
|
||||
))]
|
||||
MixnetLpInternalSpRouted,
|
||||
|
||||
#[strum(props(help = "The current rate of receiving LP UDP datagrams"))]
|
||||
MixnetLpPacketsReceivedRate,
|
||||
|
||||
#[strum(props(help = "The current rate of forwarding LP UDP datagrams"))]
|
||||
MixnetLpPacketsForwardedRate,
|
||||
|
||||
#[strum(props(help = "The current rate of LP packets dropped by the routing filter"))]
|
||||
MixnetLpRoutingFilterDroppedRate,
|
||||
|
||||
#[strum(props(help = "The current rate of LP messages reassembled"))]
|
||||
MixnetLpMessagesReceivedRate,
|
||||
|
||||
#[strum(props(help = "The current rate of LP messages successfully processed"))]
|
||||
MixnetLpMessagesProcessedRate,
|
||||
|
||||
#[strum(props(help = "The current rate of malformed LP packets"))]
|
||||
MixnetLpMalformedPacketsRate,
|
||||
|
||||
#[strum(props(
|
||||
help = "The current rate of LP forward-hop packets exceeding the maximum delay"
|
||||
))]
|
||||
MixnetLpExcessiveDelayPacketsRate,
|
||||
|
||||
#[strum(props(help = "The current rate of replayed sphinx packets"))]
|
||||
MixnetLpReplayedPacketsRate,
|
||||
|
||||
#[strum(props(help = "The current rate of LP final-hop packets dropped"))]
|
||||
MixnetLpFinalHopPacketsDroppedRate,
|
||||
|
||||
#[strum(props(help = "The current rate of unclassified LP processing errors"))]
|
||||
MixnetLpProcessingMiscErrorsRate,
|
||||
|
||||
#[strum(props(
|
||||
help = "The current rate of LP packets dropped because the pipeline queue was full"
|
||||
))]
|
||||
MixnetLpPipelineOverloadedDroppedRate,
|
||||
|
||||
#[strum(props(
|
||||
help = "The current rate of LP packets dropped because all worker queues were saturated"
|
||||
))]
|
||||
MixnetLpWorkerPoolOverloadedDroppedRate,
|
||||
|
||||
#[strum(props(
|
||||
help = "The current rate of LP packets dropped because the egress channel was full"
|
||||
))]
|
||||
MixnetLpEgressOverloadedDroppedRate,
|
||||
|
||||
#[strum(props(
|
||||
help = "The current rate of LP client-destined packets dropped because client forwarding is disabled"
|
||||
))]
|
||||
MixnetLpClientForwardingDisabledDroppedRate,
|
||||
|
||||
#[strum(props(help = "The current rate of LP packets routed to an internal service provider"))]
|
||||
MixnetLpInternalSpRoutedRate,
|
||||
|
||||
// # ENTRY
|
||||
#[strum(props(help = "The number of unique users"))]
|
||||
EntryClientUniqueUsers,
|
||||
@@ -230,6 +347,8 @@ impl PrometheusMetric {
|
||||
self,
|
||||
PrometheusMetric::EntryClientSessionsDurations { .. }
|
||||
| PrometheusMetric::MixnetIngressPacketVersion { .. }
|
||||
| PrometheusMetric::MixnetLpMessagesReceived { .. }
|
||||
| PrometheusMetric::MixnetLpMessagesProcessed { .. }
|
||||
)
|
||||
// match self {
|
||||
// PrometheusMetric::EntryClientSessionsDurations { .. } => true,
|
||||
@@ -299,6 +418,78 @@ impl PrometheusMetric {
|
||||
PrometheusMetric::MixnetEgressForwardPacketsDroppedRate => {
|
||||
Metric::new_float_gauge(&name, help)
|
||||
}
|
||||
PrometheusMetric::MixnetLpPacketsReceived => Metric::new_int_gauge(&name, help),
|
||||
PrometheusMetric::MixnetLpPacketsForwarded => Metric::new_int_gauge(&name, help),
|
||||
PrometheusMetric::MixnetLpRoutingFilterDropped => Metric::new_int_gauge(&name, help),
|
||||
PrometheusMetric::MixnetLpMessagesReceived { .. } => {
|
||||
Metric::new_int_gauge(&name, help)
|
||||
}
|
||||
PrometheusMetric::MixnetLpMessagesProcessed { .. } => {
|
||||
Metric::new_int_gauge(&name, help)
|
||||
}
|
||||
PrometheusMetric::MixnetLpMalformedPackets => Metric::new_int_gauge(&name, help),
|
||||
PrometheusMetric::MixnetLpExcessiveDelayPackets => Metric::new_int_gauge(&name, help),
|
||||
PrometheusMetric::MixnetLpReplayedPackets => Metric::new_int_gauge(&name, help),
|
||||
PrometheusMetric::MixnetLpFinalHopPacketsDropped => Metric::new_int_gauge(&name, help),
|
||||
PrometheusMetric::MixnetLpProcessingMiscErrors => Metric::new_int_gauge(&name, help),
|
||||
PrometheusMetric::MixnetLpPipelineOverloadedDropped => {
|
||||
Metric::new_int_gauge(&name, help)
|
||||
}
|
||||
PrometheusMetric::MixnetLpWorkerPoolOverloadedDropped => {
|
||||
Metric::new_int_gauge(&name, help)
|
||||
}
|
||||
PrometheusMetric::MixnetLpEgressOverloadedDropped => {
|
||||
Metric::new_int_gauge(&name, help)
|
||||
}
|
||||
PrometheusMetric::MixnetLpClientForwardingDisabledDropped => {
|
||||
Metric::new_int_gauge(&name, help)
|
||||
}
|
||||
PrometheusMetric::MixnetLpInternalSpRouted => Metric::new_int_gauge(&name, help),
|
||||
PrometheusMetric::MixnetLpPacketsReceivedRate => {
|
||||
Metric::new_float_gauge(&name, help)
|
||||
}
|
||||
PrometheusMetric::MixnetLpPacketsForwardedRate => {
|
||||
Metric::new_float_gauge(&name, help)
|
||||
}
|
||||
PrometheusMetric::MixnetLpRoutingFilterDroppedRate => {
|
||||
Metric::new_float_gauge(&name, help)
|
||||
}
|
||||
PrometheusMetric::MixnetLpMessagesReceivedRate => {
|
||||
Metric::new_float_gauge(&name, help)
|
||||
}
|
||||
PrometheusMetric::MixnetLpMessagesProcessedRate => {
|
||||
Metric::new_float_gauge(&name, help)
|
||||
}
|
||||
PrometheusMetric::MixnetLpMalformedPacketsRate => {
|
||||
Metric::new_float_gauge(&name, help)
|
||||
}
|
||||
PrometheusMetric::MixnetLpExcessiveDelayPacketsRate => {
|
||||
Metric::new_float_gauge(&name, help)
|
||||
}
|
||||
PrometheusMetric::MixnetLpReplayedPacketsRate => {
|
||||
Metric::new_float_gauge(&name, help)
|
||||
}
|
||||
PrometheusMetric::MixnetLpFinalHopPacketsDroppedRate => {
|
||||
Metric::new_float_gauge(&name, help)
|
||||
}
|
||||
PrometheusMetric::MixnetLpProcessingMiscErrorsRate => {
|
||||
Metric::new_float_gauge(&name, help)
|
||||
}
|
||||
PrometheusMetric::MixnetLpPipelineOverloadedDroppedRate => {
|
||||
Metric::new_float_gauge(&name, help)
|
||||
}
|
||||
PrometheusMetric::MixnetLpWorkerPoolOverloadedDroppedRate => {
|
||||
Metric::new_float_gauge(&name, help)
|
||||
}
|
||||
PrometheusMetric::MixnetLpEgressOverloadedDroppedRate => {
|
||||
Metric::new_float_gauge(&name, help)
|
||||
}
|
||||
PrometheusMetric::MixnetLpClientForwardingDisabledDroppedRate => {
|
||||
Metric::new_float_gauge(&name, help)
|
||||
}
|
||||
PrometheusMetric::MixnetLpInternalSpRoutedRate => {
|
||||
Metric::new_float_gauge(&name, help)
|
||||
}
|
||||
PrometheusMetric::EntryClientUniqueUsers => Metric::new_int_gauge(&name, help),
|
||||
PrometheusMetric::EntryClientSessionsStarted => Metric::new_int_gauge(&name, help),
|
||||
PrometheusMetric::EntryClientSessionsFinished => Metric::new_int_gauge(&name, help),
|
||||
@@ -446,7 +637,7 @@ mod tests {
|
||||
// a sanity check for anyone adding new metrics. if this test fails,
|
||||
// make sure any methods on `PrometheusMetric` enum don't need updating
|
||||
// or require custom Display impl
|
||||
assert_eq!(45, PrometheusMetric::COUNT)
|
||||
assert_eq!(75, PrometheusMetric::COUNT)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -88,6 +88,14 @@ pub struct LpDebug {
|
||||
/// When at capacity, new forward requests return an error, signaling the client
|
||||
/// to choose a different gateway.
|
||||
pub max_concurrent_forwards: usize,
|
||||
|
||||
/// Number of worker threads processing the LP data plane pipeline.
|
||||
///
|
||||
/// Heavy per-packet work (sphinx/outfox decryption, replay-filter check,
|
||||
/// fragmentation) is fanned out across this pool. Higher values improve
|
||||
/// throughput on multi-core hosts at the cost of more contention on the
|
||||
/// shared replay-protection mutex.
|
||||
pub data_worker_count: usize,
|
||||
}
|
||||
|
||||
impl LpConfig {
|
||||
@@ -137,6 +145,9 @@ impl LpDebug {
|
||||
|
||||
// Limits concurrent outbound connections to prevent fd exhaustion
|
||||
pub const DEFAULT_MAX_CONCURRENT_FORWARDS: usize = 1000;
|
||||
|
||||
// Default number of CPU-bound packet-processing workers.
|
||||
pub const DEFAULT_DATA_WORKER_COUNT: usize = 4;
|
||||
}
|
||||
|
||||
impl Default for LpDebug {
|
||||
@@ -148,6 +159,7 @@ impl Default for LpDebug {
|
||||
session_ttl: Self::DEFAULT_SESSION_TTL,
|
||||
state_cleanup_interval: Self::DEFAULT_STATE_CLEANUP_INTERVAL,
|
||||
max_concurrent_forwards: Self::DEFAULT_MAX_CONCURRENT_FORWARDS,
|
||||
data_worker_count: Self::DEFAULT_DATA_WORKER_COUNT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +145,12 @@ impl NodeModes {
|
||||
self.entry || self.exit
|
||||
}
|
||||
|
||||
// Duplicate of `expects_final_hop_traffic` for now, but with more explicit naming for LP context
|
||||
// Comment can be removed along the aformentioned fn
|
||||
pub fn expects_client_traffic(&self) -> bool {
|
||||
self.entry || self.exit
|
||||
}
|
||||
|
||||
pub fn with_mixnode(&mut self) -> &mut Self {
|
||||
self.mixnode = true;
|
||||
self
|
||||
|
||||
@@ -731,6 +731,7 @@ pub async fn try_upgrade_config_v12<P: AsRef<Path>>(
|
||||
session_ttl: old_cfg.gateway_tasks.lp.debug.session_ttl,
|
||||
state_cleanup_interval: old_cfg.gateway_tasks.lp.debug.state_cleanup_interval,
|
||||
max_concurrent_forwards: old_cfg.gateway_tasks.lp.debug.max_concurrent_forwards,
|
||||
data_worker_count: LpDebug::default().data_worker_count,
|
||||
},
|
||||
},
|
||||
gateway_tasks: GatewayTasksConfig {
|
||||
|
||||
@@ -9,8 +9,8 @@ mod tests {
|
||||
use crate::node::lp::directory::LpNodeDetails;
|
||||
use crate::node::lp::state::SharedLpNodeControlState;
|
||||
use anyhow::Context;
|
||||
use nym_lp::packet::version;
|
||||
use nym_lp::peer::{LpLocalPeer, LpRemotePeer, mock_peers};
|
||||
use nym_lp_data::packet::version;
|
||||
use nym_test_utils::helpers::seeded_rng;
|
||||
use nym_test_utils::mocks::async_read_write::MockIOStream;
|
||||
use nym_test_utils::traits::TimeboxedSpawnable;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user