Compare commits

...

18 Commits

Author SHA1 Message Date
Simon Wicky 8573004c34 rebasing cleanup 2026-05-20 11:38:35 +02:00
Simon Wicky 5636c5afc4 name change 2026-05-20 11:34:03 +02:00
Simon Wicky f505c29926 some PR review 2026-05-20 11:33:44 +02:00
Simon Wicky 95bec7422c tweaks and checked arithmetic 2026-05-20 11:33:31 +02:00
Simon Wicky c02c28f7cb add mut to transport layer 2026-05-20 11:33:31 +02:00
Simon Wicky 6fb4a98667 comments update 2026-05-20 11:33:30 +02:00
Simon Wicky 4a50f6dcd0 options in framing layer 2026-05-20 11:33:30 +02:00
Simon Wicky 53dec68378 remove anyhow error for in trait one 2026-05-20 11:33:30 +02:00
Simon Wicky f0ecdfd295 delete unnecessary unfinished type 2026-05-20 11:33:30 +02:00
Simon Wicky 668477c5c3 remove unnecessary imports 2026-05-20 11:33:30 +02:00
Simon Wicky 53aaa71178 cargo fmt 2026-05-20 11:33:30 +02:00
Simon Wicky 35517f1df6 nym-mix-sim crate 2026-05-20 11:33:29 +02:00
Simon Wicky ed5ddf0170 nym-lp-data crate 2026-05-20 11:33:14 +02:00
Simon Wicky 644e669a15 helper changes 2026-05-20 11:32:02 +02:00
Simon Wicky 1fd25529ce crate description 2026-05-20 11:28:36 +02:00
Simon Wicky 8677b98bcb fmt 2026-05-20 11:18:04 +02:00
Simon Wicky ca031af69a one more bit 2026-05-20 11:14:44 +02:00
Simon Wicky 7c0264b839 moving lp packets in lp-data crate 2026-05-20 11:10:46 +02:00
87 changed files with 5772 additions and 390 deletions
Generated
+544 -295
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -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",
@@ -194,6 +197,7 @@ default-members = [
"service-providers/network-requester",
"tools/internal/localnet-orchestrator",
"tools/nymvisor",
"tools/internal/localnet-orchestrator",
]
exclude = ["contracts", "nym-wallet", "cpu-cycles"]
@@ -459,6 +463,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" }
@@ -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::*;
+29
View File
@@ -0,0 +1,29 @@
[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
num_enum.workspace = true
tracing.workspace = true
thiserror.workspace = true
nym-common.workspace = true
[dev-dependencies]
nym-lp.workspace = true
[lints]
workspace = true
+103
View File
@@ -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.
+93
View File
@@ -0,0 +1,93 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use std::sync::mpsc;
use crate::AddressedTimedData;
use crate::clients::InputOptions;
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.
///
/// `Ts` must implement `Clone + PartialOrd` so that timestamps can be compared
/// to decide which packets are due.
///
pub struct ClientWrappingPipelineDriver<Ts, Pkt, Opts, NdId>
where
Ts: Clone + PartialOrd,
Opts: InputOptions<NdId>,
{
pipeline: Box<dyn DynClientWrappingPipeline<Ts, Pkt, Opts, NdId>>,
packet_buffer: Vec<AddressedTimedData<Ts, Pkt, NdId>>,
input: mpsc::Receiver<(Vec<u8>, Opts)>,
// Keeping a ref so we don't have problem about it being dropped
input_sender: mpsc::SyncSender<(Vec<u8>, Opts)>,
}
impl<Ts, Pkt, Opts, NdId> ClientWrappingPipelineDriver<Ts, Pkt, Opts, NdId>
where
Ts: Clone + PartialOrd,
Opts: InputOptions<NdId>,
{
/// Create a new driver wrapping `pipeline`.
///
/// Internally allocates a zero-capacity `sync_channel` for input payloads.
pub fn new(pipeline: impl DynClientWrappingPipeline<Ts, Pkt, Opts, NdId> + '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)> {
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: Ts) -> Vec<(Pkt, NdId)> {
// 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.clone()));
self.packet_buffer
.extract_if(.., |p| p.data.timestamp <= timestamp)
.map(|pkt| (pkt.data.data, pkt.dst))
.collect()
}
}
+69
View File
@@ -0,0 +1,69 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
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, Ts, Opts, NdId> Reliability<Ts, Opts, NdId> for T
where
T: NoOpReliability,
{
const OVERHEAD_SIZE: usize = 0;
fn reliable_encode(
&mut self,
input: Option<PipelinePayload<Ts, Opts, NdId>>,
_: Ts,
) -> Vec<PipelinePayload<Ts, Opts, NdId>> {
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, Ts, Opts, NdId> RoutingSecurity<Ts, Opts, NdId> for T
where
T: NoOpRoutingSecurity,
{
const OVERHEAD_SIZE: usize = 0;
fn nb_frames(&self) -> usize {
1
}
fn encrypt(
&mut self,
input: PipelinePayload<Ts, Opts, NdId>,
) -> PipelinePayload<Ts, Opts, NdId> {
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, Ts, Opts, NdId> Obfuscation<Ts, Opts, NdId> for T
where
T: NoOpObfuscation,
{
fn obfuscate(
&mut self,
input: Option<PipelinePayload<Ts, Opts, NdId>>,
_: Ts,
) -> Vec<PipelinePayload<Ts, Opts, NdId>> {
input.map(|payload| vec![payload]).unwrap_or_default()
}
}
+30
View File
@@ -0,0 +1,30 @@
// 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;
/// Per-message pipeline configuration carried alongside every payload.
///
/// Each pipeline stage (reliability, routing security, obfuscation) is optional
/// and toggled per-message by the corresponding accessor. The next-hop
/// destination is also resolved from the options so that addressing is decided
/// before the payload reaches [`Framing`].
///
/// # Type Parameters
/// - `NdId`: addressing type used to identify the next-hop destination.
///
/// [`Framing`]: crate::common::traits::Framing
pub trait InputOptions<NdId>: Clone {
/// Whether reliability encoding (e.g. SURB ACKs) should be applied.
fn reliability(&self) -> bool;
/// Whether routing-security encryption (e.g. Sphinx) should be applied.
fn routing_security(&self) -> bool;
/// Whether obfuscation (e.g. cover traffic) should be applied.
fn obfuscation(&self) -> bool;
/// Identifier of the next-hop node this message should be sent to.
fn next_hop(&self) -> NdId;
}
+304
View File
@@ -0,0 +1,304 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::PipelinePayload;
use crate::clients::InputOptions;
use crate::common::traits::{WireUnwrappingPipeline, WireWrappingPipeline};
use crate::{AddressedTimedData, TimedPayload};
/// Trait for splitting an incoming payload into timestamped chunks.
///
/// # Type Parameters
/// - `Ts`: Timestamp type associated with each produced [`PipelinePayload`].
/// - `Opts`: Per-message pipeline options (must implement [`InputOptions`]).
/// - `NdId`: Addressing type for the next-hop destination.
///
/// # Required Methods
/// - `chunked`: Split `input` into chunks of at most `chunk_size` bytes, tagging
/// each chunk with `timestamp` and `input_options`. Returns one
/// [`PipelinePayload`] per chunk, ready to be fed through the rest of the
/// pipeline.
pub trait Chunking<Ts, Opts, NdId>
where
Opts: InputOptions<NdId>,
{
fn chunked(
&mut self,
input: Vec<u8>,
input_options: Opts,
chunk_size: usize,
timestamp: Ts,
) -> Vec<PipelinePayload<Ts, Opts, NdId>>;
}
/// Trait for applying reliability encoding (e.g. SURB ACKs, retransmissions) to
/// a timed payload.
///
/// # Type Parameters
/// - `Ts`: Timestamp type carried by the [`PipelinePayload`].
/// - `Opts`: Per-message pipeline options.
/// - `NdId`: Addressing type for the next-hop destination.
///
/// # 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<Ts, Opts, NdId> {
const OVERHEAD_SIZE: usize;
fn reliable_encode(
&mut self,
input: Option<PipelinePayload<Ts, Opts, NdId>>,
timestamp: Ts,
) -> Vec<PipelinePayload<Ts, Opts, NdId>>;
}
/// 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
/// - `Ts`: Timestamp type carried by the [`PipelinePayload`].
/// - `Opts`: Per-message pipeline options.
/// - `NdId`: Addressing type for the next-hop destination.
pub trait Obfuscation<Ts, Opts, NdId> {
/// 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<Ts, Opts, NdId>>,
timestamp: Ts,
) -> Vec<PipelinePayload<Ts, Opts, NdId>>;
}
/// Trait for applying routing-security encryption (e.g. Sphinx) to a timed payload.
///
/// # Type Parameters
/// - `Ts`: Timestamp type carried by the [`PipelinePayload`].
/// - `Opts`: Per-message pipeline options.
/// - `NdId`: Addressing type for the next-hop destination.
///
/// # 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<Ts, Opts, NdId> {
const OVERHEAD_SIZE: usize;
fn nb_frames(&self) -> usize {
1
}
fn encrypt(
&mut self,
input: PipelinePayload<Ts, Opts, NdId>,
) -> PipelinePayload<Ts, Opts, NdId>;
}
/// 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.
///
/// # Type Parameters
/// - `Ts`: Timestamp type carried through the pipeline.
/// - `Pkt`: Final transport packet type produced by transport.
/// - `Opts`: Per-message pipeline options (must implement [`InputOptions`]).
/// - `NdId`: Addressing type for the next-hop destination.
///
/// # 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<Ts, Pkt, Opts, NdId>:
Chunking<Ts, Opts, NdId>
+ Reliability<Ts, Opts, NdId>
+ Obfuscation<Ts, Opts, NdId>
+ RoutingSecurity<Ts, Opts, NdId>
+ WireWrappingPipeline<Ts, Pkt, Opts, NdId>
where
Ts: Clone,
NdId: Clone,
Opts: InputOptions<NdId>,
{
fn chunk_size(&self, input_options: Opts) -> usize {
// Frame size comes from WireWrappingPipeline
let mut chunk_size = self.frame_size();
if input_options.routing_security() {
// 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)]
let pre_security_chunk_size = (chunk_size * self.nb_frames())
.checked_sub(<Self as RoutingSecurity<_, _, _>>::OVERHEAD_SIZE)
.expect("not enough room in a packet for routing security overhead");
chunk_size = pre_security_chunk_size;
}
if input_options.reliability() {
// 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)]
let pre_reliability_chunk_size = chunk_size
.checked_sub(<Self as Reliability<_, _, _>>::OVERHEAD_SIZE)
.expect("not enough room in a packet for reliability overhead");
chunk_size = pre_reliability_chunk_size;
}
chunk_size
}
fn process(
&mut self,
input: Option<(Vec<u8>, Opts)>, // Optional to be able to tick the pipeline without input
timestamp: Ts,
) -> Vec<AddressedTimedData<Ts, Pkt, NdId>> {
let mut chunks = if let Some((input_data, input_options)) = input {
self.chunked(
input_data,
input_options.clone(),
self.chunk_size(input_options.clone()),
timestamp.clone(),
)
} else {
Vec::new()
};
// Reliability stage with chunks that needs reliability
chunks = chunks
.into_iter()
.flat_map(|chunk| {
if chunk.options.reliability() {
self.reliable_encode(Some(chunk), timestamp.clone())
} else {
vec![chunk]
}
})
.collect();
// Even if we had nothing go into the reliability stage, we need to catch potential retransmissions
// If we had, this should be a no-op, since it already has been called with the same timestamp
chunks.append(&mut self.reliable_encode(None, timestamp.clone()));
chunks = chunks
.into_iter()
.flat_map(|chunk| {
if chunk.options.obfuscation() {
self.obfuscate(Some(chunk), timestamp.clone())
} else {
vec![chunk]
}
})
.collect();
// Even if we had nothing go into the obfuscation stage, we need to catch potential cover traffic
// If we had, this should be a no-op, since it already has been called with the same timestamp
chunks.append(&mut self.obfuscate(None, timestamp.clone()));
chunks = chunks
.into_iter()
.map(|chunk| {
if chunk.options.routing_security() {
self.encrypt(chunk)
} else {
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<Ts, Pkt, Opts, NdId>`,
/// erasing the concrete pipeline type while keeping `Ts`, `Pkt`, `Opts`, and
/// `NdId` visible.
///
/// Implement [`ClientWrappingPipeline`] on your concrete type; the blanket impl
/// below provides `DynClientWrappingPipeline` for free.
pub trait DynClientWrappingPipeline<Ts, Pkt, Opts, NdId> {
/// 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)>,
timestamp: Ts,
) -> Vec<AddressedTimedData<Ts, Pkt, NdId>>;
}
impl<T, Ts, Pkt, Opts, NdId> DynClientWrappingPipeline<Ts, Pkt, Opts, NdId> for T
where
Ts: Clone,
NdId: Clone,
Opts: InputOptions<NdId>,
T: ClientWrappingPipeline<Ts, Pkt, Opts, NdId>,
{
fn packet_size(&self) -> usize {
WireWrappingPipeline::packet_size(self)
}
fn process(
&mut self,
input: Option<(Vec<u8>, Opts)>,
timestamp: Ts,
) -> Vec<AddressedTimedData<Ts, Pkt, NdId>> {
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
/// - `Ts`: Timestamp type.
/// - `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<Ts, Pkt, Mk>: WireUnwrappingPipeline<Ts, Pkt, Mk>
where
Ts: Clone,
{
fn process_unwrapped(&mut self, payload: TimedPayload<Ts>, kind: Mk) -> Option<Vec<u8>>;
fn unwrap(&mut self, input: Pkt, timestamp: Ts) -> Result<Option<Vec<u8>>, Self::Error> {
Ok(self
.wire_unwrap(input, timestamp)?
.and_then(|(payload, kind)| self.process_unwrapped(payload, kind)))
}
}
+160
View File
@@ -0,0 +1,160 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::clients::InputOptions;
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<Ts, Opts, NdId, C, R, O, Rs, F, T> Chunking<Ts, Opts, NdId> for Pipeline<C, R, O, Rs, F, T>
where
Opts: InputOptions<NdId>,
C: Chunking<Ts, Opts, NdId>,
{
fn chunked(
&mut self,
input: Vec<u8>,
input_options: Opts,
chunk_size: usize,
timestamp: Ts,
) -> Vec<PipelinePayload<Ts, Opts, NdId>> {
self.chunking
.chunked(input, input_options, chunk_size, timestamp)
}
}
impl<Ts, Opts, NdId, C, R, O, Rs, F, T> Reliability<Ts, Opts, NdId> for Pipeline<C, R, O, Rs, F, T>
where
R: Reliability<Ts, Opts, NdId>,
{
const OVERHEAD_SIZE: usize = R::OVERHEAD_SIZE;
fn reliable_encode(
&mut self,
input: Option<PipelinePayload<Ts, Opts, NdId>>,
timestamp: Ts,
) -> Vec<PipelinePayload<Ts, Opts, NdId>> {
self.reliability.reliable_encode(input, timestamp)
}
}
impl<Ts, Opts, NdId, C, R, O, Rs, F, T> Obfuscation<Ts, Opts, NdId> for Pipeline<C, R, O, Rs, F, T>
where
O: Obfuscation<Ts, Opts, NdId>,
{
fn obfuscate(
&mut self,
input: Option<PipelinePayload<Ts, Opts, NdId>>,
timestamp: Ts,
) -> Vec<PipelinePayload<Ts, Opts, NdId>> {
self.obfuscation.obfuscate(input, timestamp)
}
}
impl<Ts, Opts, NdId, C, R, O, Rs, F, T> RoutingSecurity<Ts, Opts, NdId>
for Pipeline<C, R, O, Rs, F, T>
where
Rs: RoutingSecurity<Ts, Opts, NdId>,
{
const OVERHEAD_SIZE: usize = Rs::OVERHEAD_SIZE;
fn nb_frames(&self) -> usize {
self.security.nb_frames()
}
fn encrypt(
&mut self,
input: PipelinePayload<Ts, Opts, NdId>,
) -> PipelinePayload<Ts, Opts, NdId> {
self.security.encrypt(input)
}
}
impl<Ts, Opts, NdId, C, R, O, Rs, F, T> Framing<Ts, Opts, NdId> for Pipeline<C, R, O, Rs, F, T>
where
F: Framing<Ts, Opts, NdId>,
{
type Frame = F::Frame;
const OVERHEAD_SIZE: usize = F::OVERHEAD_SIZE;
fn to_frame(
&mut self,
payload: PipelinePayload<Ts, Opts, NdId>,
frame_size: usize,
) -> Vec<AddressedTimedData<Ts, F::Frame, NdId>> {
self.framing.to_frame(payload, frame_size)
}
}
impl<Ts, Pkt, NdId, C, R, O, Rs, F, T> Transport<Ts, Pkt, NdId> for Pipeline<C, R, O, Rs, F, T>
where
T: Transport<Ts, Pkt, NdId>,
{
type Frame = T::Frame;
const OVERHEAD_SIZE: usize = T::OVERHEAD_SIZE;
fn to_transport_packet(
&mut self,
frame: AddressedTimedData<Ts, T::Frame, NdId>,
) -> AddressedTimedData<Ts, Pkt, NdId> {
self.transport.to_transport_packet(frame)
}
}
impl<Ts, Pkt, Opts, NdId, C, R, O, Rs, F, T> WireWrappingPipeline<Ts, Pkt, Opts, NdId>
for Pipeline<C, R, O, Rs, F, T>
where
Ts: Clone,
NdId: Clone,
F: Framing<Ts, Opts, NdId>,
T: Transport<Ts, Pkt, NdId, Frame = F::Frame>,
{
fn packet_size(&self) -> usize {
self.packet_size
}
}
impl<Ts, Pkt, Opts, NdId, C, R, O, Rs, F, T> ClientWrappingPipeline<Ts, Pkt, Opts, NdId>
for Pipeline<C, R, O, Rs, F, T>
where
Ts: Clone,
NdId: Clone,
Opts: InputOptions<NdId>,
C: Chunking<Ts, Opts, NdId>,
R: Reliability<Ts, Opts, NdId>,
O: Obfuscation<Ts, Opts, NdId>,
Rs: RoutingSecurity<Ts, Opts, NdId>,
F: Framing<Ts, Opts, NdId>,
T: Transport<Ts, Pkt, NdId, Frame = F::Frame>,
{
}
+105
View File
@@ -0,0 +1,105 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
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, Ts, Opts, NdId> Framing<Ts, Opts, NdId> for T
where
T: NoOpWireWrapper,
{
type Frame = Vec<u8>;
const OVERHEAD_SIZE: usize = 0;
fn to_frame(
&mut self,
payload: PipelinePayload<Ts, Opts, NdId>,
_: usize,
) -> Vec<AddressedTimedPayload<Ts, NdId>> {
vec![payload.into_addressed()]
}
}
impl<T, Ts, Pkt, NdId> Transport<Ts, Pkt, NdId> 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<Ts, NdId>,
) -> AddressedTimedData<Ts, Pkt, NdId> {
frame.data_transform(|data| data.into())
}
}
impl<T, Ts, Pkt, Opts, NdId> WireWrappingPipeline<Ts, Pkt, Opts, NdId> for T
where
T: NoOpWireWrapper,
Ts: Clone,
Pkt: From<Vec<u8>>,
NdId: Clone,
{
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, Ts, Mk> FramingUnwrap<Ts, Mk> for T
where
T: NoOpWireUnwrapper,
Mk: Default,
{
type Frame = Vec<u8>;
fn frame_to_message(&mut self, frame: TimedPayload<Ts>) -> Option<(TimedPayload<Ts>, Mk)> {
Some((frame, Default::default()))
}
}
impl<T, Ts, Pkt> TransportUnwrap<Ts, 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: Ts,
) -> Result<TimedPayload<Ts>, Self::Error> {
Ok(TimedData {
timestamp,
data: packet.into(),
})
}
}
impl<T, Ts, Pkt, Mk> WireUnwrappingPipeline<Ts, Pkt, Mk> for T
where
T: NoOpWireUnwrapper,
Ts: Clone,
Pkt: Into<Vec<u8>>,
Mk: Default,
{
}
+5
View File
@@ -0,0 +1,5 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
pub mod helpers;
pub mod traits;
+182
View File
@@ -0,0 +1,182 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::{AddressedTimedData, PipelinePayload, TimedData, TimedPayload};
/// Trait for applying framing to a timed payload.
///
/// # Type Parameters
/// - `Ts`: Timestamp type carried by the `PipelinePayload`.
/// - `Opts` : Opts type carried by the `PipelinePayload`
/// - `NdId` : Addressing type
///
/// # 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<TimedData<Ts, Self::Frame>>` of frames of the given size.
pub trait Framing<Ts, Opts, NdId> {
type Frame;
const OVERHEAD_SIZE: usize;
fn to_frame(
&mut self,
payload: PipelinePayload<Ts, Opts, NdId>,
frame_size: usize,
) -> Vec<AddressedTimedData<Ts, Self::Frame, NdId>>;
}
/// Trait for unwrapping framing from a frame back into a payload.
///
/// # Type Parameters
/// - `Ts`: Timestamp type carried by the `TimedPayload`.
/// - `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<Ts, Mk> {
type Frame;
fn frame_to_message(
&mut self,
frame: TimedData<Ts, Self::Frame>,
) -> Option<(TimedPayload<Ts>, Mk)>;
}
/// Trait for applying a transport layer to a framed payload.
///
/// # Type Parameters
/// - `Ts`: Timestamp type carried by the `TimedPayload`.
/// - `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<Ts, Pkt, NdId> {
type Frame;
const OVERHEAD_SIZE: usize;
fn to_transport_packet(
&mut self,
frame: AddressedTimedData<Ts, Self::Frame, NdId>,
) -> AddressedTimedData<Ts, Pkt, NdId>;
}
/// Trait for unwrapping a transport packet back into a frame.
///
/// # Type Parameters
/// - `Ts`: Timestamp type carried by the `TimedPayload`.
/// - `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<Ts, Pkt> {
type Frame;
type Error;
fn packet_to_frame(
&mut self,
packet: Pkt,
timestamp: Ts,
) -> Result<TimedData<Ts, 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
/// - `Ts`: Timestamp type.
/// - `Pkt`: Final transport packet type.
/// - `Opts` : Option type
/// - `NdId` : Addressing 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<Ts, Pkt, Opts, NdId>:
Transport<Ts, Pkt, NdId>
+ Framing<Ts, Opts, NdId, Frame = <Self as Transport<Ts, Pkt, NdId>>::Frame>
where
Ts: Clone,
NdId: Clone,
{
// 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<Ts, Pkt, NdId>>::OVERHEAD_SIZE
+ <Self as Framing<Ts, Opts, NdId>>::OVERHEAD_SIZE,
)
.expect("packet_size smaller than transport + framing overhead")
}
fn wire_wrap(
&mut self,
payload: PipelinePayload<Ts, Opts, NdId>,
) -> Vec<AddressedTimedData<Ts, Pkt, NdId>> {
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
/// - `Ts`: Timestamp type.
/// - `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<Ts, Pkt, Mk>:
TransportUnwrap<Ts, Pkt> + FramingUnwrap<Ts, Mk, Frame = <Self as TransportUnwrap<Ts, Pkt>>::Frame>
where
Ts: Clone,
{
fn wire_unwrap(
&mut self,
input: Pkt,
timestamp: Ts,
) -> Result<Option<(TimedPayload<Ts>, Mk)>, Self::Error> {
let frame = self.packet_to_frame(input, timestamp)?;
Ok(self.frame_to_message(frame))
}
}
+162
View File
@@ -0,0 +1,162 @@
// 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 |
//! | [`mixnodes`] | 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 a
//! timestamp, threading timing information through every stage of the pipeline.
//! [`TimedPayload`] is a convenience alias for `TimedData<Ts, Vec<u8>>`.
use std::fmt::Debug;
pub mod clients;
pub mod common;
pub mod nymnodes;
pub mod packet;
/// Convenience alias for [`TimedData`] when the payload is a raw byte buffer.
pub type TimedPayload<Ts> = TimedData<Ts, Vec<u8>>;
/// Convenience alias for [`AddressedTimedData`] when the payload is a raw byte buffer.
pub type AddressedTimedPayload<Ts, NdId> = AddressedTimedData<Ts, Vec<u8>, NdId>;
/// Convenience alias for [`PipelineData`] when the payload is a raw byte buffer.
pub type PipelinePayload<Ts, Opts, NdId> = PipelineData<Ts, Vec<u8>, Opts, NdId>;
/// A value of type `D` tagged with a timestamp of type `Ts`.
///
/// `TimedData` threads timing information through every stage of the LP
/// pipeline. It is produced by [`clients::traits::Chunking`] and propagated
/// unchanged (or with the timestamp transformed) through every subsequent
/// pipeline stage until the packet is sent on the wire.
#[derive(Clone, Debug)]
pub struct TimedData<Ts, D> {
pub timestamp: Ts,
pub data: D,
}
impl<Ts, D> TimedData<Ts, D> {
pub fn new(timestamp: Ts, 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<Ts, Nd>
where
F: FnMut(D) -> Nd,
{
TimedData {
data: op(self.data),
timestamp: self.timestamp,
}
}
/// Apply `op` to the timestamp component, leaving the data unchanged.
pub fn ts_transform<F>(self, mut op: F) -> Self
where
F: FnMut(Ts) -> Ts,
{
TimedData {
data: self.data,
timestamp: op(self.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`: per-message configuration consumed by the pipeline (typically an
/// [`InputOptions`] implementor on the client side; `()` once the message is
/// reduced to an addressed payload),
/// - `dst`: the next-hop destination identifier 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
/// [`InputOptions`]: crate::clients::InputOptions
#[derive(Clone, Debug)]
pub struct PipelineData<Ts, D, Opts, NdId> {
pub data: TimedData<Ts, D>,
pub options: Opts,
pub dst: NdId,
}
impl<Ts, D, Opts, NdId> PipelineData<Ts, D, Opts, NdId> {
/// Construct a new [`PipelineData`] from its parts.
pub fn new(timestamp: Ts, 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<Ts, Nd, Opts, NdId>
where
F: FnMut(D) -> Nd,
{
PipelineData {
data: self.data.data_transform(op),
options: self.options,
dst: self.dst,
}
}
/// Apply `op` to the timestamp component, leaving the data unchanged.
pub fn ts_transform<F>(self, op: F) -> Self
where
F: FnMut(Ts) -> Ts,
{
PipelineData {
data: self.data.ts_transform(op),
options: self.options,
dst: self.dst,
}
}
/// Drop the pipeline options, producing a plain addressed payload.
pub fn into_addressed(self) -> AddressedTimedData<Ts, 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<Ts, D, NdId> = PipelineData<Ts, D, (), NdId>;
impl<Ts, D, NdId> AddressedTimedData<Ts, D, NdId> {
/// Construct a new [`AddressedTimedData`] with unit `options`.
pub fn new_addressed(timestamp: Ts, data: D, dst: NdId) -> Self {
AddressedTimedData {
data: TimedData::new(timestamp, data),
options: (),
dst,
}
}
}
+4
View File
@@ -0,0 +1,4 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
pub mod traits;
+60
View File
@@ -0,0 +1,60 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
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
/// - `Ts`: Timestamp / tick-context type.
/// - `Pkt`: Transport packet type; the same type is consumed and produced.
/// - `Opts`: Per-message pipeline options carried into the re-wrapping side.
/// - `Mk`: Message-kind marker returned by the unwrap side.
/// - `NdId`: Identifier type for the next-hop destination.
///
/// 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<Ts, Pkt, Opts, Mk, NdId>:
WireUnwrappingPipeline<Ts, Pkt, Mk> + WireWrappingPipeline<Ts, Pkt, Opts, NdId>
where
Ts: Clone,
NdId: Clone,
{
fn mix(
&mut self,
message_kind: Mk,
payload: TimedPayload<Ts>,
timestamp: Ts,
) -> Vec<PipelinePayload<Ts, Opts, NdId>>;
fn process(
&mut self,
input: Pkt,
timestamp: Ts,
) -> Result<Vec<AddressedTimedData<Ts, Pkt, NdId>>, Self::Error> {
let Some((payload, kind)) = self.wire_unwrap(input, timestamp.clone())? 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())
}
}
@@ -110,7 +110,9 @@ 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()
}
}
@@ -165,6 +167,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 +179,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.
@@ -0,0 +1,222 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use nym_lp_data::packet::{
LpFrame, LpHeader, LpPacket,
frame::{LpFrameHeader, LpFrameKind},
};
use nym_lp_data::{
AddressedTimedData, PipelinePayload,
clients::{
InputOptions,
traits::{Chunking, Obfuscation, Reliability, RoutingSecurity},
},
common::traits::{Framing, Transport},
};
#[derive(Clone, Copy)]
pub struct BasicOptions {
pub reliability: bool,
pub security: bool,
pub obfuscation: bool,
pub next_hop: u8,
}
impl InputOptions<u8> for BasicOptions {
fn reliability(&self) -> bool {
self.reliability
}
fn routing_security(&self) -> bool {
self.security
}
fn obfuscation(&self) -> bool {
self.obfuscation
}
fn next_hop(&self) -> u8 {
self.next_hop
}
}
pub type BasicPipelinePayload<Ts> = PipelinePayload<Ts, BasicOptions, u8>;
pub struct MockChunking;
impl<Ts> Chunking<Ts, BasicOptions, u8> for MockChunking
where
Ts: Clone,
{
fn chunked(
&mut self,
input: Vec<u8>,
input_options: BasicOptions,
chunk_size: usize,
timestamp: Ts,
) -> Vec<BasicPipelinePayload<Ts>> {
input
.chunks(chunk_size)
.map(|chunk| {
BasicPipelinePayload::new(
timestamp.clone(),
chunk.to_vec(),
input_options,
input_options.next_hop(),
)
})
.collect()
}
}
pub struct MockReliability;
impl MockReliability {
const HEADER: &[u8; 5] = b"0KCP0";
}
impl<Ts> Reliability<Ts, BasicOptions, u8> for MockReliability {
const OVERHEAD_SIZE: usize = Self::HEADER.len();
fn reliable_encode(
&mut self,
input: Option<BasicPipelinePayload<Ts>>,
_: Ts,
) -> Vec<BasicPipelinePayload<Ts>> {
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<Ts> RoutingSecurity<Ts, BasicOptions, u8> for MockSphinxSecurity {
const OVERHEAD_SIZE: usize = Self::HEADER.len();
fn nb_frames(&self) -> usize {
self.nb_frames
}
fn encrypt(&mut self, input: BasicPipelinePayload<Ts>) -> BasicPipelinePayload<Ts> {
input.data_transform(|data| {
let mut packet = Self::HEADER.to_vec();
packet.extend(data);
packet
})
}
}
pub struct KekwObfuscation;
impl Obfuscation<u32, BasicOptions, u8> for KekwObfuscation {
fn obfuscate(
&mut self,
input: Option<BasicPipelinePayload<u32>>,
_timestamp: u32,
) -> Vec<BasicPipelinePayload<u32>> {
if let Some(input) = input {
vec![input.ts_transform(|ts| ts + 1)]
} else {
Vec::new()
}
}
}
#[allow(dead_code)]
pub struct ReallyOddObfuscation {
next_ts: u32,
}
impl ReallyOddObfuscation {
#[allow(dead_code)]
pub fn new(start_ts: u32) -> Self {
let next_ts = if !start_ts.is_multiple_of(2) {
start_ts
} else {
start_ts + 1
};
Self { next_ts }
}
}
impl Obfuscation<u32, BasicOptions, u8> for ReallyOddObfuscation {
fn obfuscate(
&mut self,
input: Option<BasicPipelinePayload<u32>>,
_timestamp: u32,
) -> Vec<BasicPipelinePayload<u32>> {
if let Some(input) = input {
let pkt = input.ts_transform(|_| self.next_ts);
self.next_ts += 2;
vec![pkt]
} else {
Vec::new()
}
}
}
pub struct MockLpFraming;
impl MockLpFraming {
const FRAME_ATTRIBUTES: &[u8; 14] = b"0LpFrameAttrs0";
}
impl<Ts> Framing<Ts, BasicOptions, u8> for MockLpFraming
where
Ts: Clone,
{
type Frame = LpFrame;
const OVERHEAD_SIZE: usize = LpFrameHeader::SIZE;
fn to_frame(
&mut self,
input: PipelinePayload<Ts, BasicOptions, u8>,
frame_size: usize,
) -> Vec<AddressedTimedData<Ts, LpFrame, u8>> {
input
.data
.data
.chunks(frame_size)
.map(|frame_payload| {
let header = LpFrameHeader::new(LpFrameKind::Opaque, *Self::FRAME_ATTRIBUTES);
AddressedTimedData::new_addressed(
input.data.timestamp.clone(),
LpFrame {
header,
content: frame_payload.to_vec().into(),
},
input.dst,
)
})
.collect()
}
}
pub struct MockLpTransport;
impl<Ts> Transport<Ts, LpPacket, u8> for MockLpTransport {
type Frame = LpFrame;
const OVERHEAD_SIZE: usize = LpHeader::SIZE;
fn to_transport_packet(
&mut self,
input: AddressedTimedData<Ts, Self::Frame, u8>,
) -> AddressedTimedData<Ts, LpPacket, u8> {
AddressedTimedData::new_addressed(
input.data.timestamp,
LpPacket::new(LpHeader::new(7, 7, 7), input.data.data),
input.dst,
)
}
}
@@ -0,0 +1,35 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
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, 1);
assert!(output.is_empty());
}
// TODO More test to come later
+4 -1
View File
@@ -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 -2
View File
@@ -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() -> (
+2 -2
View File
@@ -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};
+5 -2
View File
@@ -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();
-33
View File
@@ -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(())
}
}
+1 -2
View File
@@ -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 -1
View File
@@ -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;
+35
View File
@@ -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(())
}
}
+3 -3
View File
@@ -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 -1
View File
@@ -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 {
+2 -2
View File
@@ -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 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
+1
View File
@@ -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" }
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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 }
+2 -2
View File
@@ -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() {
+44
View File
@@ -0,0 +1,44 @@
[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-sphinx.workspace = true
nym-crypto = { workspace = true, features = ["serde", "rand"] }
[lints]
workspace = true
+162
View File
@@ -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
```
+102
View File
@@ -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(())
}
+279
View File
@@ -0,0 +1,279 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use std::{fmt::Debug, io::ErrorKind, net::UdpSocket, sync::Arc};
use nym_lp_data::AddressedTimedData;
use crate::{
node::NodeId,
packet::WirePacketFormat,
topology::{TopologyClient, directory::Directory},
};
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<Ts: Clone + PartialOrd + Debug + Send>: Send {
fn tick(&mut self, timestamp: Ts);
}
/// 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<Ts, 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: Ts,
) -> Vec<AddressedTimedData<Ts, SndPkt, NodeId>>;
/// 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: Ts) -> 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<Ts, 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,
/// Shared routing table used to resolve next-hop [`NodeId`]s to addresses.
directory: Arc<Directory>,
/// 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<Ts, SndPkt, NodeId>>,
/// 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<Ts, Pc, SndPkt, RcvPkt> BaseClient<Ts, Pc, SndPkt, RcvPkt> {
/// Bind both UDP sockets to the given addresses.
pub(crate) fn with_pipeline(
topology_client: &TopologyClient,
directory: Arc<Directory>,
processing_client: Pc,
) -> anyhow::Result<Self> {
let mix_socket = UdpSocket::bind(topology_client.mixnet_address)?;
mix_socket.set_nonblocking(true)?;
let app_socket = UdpSocket::bind(topology_client.app_address)?;
app_socket.set_nonblocking(true)?;
Ok(Self {
id: topology_client.client_id,
mix_socket,
app_socket,
directory,
outgoing_queue: Vec::new(),
processing_client,
_marker: std::marker::PhantomData,
})
}
}
impl<Ts, Pc, SndPkt, RcvPkt> BaseClient<Ts, 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 [`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_id: NodeId, packet: SndPkt) {
if let Some(node) = self.directory.node(node_id) {
if let Err(e) = self.mix_socket.send_to(&packet.to_bytes(), node.addr) {
tracing::error!("[Client {}] Failed to send to node {node_id}: {e}", self.id);
} else {
tracing::debug!(
"[Client {}] Sent packet to node {node_id} @ {}",
self.id,
node.addr
);
}
} else {
tracing::error!("[Client {}] Node {node_id} not found in directory", 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<Ts, Pc, SndPkt, RcvPkt> MixSimClient<Ts> for BaseClient<Ts, Pc, SndPkt, RcvPkt>
where
Ts: Clone + PartialOrd + Debug + Send,
SndPkt: WirePacketFormat + Debug + Send,
RcvPkt: WirePacketFormat + Debug + Send,
Pc: ProcessingClient<Ts, SndPkt, RcvPkt>,
{
fn tick(&mut self, timestamp: Ts) {
self.tick_app_incoming(timestamp.clone());
self.tick_outgoing(timestamp.clone());
self.tick_mix_incoming(timestamp);
}
}
impl<Ts, Pc, SndPkt, RcvPkt> BaseClient<Ts, Pc, SndPkt, RcvPkt>
where
Ts: Clone + PartialOrd + Debug + Send,
SndPkt: WirePacketFormat + Debug + Send,
RcvPkt: WirePacketFormat + Debug + Send,
Pc: ProcessingClient<Ts, 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: Ts) {
// 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.clone());
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: Ts) {
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: Ts) {
while let Some(result) = self.recv_from_mix() {
match result {
Ok(pkt) => match self.processing_client.unwrap(pkt, timestamp.clone()) {
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);
}
}
}
}
}
+266
View File
@@ -0,0 +1,266 @@
// 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::sync::Arc;
use nym_lp_data::{
AddressedTimedData, PipelinePayload, TimedData, TimedPayload,
clients::{
InputOptions,
helpers::{NoOpObfuscation, NoOpReliability, NoOpRoutingSecurity},
traits::{Chunking, ClientUnwrappingPipeline, ClientWrappingPipeline},
},
common::traits::{
Framing, FramingUnwrap, Transport, TransportUnwrap, WireUnwrappingPipeline,
WireWrappingPipeline,
},
};
use crate::{
client::{BaseClient, ClientId, ProcessingClient},
node::NodeId,
packet::simple::{
SimpleFrame, SimpleMessage, 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<Ts> = BaseClient<Ts, SimpleProcessingClient, SimplePacket>;
impl<Ts> SimpleClient<Ts> {
/// 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> {
let processing_client = SimpleProcessingClient {
wrapper: SimpleClientWrappingPipeline::default(),
unwrapper: SimpleClientUnwrapping::default(),
};
BaseClient::with_pipeline(&topology_client, directory, processing_client)
}
}
/// [`InputOptions`] for the simple pipeline — all optional features disabled,
/// next hop is always node 0, really simple routing
#[derive(Clone, Copy)]
pub struct SimpleInputOptions;
impl InputOptions<NodeId> for SimpleInputOptions {
fn reliability(&self) -> bool {
false
}
fn routing_security(&self) -> bool {
false
}
fn obfuscation(&self) -> bool {
false
}
fn next_hop(&self) -> NodeId {
0
}
}
/// Bridges [`BaseClient`] to the simple wrapping and unwrapping pipelines.
pub struct SimpleProcessingClient {
wrapper: SimpleClientWrappingPipeline,
unwrapper: SimpleClientUnwrapping,
}
impl<Ts: Clone> ProcessingClient<Ts, SimplePacket> for SimpleProcessingClient {
fn process(
&mut self,
input: Vec<u8>,
_: ClientId,
timestamp: Ts,
) -> Vec<AddressedTimedData<Ts, SimplePacket, NodeId>> {
self.wrapper
.process(Some((input, SimpleInputOptions)), timestamp)
}
fn unwrap(&mut self, input: SimplePacket, timestamp: Ts) -> 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);
pub(crate) type SimplePipelinePayload<Ts> = PipelinePayload<Ts, SimpleInputOptions, NodeId>;
impl Default for SimpleClientWrappingPipeline {
fn default() -> Self {
Self(SimpleWireWrapper)
}
}
impl<Ts: Clone> Chunking<Ts, SimpleInputOptions, NodeId> 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,
mut input: Vec<u8>,
options: SimpleInputOptions,
chunk_size: usize,
timestamp: Ts,
) -> Vec<SimplePipelinePayload<Ts>> {
input.push(1);
if !input.len().is_multiple_of(chunk_size) {
let padding = vec![0; chunk_size - input.len() % chunk_size];
input.extend_from_slice(&padding);
}
input
.chunks(chunk_size)
.map(|chunk| {
SimplePipelinePayload::new(
timestamp.clone(),
chunk.to_vec(),
options,
options.next_hop(),
)
})
.collect()
}
}
impl NoOpReliability for SimpleClientWrappingPipeline {}
impl NoOpObfuscation for SimpleClientWrappingPipeline {}
impl NoOpRoutingSecurity for SimpleClientWrappingPipeline {}
// Delegation to SimpleWireWrapper
impl<Ts: Clone> Framing<Ts, SimpleInputOptions, NodeId> for SimpleClientWrappingPipeline {
type Frame = SimpleFrame;
const OVERHEAD_SIZE: usize = <SimpleWireWrapper as Framing<Ts, _, _>>::OVERHEAD_SIZE;
fn to_frame(
&mut self,
payload: PipelinePayload<Ts, SimpleInputOptions, NodeId>,
frame_size: usize,
) -> Vec<AddressedTimedData<Ts, SimpleFrame, NodeId>> {
self.0.to_frame(payload, frame_size)
}
}
// Delegation to SimpleWireWrapper
impl<Ts: Clone> Transport<Ts, SimplePacket, NodeId> for SimpleClientWrappingPipeline {
type Frame = SimpleFrame;
const OVERHEAD_SIZE: usize = <SimpleWireWrapper as Transport<Ts, _, _>>::OVERHEAD_SIZE;
fn to_transport_packet(
&mut self,
frame: AddressedTimedData<Ts, SimpleFrame, NodeId>,
) -> AddressedTimedData<Ts, SimplePacket, NodeId> {
self.0.to_transport_packet(frame)
}
}
// Delegation to SimpleWireWrapper
impl<Ts: Clone> WireWrappingPipeline<Ts, SimplePacket, SimpleInputOptions, NodeId>
for SimpleClientWrappingPipeline
{
fn packet_size(&self) -> usize {
<SimpleWireWrapper as WireWrappingPipeline<Ts, _, _, _>>::packet_size(&self.0)
}
}
impl<Ts: Clone> ClientWrappingPipeline<Ts, SimplePacket, SimpleInputOptions, NodeId>
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<Ts> FramingUnwrap<Ts, SimpleMessage> for SimpleClientUnwrapping {
type Frame = SimpleFrame;
fn frame_to_message(
&mut self,
frame: TimedData<Ts, SimpleFrame>,
) -> Option<(TimedPayload<Ts>, SimpleMessage)> {
self.0.frame_to_message(frame)
}
}
// Delegation to SimpleWireUnwrapper
impl<Ts: Clone> TransportUnwrap<Ts, SimplePacket> for SimpleClientUnwrapping {
type Frame = SimpleFrame;
type Error = anyhow::Error;
fn packet_to_frame(
&mut self,
packet: SimplePacket,
timestamp: Ts,
) -> anyhow::Result<TimedData<Ts, SimpleFrame>> {
self.0.packet_to_frame(packet, timestamp)
}
}
impl<Ts: Clone> WireUnwrappingPipeline<Ts, SimplePacket, SimpleMessage> for SimpleClientUnwrapping {}
impl<Ts: Clone> ClientUnwrappingPipeline<Ts, SimplePacket, SimpleMessage>
for SimpleClientUnwrapping
{
fn process_unwrapped(
&mut self,
payload: TimedPayload<Ts>,
_kind: SimpleMessage,
) -> Option<Vec<u8>> {
let mut data = payload.data;
if let Some(pos) = data.iter().rposition(|&b| b == 1) {
data.truncate(pos);
}
Some(data)
}
}
+362
View File
@@ -0,0 +1,362 @@
// 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;
use nym_lp_data::{
AddressedTimedData, PipelinePayload, TimedPayload,
clients::{
InputOptions,
traits::{
Chunking, ClientUnwrappingPipeline, ClientWrappingPipeline, Obfuscation, Reliability,
RoutingSecurity,
},
},
common::{
helpers::{NoOpWireUnwrapper, NoOpWireWrapper},
traits::{Framing, Transport, WireWrappingPipeline},
},
};
use nym_sphinx::{
Delay, Destination, DestinationAddressBytes, 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},
},
node::NodeId,
packet::sphinx::{GenerateDelay, SimMixPacket, SphinxMessage, SurbAck},
topology::{TopologyClient, directory::Directory},
};
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<Ts, R> = BaseClient<Ts, SphinxProcessingClient<Ts, R>, SimMixPacket, Vec<u8>>;
impl<Ts: Clone + GenerateDelay + PartialOrd + Send, R: Rng + Clone + Send> SphinxClient<Ts, 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: Ts,
rng: R,
) -> anyhow::Result<Self> {
let processing_client = SphinxProcessingClient {
wrapper: SphinxClientWrappingPipeline {
cover_traffic: PoissonCoverTraffic::new(
topology_client.client_id,
directory.clone(),
current_timestamp,
rng.clone(),
),
reliability: SurbAcksReliability::new(
rng.clone(),
topology_client.client_id,
directory.clone(),
),
directory: directory.clone(),
rng,
},
unwrapper: SphinxClientUnwrapping::default(),
};
BaseClient::with_pipeline(&topology_client, directory, processing_client)
}
}
/// [`InputOptions`] for the Sphinx pipeline — reliability, routing security,
/// and obfuscation are all enabled.
#[derive(Clone, Copy)]
pub struct SphinxInputOptions {
/// Destination client ID, embedded in the Sphinx destination address.
dst: ClientId,
/// First-hop node ID. In a real Nym network this would be the client's
/// gateway; here it is chosen at random from the topology because there is
/// no gateway concept in the simulation.
next_hop: NodeId,
}
impl InputOptions<NodeId> for SphinxInputOptions {
fn reliability(&self) -> bool {
true
}
fn routing_security(&self) -> bool {
true
}
fn obfuscation(&self) -> bool {
true
}
fn next_hop(&self) -> NodeId {
self.next_hop
}
}
/// Bridges [`BaseClient`] to the Sphinx wrapping and unwrapping pipelines.
pub struct SphinxProcessingClient<Ts: Clone + GenerateDelay + PartialOrd, R: Rng> {
wrapper: SphinxClientWrappingPipeline<Ts, R>,
unwrapper: SphinxClientUnwrapping,
}
impl<Ts: Clone + GenerateDelay + PartialOrd + Send, R: Rng + Send>
ProcessingClient<Ts, SimMixPacket, Vec<u8>> for SphinxProcessingClient<Ts, R>
{
fn process(
&mut self,
input: Vec<u8>,
dst: ClientId,
timestamp: Ts,
) -> Vec<AddressedTimedData<Ts, SimMixPacket, NodeId>> {
let input_options = SphinxInputOptions {
dst,
next_hop: self
.wrapper
.directory
.random_next_hop(&mut self.wrapper.rng), // This substitutes for a real gateway selection — in the simulation every node is equally eligible as a first hop
};
self.wrapper
.process(Some((input, input_options)), timestamp)
}
fn unwrap(&mut self, input: Vec<u8>, timestamp: Ts) -> 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<Ts: Clone + GenerateDelay + PartialOrd, R: Rng> {
/// Poisson cover traffic generator providing the [`Obfuscation`] stage.
cover_traffic: PoissonCoverTraffic<Ts, 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,
}
pub(crate) type SphinxPipelinePayload<Ts> = PipelinePayload<Ts, SphinxInputOptions, NodeId>;
impl<Ts: Clone + GenerateDelay + PartialOrd, R: Rng> Chunking<Ts, SphinxInputOptions, NodeId>
for SphinxClientWrappingPipeline<Ts, R>
{
fn chunked(
&mut self,
input: Vec<u8>,
options: SphinxInputOptions,
chunk_size: usize,
timestamp: Ts,
) -> Vec<SphinxPipelinePayload<Ts>> {
if input.is_empty() {
return Vec::new();
}
// This is using standard sphinx chunking. Proper LP should use a different one
let fragments = NymMessage::new_plain(input)
.pad_to_full_packet_lengths(chunk_size)
.split_into_fragments(&mut self.rng, chunk_size);
fragments
.into_iter()
.map(|fragment| {
SphinxPipelinePayload::new(
timestamp.clone(),
fragment.into_bytes(),
options,
options.dst,
)
})
.collect()
}
}
impl<Ts: Clone + GenerateDelay + PartialOrd, R: Rng> Reliability<Ts, SphinxInputOptions, NodeId>
for SphinxClientWrappingPipeline<Ts, R>
{
const OVERHEAD_SIZE: usize =
<SurbAcksReliability<R> as Reliability<Ts, SphinxInputOptions, _>>::OVERHEAD_SIZE;
fn reliable_encode(
&mut self,
input: Option<SphinxPipelinePayload<Ts>>,
timestamp: Ts,
) -> Vec<SphinxPipelinePayload<Ts>> {
self.reliability.reliable_encode(input, timestamp)
}
}
impl<Ts: Clone + GenerateDelay + PartialOrd, R: Rng> Obfuscation<Ts, SphinxInputOptions, NodeId>
for SphinxClientWrappingPipeline<Ts, R>
{
fn obfuscate(
&mut self,
input: Option<SphinxPipelinePayload<Ts>>,
timestamp: Ts,
) -> Vec<SphinxPipelinePayload<Ts>> {
self.cover_traffic.obfuscate(input, timestamp)
}
}
impl<Ts: Clone + GenerateDelay + PartialOrd, R: Rng> RoutingSecurity<Ts, SphinxInputOptions, NodeId>
for SphinxClientWrappingPipeline<Ts, 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
/// [`GenerateDelay::generate_mix_delay`].
fn encrypt(&mut self, input: SphinxPipelinePayload<Ts>) -> SphinxPipelinePayload<Ts> {
// SAFETY: IDs were sampled from the directory, so they are guaranteed to exist.
#[allow(clippy::unwrap_used)]
let first_hop = self.directory.node(input.options.next_hop).unwrap().into();
let route = std::iter::once(first_hop)
.chain(
self.directory
.random_route(2, &mut self.rng)
.iter()
.map(Into::into),
)
.collect::<Vec<_>>();
let destination = Destination::new(
DestinationAddressBytes::from_bytes([input.options.dst; 32]),
[input.options.dst; 16],
);
let delays = (0..route.len())
.map(|_| Delay::new_from_millis(Ts::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<
Ts,
SimMixPacket,
SphinxInputOptions,
NodeId,
>>::packet_size(self)
- <Self as Framing<Ts, SphinxInputOptions, NodeId>>::OVERHEAD_SIZE
- <Self as Transport<Ts, SimMixPacket, NodeId>>::OVERHEAD_SIZE
- <Self as RoutingSecurity<Ts, _, _>>::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();
SphinxPipelinePayload::new(
input.data.timestamp,
packet.to_bytes(),
input.options,
input.dst,
)
}
}
impl<Ts: Clone + GenerateDelay + PartialOrd, R: Rng> NoOpWireWrapper
for SphinxClientWrappingPipeline<Ts, R>
{
}
impl<Ts: Clone + GenerateDelay + PartialOrd, R: Rng>
ClientWrappingPipeline<Ts, SimMixPacket, SphinxInputOptions, NodeId>
for SphinxClientWrappingPipeline<Ts, 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<Ts: Clone> ClientUnwrappingPipeline<Ts, Vec<u8>, SphinxMessage> for SphinxClientUnwrapping {
fn process_unwrapped(
&mut self,
timed_plaintext: TimedPayload<Ts>,
_kind: SphinxMessage,
) -> 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,152 @@
// 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;
use nym_lp_data::clients::traits::Obfuscation;
use nym_sphinx::cover::LOOP_COVER_MESSAGE_PAYLOAD;
use rand::Rng;
use crate::{
client::{
ClientId,
sphinx::{SphinxInputOptions, SphinxPipelinePayload},
},
node::NodeId,
packet::sphinx::GenerateDelay,
topology::directory::Directory,
};
/// 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<Ts, R>
where
Ts: Clone + GenerateDelay + PartialOrd,
R: Rng,
{
address: ClientId,
directory: Arc<Directory>,
/// Timestamp at which the main loop next fires (real or cover packet).
main_loop_next_timestamp: Ts,
/// Timestamp at which the secondary cover loop next fires.
secondary_loop_next_timestamp: Ts,
/// Random number generator used for exponential delay sampling.
rng: R,
}
impl<Ts, R> PoissonCoverTraffic<Ts, R>
where
Ts: Clone + GenerateDelay + PartialOrd,
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(
address: ClientId,
directory: Arc<Directory>,
current_timestamp: Ts,
rng: R,
) -> Self {
Self {
address,
directory,
main_loop_next_timestamp: current_timestamp.clone(),
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.address,
next_hop: self.directory.random_next_hop(&mut self.rng),
}
}
}
impl<Ts, R> Obfuscation<Ts, SphinxInputOptions, NodeId> for PoissonCoverTraffic<Ts, R>
where
Ts: Clone + GenerateDelay + PartialOrd,
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<SphinxPipelinePayload<Ts>>,
timestamp: Ts,
) -> Vec<SphinxPipelinePayload<Ts>> {
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(SphinxPipelinePayload::new(
timestamp.clone(),
LOOP_COVER_MESSAGE_PAYLOAD.to_vec(),
cover_options,
cover_options.next_hop,
));
self.secondary_loop_next_timestamp = self.secondary_loop_next_timestamp.clone()
+ Ts::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.ts_transform(|_| self.main_loop_next_timestamp.clone()));
self.main_loop_next_timestamp = self.main_loop_next_timestamp.clone()
+ Ts::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(SphinxPipelinePayload::new(
timestamp,
LOOP_COVER_MESSAGE_PAYLOAD.to_vec(),
cover_options,
cover_options.next_hop,
));
self.main_loop_next_timestamp = self.main_loop_next_timestamp.clone()
+ Ts::generate_sending_delay(&mut self.rng);
}
// No message, not the time to send anything, nothing to do
None => {}
}
output
}
}
@@ -0,0 +1,97 @@
// 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;
use crate::{
client::{
ClientId,
sphinx::{SphinxInputOptions, SphinxPipelinePayload},
},
node::NodeId,
packet::sphinx::{GenerateDelay, SurbAck},
topology::directory::Directory,
};
use nym_lp_data::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,
{
address: ClientId,
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, address: ClientId, directory: Arc<Directory>) -> Self {
Self {
address,
directory,
rng,
}
}
}
impl<Ts, R> Reliability<Ts, SphinxInputOptions, NodeId> for SurbAcksReliability<R>
where
R: Rng,
Ts: GenerateDelay,
{
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<SphinxPipelinePayload<Ts>>,
_timestamp: Ts,
) -> Vec<SphinxPipelinePayload<Ts>> {
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::<Ts, R>(
&mut self.rng,
self.address,
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()
}
}
}
+245
View File
@@ -0,0 +1,245 @@
// 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 simple;
mod sphinx;
pub use simple::SimpleMixDriver;
pub use sphinx::{DiscreteSphinxMixDriver, 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<Ts>
where
Ts: Clone + PartialOrd + Debug + Send,
{
nodes: Vec<Box<dyn MixSimNode<Ts> + Send>>,
clients: Vec<Box<dyn MixSimClient<Ts> + Send>>,
}
impl<Ts> MixSimDriver<Ts>
where
Ts: Clone + PartialOrd + Debug + Send,
{
/// 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<Ts> + Send>>,
clients: Vec<Box<dyn MixSimClient<Ts> + Send>>,
) -> Self {
Self { nodes, clients }
}
/// Pretty-print the current state of every node at `tick`.
pub fn display_state(&self, tick: Ts) {
println!(
"┌─── Tick {tick:─<3?}────────────────────────────────────────────────────────────┐"
);
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: Ts, display_state: bool) {
for client in &mut self.clients {
client.tick(timestamp.clone());
}
// Phase 1 — incoming
for node in &mut self.nodes {
node.tick_incoming();
}
if display_state {
self.display_state(timestamp.clone());
}
// Phase 2 — processing
for node in &mut self.nodes {
node.tick_processing(timestamp.clone());
}
if display_state {
self.display_state(timestamp.clone());
}
// Phase 3 — outgoing
for node in &mut self.nodes {
node.tick_outgoing(timestamp.clone());
}
}
}
/// Driving logic for the concrete `Ts = u32` timestamp flavour.
///
/// The timestamp is a monotonically increasing tick counter starting at zero.
/// If a richer timestamp type is needed in the future, a new impl block should
/// be added.
impl MixSimDriver<u32> {
/// Start the simulation in either manual or automatic mode.
pub async fn run(
self,
manual_mode: bool,
display_state: bool,
start_tick: u32,
tick_duration_ms: u64,
) -> anyhow::Result<()> {
if manual_mode {
self.run_manual(start_tick, display_state)
} else {
self.run_automatic(start_tick, 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,
start_tick: u32,
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 {
let mut current_tick = start_tick;
loop {
self.tick(current_tick, false);
current_tick += 1;
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, start_tick: u32, display_state: bool) -> anyhow::Result<()> {
info!("Manual mode: press ENTER to advance a tick, Ctrl-C to quit");
let mut current_tick = start_tick;
let mut line = String::new();
loop {
line.clear();
std::io::stdin().read_line(&mut line)?;
info!("Tick {current_tick}");
self.tick(current_tick, display_state);
current_tick += 1;
}
}
}
/// Driving logic for the concrete `Ts = Instant` timestamp flavour.
impl MixSimDriver<Instant> {
/// Start the simulation in either manual or automatic mode.
pub async fn run(self, manual_mode: bool, tick_duration_ms: u64) -> anyhow::Result<()> {
if manual_mode {
tracing::error!("Instant-based MixSim is incompatible with manual driving mode");
Ok(())
} 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(())
}
}
/// 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, discrete tick counter.
Simple,
/// Full Sphinx encryption, wall-clock timestamps, automatic mode only.
Sphinx,
/// Full Sphinx encryption, discrete tick counter, supports manual mode.
#[default]
DiscreteSphinx,
}
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, tick_duration_ms)
.await
}
SimDriver::DiscreteSphinx => {
DiscreteSphinxMixDriver::new(topology)?
.run(manual, display_state, tick_duration_ms)
.await
}
}
}
}
+68
View File
@@ -0,0 +1,68 @@
// 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<u32>);
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<u32> + 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<u32> + 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, 0, tick_duration_ms)
.await
}
}
+116
View File
@@ -0,0 +1,116 @@
// 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<Instant>);
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<Instant> + 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<Instant> + 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, tick_duration_ms: u64) -> anyhow::Result<()> {
self.0.run(false, tick_duration_ms).await
}
}
/// Concrete [`MixSimDriver`] instantiation that uses full Sphinx encryption with a
/// discrete tick counter.
///
/// Each tick corresponds to 1 ms of simulated time, enabling deterministic
/// stepping and delay arithmetic without requiring wall-clock time. This is
/// the default driver and the only Sphinx variant that supports manual mode.
pub struct DiscreteSphinxMixDriver(MixSimDriver<u32>);
impl DiscreteSphinxMixDriver {
const START_TICK: u32 = 0;
/// 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<u32> + 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<u32> + Send>> =
Vec::with_capacity(topology.clients.len());
for top_client in topology.clients {
let client = SphinxClient::new(top_client, directory.clone(), Self::START_TICK, OsRng)?;
clients.push(Box::new(client));
}
Ok(DiscreteSphinxMixDriver(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,
Self::START_TICK,
tick_duration_ms,
)
.await
}
}
+43
View File
@@ -0,0 +1,43 @@
// 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 node;
pub mod packet;
pub mod topology;
+130
View File
@@ -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 = 6)]
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 (automatic mode only).
#[arg(short = 'd', long, default_value = "1")]
tick_duration_ms: u64,
/// Simulation driver to use: simple | sphinx | discrete-sphinx (default).
#[arg(long, default_value_t = SimDriver::DiscreteSphinx)]
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(())
}
+255
View File
@@ -0,0 +1,255 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use std::{
fmt::Debug,
io::ErrorKind,
net::{SocketAddr, UdpSocket},
sync::Arc,
};
use nym_lp_data::AddressedTimedData;
use crate::{packet::WirePacketFormat, topology::directory::Directory};
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<Ts: Clone + PartialOrd + Debug + Send>: 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: Ts);
/// **Phase 3** — forward all outbound packets whose scheduled timestamp is
/// ≤ `timestamp` to their next-hop address.
fn tick_outgoing(&mut self, timestamp: Ts);
/// Pretty-print the node's current buffer state to stdout (used in manual mode).
fn display_state(&self);
}
/// Minimal pipeline interface used by [`BaseNode`].
///
/// Hides the `Frame` and message-marker type parameters of
/// [`NymNodeProcessingPipeline`] so that [`BaseNode`] only needs
/// `<Ts, Pkt, Pipeline>` rather than five generics.
///
/// Implement [`NymNodeProcessingPipeline`] on your concrete type and then add
/// a trivial delegation impl of this trait; the two-line body just calls
/// through to [`NymNodeProcessingPipeline::process`].
///
/// [`NymNodeProcessingPipeline`]: nym_lp_data::mixnodes::traits::NymNodeProcessingPipeline
/// [`NymNodeProcessingPipeline::process`]: nym_lp_data::mixnodes::traits::NymNodeProcessingPipeline::process
pub trait ProcessingNode<Ts, Pkt>: Send {
fn process(
&mut self,
input: Pkt,
timestamp: Ts,
) -> anyhow::Result<Vec<AddressedTimedData<Ts, Pkt, NodeId>>>;
}
/// Full mix-node state: UDP transport, routing directory, packet buffers, and
/// processing pipeline.
///
/// `Ts` is the timestamp / tick-context type. `Pkt` is the wire packet type
/// (e.g. [`SimplePacket`] or [`SimMixPacket`]). `Pn` is any type that
/// implements [`ProcessingNode<Ts, 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<Ts, 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,
/// Shared routing table used to resolve next-hop [`NodeId`]s to addresses.
directory: Arc<Directory>,
/// 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<Ts, Pkt, NodeId>>,
/// Concrete mix-processing implementation invoked by `tick_processing`.
processing_node: Pn,
}
impl<Ts, Pkt, Pn> BaseNode<Ts, 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,
directory: Arc<Directory>,
processing_node: Pn,
) -> anyhow::Result<Self> {
let socket = UdpSocket::bind(socket_address)?;
socket.set_nonblocking(true)?;
Ok(Self {
id,
_reliability: reliability,
socket_address,
socket,
directory,
packets_to_process: Vec::new(),
processed_packets: Vec::new(),
processing_node,
})
}
/// Send `packet` to the node or client identified by `node_id`.
///
/// Resolves `node_id` against the shared [`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_id: NodeId, packet: Pkt)
where
Pkt: WirePacketFormat,
{
if let Some(node) = self.directory.node(node_id) {
if let Err(e) = self.socket.send_to(&packet.to_bytes(), node.addr) {
tracing::error!(
"[Node {}] Failed to send data to node {node_id} : {e}",
self.id
);
} else {
tracing::debug!(
"[Node {}] Successfully sent a packet to node {node_id}",
self.id
);
}
} else if let Some(client) = self.directory.client(node_id) {
if let Err(e) = self.socket.send_to(&packet.to_bytes(), client) {
tracing::error!(
"[Node {}] Failed to send data to client {node_id} : {e}",
self.id
);
} else {
tracing::debug!(
"[Node {}] Successfully sent a packet to client {node_id} @ {client}",
self.id
);
}
} else {
tracing::error!(
"[Node {}] Trying to send to non-existing node/client {node_id}",
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<Ts, Pkt, Pn> MixSimNode<Ts> for BaseNode<Ts, Pkt, Pn>
where
Ts: Clone + PartialOrd + Debug + Send,
Pkt: WirePacketFormat + Debug + Send,
Pn: ProcessingNode<Ts, Pkt>,
{
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: Ts) {
while let Some(packet) = self.packets_to_process.pop() {
match self.processing_node.process(packet, timestamp.clone()) {
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: Ts) {
let to_send = self
.processed_packets
.extract_if(.., |pkt| pkt.data.timestamp <= timestamp)
.collect::<Vec<_>>();
for pkt in to_send {
self.send_to_node(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:?}");
}
}
}
}
+168
View File
@@ -0,0 +1,168 @@
// 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::sync::Arc;
use nym_lp_data::{
AddressedTimedData, PipelinePayload, TimedData, TimedPayload,
common::traits::{
Framing, FramingUnwrap, Transport, TransportUnwrap, WireUnwrappingPipeline,
WireWrappingPipeline,
},
nymnodes::traits::NymNodeProcessingPipeline,
};
use crate::{
client::simple::SimpleInputOptions,
node::{BaseNode, NodeId, ProcessingNode},
packet::simple::{
SimpleFrame, SimpleMessage, 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<Ts> = BaseNode<Ts, SimplePacket, SimpleProcessingNode>;
impl<Ts> SimpleNode<Ts> {
/// 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);
BaseNode::with_pipeline(
topology_node.node_id,
topology_node.reliability,
topology_node.socket_address,
directory,
pipeline,
)
}
}
impl<Ts: Clone> ProcessingNode<Ts, SimplePacket> for SimpleProcessingNode {
fn process(
&mut self,
input: SimplePacket,
timestamp: Ts,
) -> anyhow::Result<Vec<AddressedTimedData<Ts, SimplePacket, NodeId>>> {
NymNodeProcessingPipeline::<Ts, SimplePacket, SimpleInputOptions, SimpleMessage, NodeId>::process(
self, input, timestamp,
)
}
}
// ─────────────────────────────────────────────────────────────────────────────
/// 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 {
id: NodeId,
wrapper: SimpleWireWrapper,
unwrapper: SimpleWireUnwrapper,
}
impl SimpleProcessingNode {
/// Construct a pipeline for the node identified by `id`.
pub fn new(id: NodeId) -> Self {
Self {
id,
wrapper: SimpleWireWrapper,
unwrapper: SimpleWireUnwrapper,
}
}
}
impl<Ts: Clone>
NymNodeProcessingPipeline<Ts, SimplePacket, SimpleInputOptions, SimpleMessage, NodeId>
for SimpleProcessingNode
{
/// Route the payload to the next node in the chain (`self.id + 1`).
///
/// This is a trivial fixed routing rule used for simulation testing.
/// Real mix nodes would perform cryptographic route unwrapping here.
fn mix(
&mut self,
_: SimpleMessage,
payload: TimedPayload<Ts>,
_timestamp: Ts,
) -> Vec<PipelinePayload<Ts, SimpleInputOptions, NodeId>> {
vec![PipelinePayload::new(
payload.timestamp,
payload.data,
SimpleInputOptions,
self.id + 1,
)]
}
}
// Delegation of subtraits
impl<Ts: Clone> Framing<Ts, SimpleInputOptions, NodeId> for SimpleProcessingNode {
type Frame = SimpleFrame;
const OVERHEAD_SIZE: usize = <SimpleWireWrapper as Framing<Ts, _, _>>::OVERHEAD_SIZE;
fn to_frame(
&mut self,
payload: PipelinePayload<Ts, SimpleInputOptions, NodeId>,
frame_size: usize,
) -> Vec<AddressedTimedData<Ts, SimpleFrame, NodeId>> {
self.wrapper.to_frame(payload, frame_size)
}
}
impl<Ts: Clone> Transport<Ts, SimplePacket, NodeId> for SimpleProcessingNode {
type Frame = SimpleFrame;
const OVERHEAD_SIZE: usize = <SimpleWireWrapper as Transport<Ts, _, _>>::OVERHEAD_SIZE;
fn to_transport_packet(
&mut self,
frame: AddressedTimedData<Ts, SimpleFrame, NodeId>,
) -> AddressedTimedData<Ts, SimplePacket, NodeId> {
self.wrapper.to_transport_packet(frame)
}
}
impl<Ts: Clone> WireWrappingPipeline<Ts, SimplePacket, SimpleInputOptions, NodeId>
for SimpleProcessingNode
{
fn packet_size(&self) -> usize {
<SimpleWireWrapper as WireWrappingPipeline<Ts, _, _, _>>::packet_size(&self.wrapper)
}
}
impl<Ts> FramingUnwrap<Ts, SimpleMessage> for SimpleProcessingNode {
type Frame = SimpleFrame;
fn frame_to_message(
&mut self,
frame: TimedData<Ts, SimpleFrame>,
) -> Option<(TimedPayload<Ts>, SimpleMessage)> {
self.unwrapper.frame_to_message(frame)
}
}
impl<Ts: Clone> TransportUnwrap<Ts, SimplePacket> for SimpleProcessingNode {
type Frame = SimpleFrame;
type Error = anyhow::Error;
fn packet_to_frame(
&mut self,
packet: SimplePacket,
timestamp: Ts,
) -> anyhow::Result<TimedData<Ts, SimpleFrame>> {
self.unwrapper.packet_to_frame(packet, timestamp)
}
}
impl<Ts: Clone> WireUnwrappingPipeline<Ts, SimplePacket, SimpleMessage> for SimpleProcessingNode {}
+178
View File
@@ -0,0 +1,178 @@
// 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;
use nym_crypto::asymmetric::x25519;
use nym_lp_data::{
AddressedTimedData, AddressedTimedPayload, TimedPayload,
common::helpers::{NoOpWireUnwrapper, NoOpWireWrapper},
nymnodes::traits::NymNodeProcessingPipeline,
};
use nym_sphinx::SphinxPacket;
use crate::{
node::{BaseNode, NodeId, ProcessingNode},
packet::{
WirePacketFormat,
sphinx::{AddDelay, SimMixPacket, SphinxMessage, 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<Ts> = BaseNode<Ts, SimMixPacket, SphinxProcessingNode>;
impl<Ts> SphinxNode<Ts> {
/// 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);
BaseNode::with_pipeline(
topology_node.node_id,
topology_node.reliability,
topology_node.socket_address,
directory,
pipeline,
)
}
}
impl<Ts> ProcessingNode<Ts, SimMixPacket> for SphinxProcessingNode
where
Ts: AddDelay + Clone,
{
fn process(
&mut self,
input: SimMixPacket,
timestamp: Ts,
) -> anyhow::Result<Vec<AddressedTimedData<Ts, SimMixPacket, NodeId>>> {
Ok(NymNodeProcessingPipeline::<
Ts,
SimMixPacket,
(),
SphinxMessage,
NodeId,
>::process(self, input, timestamp)?)
}
}
// ─────────────────────────────────────────────────────────────────────────────
/// 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,
}
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) -> Self {
Self {
id: node_id,
sphinx_secret,
}
}
}
impl<Ts> NymNodeProcessingPipeline<Ts, SimMixPacket, (), SphinxMessage, NodeId>
for SphinxProcessingNode
where
Ts: AddDelay + Clone,
{
/// 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,
_: SphinxMessage,
payload: TimedPayload<Ts>,
timestamp: Ts,
) -> Vec<AddressedTimedPayload<Ts, NodeId>> {
// 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 timed_sphinx = AddressedTimedData::new_addressed(
timestamp.add_delay(delay),
next_hop_packet.to_bytes(),
next_hop_address.as_bytes()[0],
);
vec![timed_sphinx]
}
nym_sphinx::ProcessedPacketData::FinalHop {
destination,
identifier: _,
payload,
} => {
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);
let mut packets_to_forward = vec![AddressedTimedData::new_addressed(
timestamp.clone(),
message,
destination.as_bytes()[0],
)];
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}"))
{
packets_to_forward.push(AddressedTimedData::new_addressed(
timestamp,
surb_ack.to_bytes(),
next_hop,
));
}
packets_to_forward
} else {
Vec::new()
}
}
},
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 {}
+39
View File
@@ -0,0 +1,39 @@
// 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::mixnodes::traits::NymNodeProcessingPipeline`].
//!
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()
}
}
+282
View File
@@ -0,0 +1,282 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use std::fmt::Debug;
use nym_common::debug::format_debug_bytes;
use nym_lp_data::{
AddressedTimedData, PipelinePayload, TimedData, TimedPayload,
common::traits::{
Framing, FramingUnwrap, Transport, TransportUnwrap, WireUnwrappingPipeline,
WireWrappingPipeline,
},
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{client::simple::SimpleInputOptions, node::NodeId, 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 })
}
}
/// Marker type identifying a fully-unwrapped simple payload.
///
/// Passed through the pipeline's [`FramingUnwrap`] stage; because the simple
/// pipeline has only one message kind this is a zero-sized unit struct.
///
/// [`FramingUnwrap`]: nym_lp_data::common::traits::FramingUnwrap
pub struct SimpleMessage;
// ─────────────────────────────────────────────────────────────────────────────
// 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<Ts: Clone> Framing<Ts, SimpleInputOptions, NodeId> for SimpleWireWrapper {
type Frame = SimpleFrame;
const OVERHEAD_SIZE: usize = SimpleFrame::HEADER.len();
fn to_frame(
&mut self,
payload: PipelinePayload<Ts, SimpleInputOptions, NodeId>,
frame_size: usize,
) -> Vec<AddressedTimedData<Ts, SimpleFrame, NodeId>> {
payload
.data
.data
.chunks(frame_size)
.map(|chunk| {
AddressedTimedData::new_addressed(
payload.data.timestamp.clone(),
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<Ts: Clone> Transport<Ts, SimplePacket, NodeId> for SimpleWireWrapper {
type Frame = SimpleFrame;
const OVERHEAD_SIZE: usize = 16;
fn to_transport_packet(
&mut self,
frame: AddressedTimedData<Ts, SimpleFrame, NodeId>,
) -> AddressedTimedData<Ts, SimplePacket, NodeId> {
// 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<Ts: Clone> WireWrappingPipeline<Ts, SimplePacket, SimpleInputOptions, NodeId>
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<Ts> FramingUnwrap<Ts, SimpleMessage> for SimpleWireUnwrapper {
type Frame = SimpleFrame;
fn frame_to_message(
&mut self,
frame: TimedData<Ts, SimpleFrame>,
) -> Option<(TimedPayload<Ts>, SimpleMessage)> {
Some((
TimedPayload {
data: frame.data.data,
timestamp: frame.timestamp,
},
SimpleMessage,
))
}
}
impl<Ts: Clone> TransportUnwrap<Ts, SimplePacket> for SimpleWireUnwrapper {
type Frame = SimpleFrame;
type Error = anyhow::Error;
fn packet_to_frame(
&mut self,
packet: SimplePacket,
timestamp: Ts,
) -> anyhow::Result<TimedData<Ts, SimpleFrame>> {
// packet.data holds the framed bytes (HEADER + payload)
Ok(TimedData::new(
timestamp,
SimpleFrame::try_from_bytes(&packet.data)?,
))
}
}
impl<Ts: Clone> WireUnwrappingPipeline<Ts, SimplePacket, SimpleMessage> for SimpleWireUnwrapper {}
+309
View File
@@ -0,0 +1,309 @@
// 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, Destination, DestinationAddressBytes, SphinxPacketBuilder};
use rand::Rng;
use rand_distr::{Distribution, Exp};
use std::{fmt::Debug, ops::Add, time::Duration};
use crate::{
client::ClientId, node::NodeId, packet::WirePacketFormat, topology::directory::Directory,
};
/// 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<Ts: GenerateDelay, R>(
rng: &mut R,
recipient: ClientId,
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(Into::into).collect::<Vec<_>>();
let destination = Destination::new(
DestinationAddressBytes::from_bytes([recipient; 32]),
[recipient; 16],
);
let delays = (0..sphinx_route.len())
.map(|_| Delay::new_from_millis(Ts::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
}
}
/// Marker type identifying a fully-unwrapped Sphinx payload.
///
/// Passed through the pipeline's [`FramingUnwrap`] stage so that
/// [`ClientUnwrappingPipeline::process_unwrapped`] can dispatch on the message
/// kind. In the simulation there is only one kind, so this is a zero-sized
/// unit struct.
///
/// [`FramingUnwrap`]: nym_lp_data::common::traits::FramingUnwrap
/// [`ClientUnwrappingPipeline::process_unwrapped`]: nym_lp_data::clients::traits::ClientUnwrappingPipeline::process_unwrapped
#[derive(Default)]
pub struct SphinxMessage;
/// Abstracts adding a Sphinx [`Delay`] to a timestamp type.
///
/// Implemented for `u32` (1 tick = 1 ms) and [`Instant`](std::time::Instant)
/// so the same mix logic can run against either timestamp flavour.
pub trait AddDelay: Sized {
/// Return a new timestamp shifted forward by `delay`.
fn add_delay(self, delay: nym_sphinx::Delay) -> Self;
}
impl AddDelay for u32 {
/// One tick = 1 ms.
fn add_delay(self, delay: nym_sphinx::Delay) -> Self {
self + (delay.to_nanos() / 1_000_000) as u32
}
}
impl AddDelay for std::time::Instant {
fn add_delay(self, delay: nym_sphinx::Delay) -> Self {
self + delay.to_duration()
}
}
/// Timestamp types that can generate Sphinx delays and be advanced by them.
///
/// Implemented for `u32` (discrete ticks, 1 tick = 1 ms) and
/// [`Instant`](std::time::Instant) (wall-clock time).
pub trait GenerateDelay: Sized + Add<Self::Delay, Output = Self> {
/// The delay unit that can be added to `Self` (e.g. `u32` ticks or
/// [`Duration`]).
type Delay;
/// Draw a per-hop mix delay in milliseconds for inclusion in a Sphinx packet header.
fn generate_mix_delay(rng: &mut impl Rng) -> u64;
/// Draw an inter-packet sending delay for the main Poisson loop.
fn generate_sending_delay(rng: &mut impl Rng) -> Self::Delay;
/// Draw an inter-packet sending delay for the secondary cover traffic loop.
fn generate_cover_traffic_delay(rng: &mut impl Rng) -> Self::Delay;
}
impl GenerateDelay for u32 {
type Delay = u32;
/// Uniform in `[0, 10]` ms.
fn generate_mix_delay(rng: &mut impl Rng) -> u64 {
rng.gen_range(0..=10)
}
/// Exponential with mean 10 ticks (ms).
fn generate_sending_delay(rng: &mut impl Rng) -> u32 {
// SAFETY : hardcoded > 0 value
#[allow(clippy::unwrap_used)]
let exp: Exp<f64> = Exp::new(1.0 / 10.0).unwrap();
exp.sample(rng).round() as u32
}
/// Exponential with mean 100 ticks (ms).
fn generate_cover_traffic_delay(rng: &mut impl Rng) -> u32 {
// SAFETY : hardcoded > 0 value
#[allow(clippy::unwrap_used)]
let exp: Exp<f64> = Exp::new(1.0 / 100.0).unwrap();
exp.sample(rng).round() as u32
}
}
impl GenerateDelay for std::time::Instant {
type Delay = Duration;
/// Exponential with mean 50 ms.
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.
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.
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)
}
}
+146
View File
@@ -0,0 +1,146 @@
// 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::x25519;
use nym_sphinx::{Node as SphinxNode, NodeAddressBytes};
use rand::{prelude::SliceRandom, seq::IteratorRandom};
use crate::{
client::ClientId,
node::NodeId,
topology::{Topology, 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, SocketAddr>,
}
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<&SocketAddr> {
self.clients.get(&id)
}
/// Return the [`NodeId`] of every node currently in the directory.
pub fn node_ids(&self) -> Vec<NodeId> {
self.nodes.keys().copied().collect()
}
/// 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) -> NodeId {
// SAFETY: The directory always contains at least one node in a valid simulation.
#[allow(clippy::unwrap_used)]
*self.node_ids().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()
}
}
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.mixnet_address);
}
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 From<&DirectoryNode> for SphinxNode {
/// Convert a [`DirectoryNode`] into a [`SphinxNode`] suitable for use with
/// [`SphinxPacketBuilder`].
///
/// The Sphinx [`NodeAddressBytes`] are constructed by repeating the single-byte
/// [`NodeId`] across all 32 bytes — a simulation-only convention that lets the
/// node recover its own ID from the address after decryption.
///
/// [`SphinxPacketBuilder`]: nym_sphinx::SphinxPacketBuilder
fn from(value: &DirectoryNode) -> Self {
let address = NodeAddressBytes::from_bytes([value.id; 32]);
SphinxNode::new(address, *value.sphinx_public_key)
}
}
impl From<DirectoryNode> for SphinxNode {
fn from(value: DirectoryNode) -> Self {
(&value).into()
}
}
+85
View File
@@ -0,0 +1,85 @@
// 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 (0100); 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, Clone, Debug)]
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,
}
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 {
Self {
client_id,
mixnet_address,
app_address,
}
}
}
/// 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>,
}
+1
View File
@@ -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 }
@@ -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;
@@ -6,13 +6,15 @@ use crate::node::lp::control::{LP_DURATION_BUCKETS, LpConnectionStats};
use crate::node::lp::error::LpHandlerError;
use crate::node::lp::state::SharedLpClientControlState;
use dashmap::mapref::one::RefMut;
use nym_lp::packet::frame::LpFrameKind;
use nym_lp::packet::{EncryptedLpPacket, ForwardPacketData, LpFrame};
use nym_lp::peer_config::LpReceiverIndex;
use nym_lp::LpTransportSession;
use nym_lp::session::{LpAction, LpInput};
use nym_lp::transport::LpHandshakeChannel;
use nym_lp::transport::traits::LpTransportChannel;
use nym_lp::{LpTransportSession, packet::frame::ExpectedResponseSize};
use nym_lp_data::packet::frame::LpFrameKind;
use nym_lp_data::packet::header::LpReceiverIndex;
use nym_lp_data::packet::{
EncryptedLpPacket, ForwardPacketData, LpFrame, frame::ExpectedResponseSize,
};
use nym_metrics::{add_histogram_obs, inc};
use nym_node_metrics::NymNodeMetrics;
use nym_registration_common::{LpRegistrationRequest, RegistrationStatus};
+1 -1
View File
@@ -17,7 +17,7 @@
use crate::node::lp::error::LpHandlerError;
use crate::node::lp::state::SharedLpDataState;
use nym_lp::packet::OuterHeader;
use nym_lp_data::packet::OuterHeader;
use nym_metrics::inc;
use std::net::SocketAddr;
use tracing::*;
+2 -3
View File
@@ -1,11 +1,10 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use nym_lp::packet::frame::LpFrameKind;
use nym_lp::peer_config::LpReceiverIndex;
use nym_lp::LpError;
use nym_lp::session::LpAction;
use nym_lp::transport::LpTransportError;
use nym_lp::{LpError, packet::MalformedLpPacketError};
use nym_lp_data::packet::{MalformedLpPacketError, frame::LpFrameKind, header::LpReceiverIndex};
use nym_topology::NodeId;
use std::net::{IpAddr, SocketAddr};
use thiserror::Error;
+1 -1
View File
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: GPL-3.0-only
use crate::node::lp::state::SharedLpClientControlState;
use nym_lp::peer_config::LpReceiverIndex;
use nym_lp_data::packet::header::LpReceiverIndex;
use nym_metrics::{add_histogram_obs, inc};
use nym_registration_common::dvpn::{
LpDvpnRegistrationFinalisation, LpDvpnRegistrationInitialRequest,
+1 -1
View File
@@ -10,7 +10,7 @@ use dashmap::mapref::one::RefMut;
use nym_gateway::node::wireguard::PeerRegistrator;
use nym_lp::LpTransportSession;
use nym_lp::peer::LpLocalPeer;
use nym_lp::peer_config::LpReceiverIndex;
use nym_lp_data::packet::header::LpReceiverIndex;
use nym_mixnet_client::forwarder::MixForwardingSender;
use nym_node_metrics::NymNodeMetrics;
use std::sync::Arc;
+1
View File
@@ -31,6 +31,7 @@ nym-credentials-interface = { workspace = true }
nym-crypto = { workspace = true, features = ["asymmetric", "libcrux_x25519"] }
nym-ip-packet-client = { workspace = true }
nym-lp = { path = "../common/nym-lp" }
nym-lp-data.workspace = true
nym-registration-common = { workspace = true }
nym-sdk = { workspace = true }
nym-validator-client = { workspace = true }
@@ -13,13 +13,13 @@ use crate::lp_client::session_helpers::{extract_forwarded_response, prepare_send
use nym_bandwidth_controller::{BandwidthTicketProvider, DEFAULT_TICKETS_TO_SPEND};
use nym_credentials_interface::TicketType;
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_lp::Ciphersuite;
use nym_lp::LpTransportSession;
use nym_lp::peer::{DHKeyPair, LpLocalPeer, LpRemotePeer};
use nym_lp::peer_config::LpReceiverIndex;
use nym_lp::psq::initiator::HandshakeMode;
use nym_lp::transport::traits::LpTransportChannel;
use nym_lp::transport::{LpHandshakeChannel, LpTransportError};
use nym_lp::{Ciphersuite, packet::EncryptedLpPacket, packet::version};
use nym_lp_data::packet::{EncryptedLpPacket, header::LpReceiverIndex, version};
use nym_registration_common::dvpn::LpDvpnRegistrationResponseMessageContent;
use nym_registration_common::{
LpRegistrationRequest, LpRegistrationResponse, WireguardConfiguration,
@@ -708,7 +708,7 @@ where
mod tests {
use super::*;
use nym_kkt::key_utils::generate_lp_keypair_x25519;
use nym_lp::packet::version;
use nym_lp_data::packet::version;
use nym_test_utils::helpers::deterministic_rng_09;
#[test]
@@ -4,10 +4,10 @@
//! Error types for LP (Lewes Protocol) client operations.
use nym_lp::LpError;
use nym_lp::packet::MalformedLpPacketError;
use nym_lp::packet::frame::LpFrameKind;
use nym_lp::session::LpAction;
use nym_lp::transport::LpTransportError;
use nym_lp_data::packet::MalformedLpPacketError;
use nym_lp_data::packet::frame::LpFrameKind;
use thiserror::Error;
/// Errors that can occur during LP client operations.
@@ -4,10 +4,10 @@
#![allow(dead_code)]
use crate::LpClientError;
use nym_lp::packet::frame::LpFrameKind;
use nym_lp::packet::{ForwardPacketData, LpFrame};
use nym_lp::peer::LpRemotePeer;
use nym_lp::session::{LpAction, LpInput};
use nym_lp_data::packet::frame::LpFrameKind;
use nym_lp_data::packet::{ForwardPacketData, LpFrame};
use nym_registration_common::{
LpRegistrationRequest, LpRegistrationResponse, NymNodeLPInformation,
};
@@ -5,10 +5,10 @@ use crate::lp_client::helpers::{convert_forward_data, try_convert_forward_respon
use crate::{LpClientError, LpRegistrationClient};
use bytes::{BufMut, BytesMut};
use nym_lp::KEM;
use nym_lp::packet::{EncryptedLpPacket, ForwardPacketData, frame::ExpectedResponseSize};
use nym_lp::session::{LpAction, LpInput};
use nym_lp::transport::traits::{HandshakeMessage, LpTransportChannel};
use nym_lp::transport::{LpHandshakeChannel, LpTransportError};
use nym_lp_data::packet::{EncryptedLpPacket, ForwardPacketData, frame::ExpectedResponseSize};
use std::io;
use std::net::SocketAddr;
@@ -27,13 +27,13 @@ use crate::lp_client::session_helpers::{extract_forwarded_response, prepare_send
use nym_bandwidth_controller::{BandwidthTicketProvider, DEFAULT_TICKETS_TO_SPEND};
use nym_credentials_interface::TicketType;
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_lp::packet::version;
use nym_lp::packet::{EncryptedLpPacket, LpFrame};
use nym_lp::peer::{DHKeyPair, LpLocalPeer, LpRemotePeer};
use nym_lp::psq::initiator::HandshakeMode;
use nym_lp::transport::LpHandshakeChannel;
use nym_lp::transport::traits::LpTransportChannel;
use nym_lp::{Ciphersuite, KEM, LpTransportSession};
use nym_lp_data::packet::version;
use nym_lp_data::packet::{EncryptedLpPacket, LpFrame};
use nym_registration_common::dvpn::LpDvpnRegistrationResponseMessageContent;
use nym_registration_common::{
LpRegistrationRequest, LpRegistrationResponse, WireguardConfiguration,
@@ -2,9 +2,9 @@
// SPDX-License-Identifier: Apache-2.0
use crate::LpClientError;
use nym_lp::packet::LpFrame;
use nym_lp::LpTransportSession;
use nym_lp::session::{LpAction, LpInput};
use nym_lp::{LpTransportSession, packet::EncryptedLpPacket};
use nym_lp_data::packet::{EncryptedLpPacket, LpFrame};
/// Attempt to prepare the provided data for sending by wrapping it in appropriate `LpAction`,
/// and attempting to extract `EncryptedLpPacket` from the provided state machine.
+1 -1
View File
@@ -39,7 +39,7 @@ nym-credentials-interface = { workspace = true }
nym-credential-storage = { workspace = true }
nym-credential-utils = { workspace = true }
nym-network-defaults = { workspace = true }
nym-lp = { workspace = true }
nym-lp-data = { workspace = true }
nym-sphinx = { workspace = true }
nym-statistics-common = { workspace = true }
nym-task = { workspace = true }
@@ -19,7 +19,7 @@ use nym_sphinx::params::PacketType;
use nym_task::connections::TransmissionLane;
use tokio_util::sync::PollSender;
use nym_lp::packet::frame::SphinxStreamMsgType;
use nym_lp_data::packet::frame::SphinxStreamMsgType;
use super::protocol::{encode_stream_message, StreamId};
use super::StreamMap;
+1 -1
View File
@@ -40,7 +40,7 @@ use nym_sphinx::addressing::clients::Recipient;
use nym_sphinx::anonymous_replies::requests::AnonymousSenderTag;
use nym_task::connections::TransmissionLane;
use nym_lp::packet::frame::SphinxStreamMsgType;
use nym_lp_data::packet::frame::SphinxStreamMsgType;
use protocol::{decode_stream_message, encode_stream_message};
use crate::mixnet::native_client::MixnetClient;
@@ -17,7 +17,7 @@
use std::fmt;
use bytes::BytesMut;
use nym_lp::packet::frame::{
use nym_lp_data::packet::frame::{
LpFrame, LpFrameHeader, LpFrameKind, SphinxStreamFrameAttributes, SphinxStreamMsgType,
};
@@ -26,7 +26,7 @@ nym-crypto = { workspace = true }
nym-exit-policy = { workspace = true }
nym-id = { workspace = true }
nym-ip-packet-requests = { workspace = true }
nym-lp = { workspace = true }
nym-lp-data = { workspace = true }
nym-network-defaults = { workspace = true }
nym-network-requester = { path = "../network-requester" }
nym-sdk = { workspace = true }
@@ -12,7 +12,7 @@ use nym_ip_packet_requests::{
v8::response::IpPacketResponse as IpPacketResponseV8,
v9,
};
use nym_lp::packet::frame::{
use nym_lp_data::packet::frame::{
LpFrame, LpFrameHeader, SphinxStreamFrameAttributes, SphinxStreamMsgType,
};
use nym_sdk::mixnet::{
@@ -24,7 +24,7 @@ use crate::{
use futures::StreamExt;
use nym_ip_packet_requests::codec::MultiIpPacketCodec;
use nym_ip_packet_requests::{MAX_NON_STREAM_VERSION, SPHINX_STREAM_VERSION_THRESHOLD};
use nym_lp::packet::frame::{LpFrameHeader, LpFrameKind, SphinxStreamFrameAttributes};
use nym_lp_data::packet::frame::{LpFrameHeader, LpFrameKind, SphinxStreamFrameAttributes};
use nym_sdk::mixnet::MixnetMessageSender;
use nym_sphinx::receiver::ReconstructedMessage;
use nym_task::ShutdownToken;
@@ -704,7 +704,7 @@ mod tests {
#[test]
fn test_lp_stream_frame_detected() {
use bytes::BytesMut;
use nym_lp::packet::frame::{
use nym_lp_data::packet::frame::{
LpFrameHeader, LpFrameKind, SphinxStreamFrameAttributes, SphinxStreamMsgType,
};
@@ -713,7 +713,7 @@ mod tests {
msg_type: SphinxStreamMsgType::Data,
sequence_num: 42,
};
let frame = nym_lp::packet::frame::LpFrame::new_stream(attrs, vec![8, 1, 0]); // fake IPR payload
let frame = nym_lp_data::packet::frame::LpFrame::new_stream(attrs, vec![8, 1, 0]); // fake IPR payload
let mut buf = BytesMut::new();
frame.encode(&mut buf);
+1
View File
@@ -33,6 +33,7 @@ nym-kkt-ciphersuite = { workspace = true }
nym-http-api-client = { path = "../../common/http-api-client" }
nym-kcp = { path = "../../common/nym-kcp" }
nym-lp = { path = "../../common/nym-lp" }
nym-lp-data.workspace = true
nym-sphinx = { path = "../../common/nymsphinx" }
nym-sphinx-framing = { path = "../../common/nymsphinx/framing", features = ["no-mix-acks"] }
nym-sphinx-anonymous-replies = { path = "../../common/nymsphinx/anonymous-replies" }
+1 -1
View File
@@ -33,8 +33,8 @@ use tracing::{debug, info, trace};
use crate::topology::{GatewayInfo, SpeedtestTopology};
use nym_ip_packet_requests::v8::request::IpPacketRequest;
use nym_lp::packet::version;
use nym_lp::peer::{DHKeyPair, LpRemotePeer};
use nym_lp_data::packet::version;
use nym_sphinx::forwarding::packet::MixPacket;
/// Conv ID for KCP - hash of source and destination addresses