Compare commits

..

20 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
import this bde2b07d0d NTM: NIP-11 exit policy update (#6807) 2026-05-20 08:30:49 +00:00
import this 26538f5a40 Patch: Linux kernel vulnerability patch (#6773)
* add guide component

* add mitigate kernel playbook

* add to troubleshooting

* remove redundant

* remove redundant

* FIX ISSUES

* fix

* fix url to raw

* update docs and add new playbook

* update and simplify docs and ansible

* create ntm explanation component and import it

* rm mistaken empty file

* rm crap

* rm crap

* rm all crap

* try to fix nextra screaming seagul

* try to fix nextra screaming seagul

* try to fix nextra screaming seagul

* UX improvement by logic refactoring

* UX improvement by logic refactoring

* UX improvement by logic refactoring

* UX improvement by logic refactoring

* fix header urls

* fix command syntax

* fix indentation

* update auto-stats

* resolve review comments

* resolve review comments in docs

* fix remove kernel book

* soften warning

* address comments

* address comments

* update stats
2026-05-20 09:17:36 +02:00
111 changed files with 6357 additions and 554 deletions
@@ -16,7 +16,7 @@ jobs:
uses: actions/checkout@v6
- name: Get version from cargo.toml
uses: mikefarah/yq@v4.53.2
uses: mikefarah/yq@v4.52.4
id: get_version
with:
cmd: yq -oy '.package.version' ${{ env.WORKING_DIRECTORY }}/Cargo.toml
@@ -16,7 +16,7 @@ jobs:
uses: actions/checkout@v6
- name: Get version from cargo.toml
uses: mikefarah/yq@v4.53.2
uses: mikefarah/yq@v4.52.4
id: get_version
with:
cmd: yq -oy '.package.version' ${{ env.WORKING_DIRECTORY }}/Cargo.toml
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
git config --global user.name "Lawrence Stalder"
- name: Get version from cargo.toml
uses: mikefarah/yq@v4.53.2
uses: mikefarah/yq@v4.52.4
id: get_version
with:
cmd: yq -oy '.package.version' ${{ env.WORKING_DIRECTORY }}/nym-credential-proxy/Cargo.toml
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
git config --global user.name "Lawrence Stalder"
- name: Get version from cargo.toml
uses: mikefarah/yq@v4.53.2
uses: mikefarah/yq@v4.52.4
id: get_version
with:
cmd: yq -oy '.package.version' ${{ env.WORKING_DIRECTORY }}/Cargo.toml
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
git config --global user.name "Lawrence Stalder"
- name: Get version from cargo.toml
uses: mikefarah/yq@v4.53.2
uses: mikefarah/yq@v4.52.4
id: get_version
with:
cmd: yq -oy '.package.version' ${{ env.WORKING_DIRECTORY }}/nym-network-monitor/Cargo.toml
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
git config --global user.name "Lawrence Stalder"
- name: Get version from cargo.toml
uses: mikefarah/yq@v4.53.2
uses: mikefarah/yq@v4.52.4
id: get_version
with:
cmd: yq -oy '.package.version' ${{ env.WORKING_DIRECTORY }}/nym-api/Cargo.toml
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
git config --global user.name "Lawrence Stalder"
- name: Get version from cargo.toml
uses: mikefarah/yq@v4.53.2
uses: mikefarah/yq@v4.52.4
id: get_version
with:
cmd: yq -oy '.package.version' ${{ env.WORKING_DIRECTORY }}/Cargo.toml
@@ -26,7 +26,7 @@ jobs:
git config --global user.name "Lawrence Stalder"
- name: Get version from cargo.toml
uses: mikefarah/yq@v4.53.2
uses: mikefarah/yq@v4.52.4
id: get_version
with:
cmd: yq -oy '.package.version' ${{ env.WORKING_DIRECTORY }}/Cargo.toml
@@ -26,7 +26,7 @@ jobs:
git config --global user.name "Lawrence Stalder"
- name: Get version from cargo.toml
uses: mikefarah/yq@v4.53.2
uses: mikefarah/yq@v4.52.4
id: get_version
with:
cmd: yq -oy '.package.version' ${{ env.WORKING_DIRECTORY }}/Cargo.toml
@@ -26,7 +26,7 @@ jobs:
git config --global user.name "Lawrence Stalder"
- name: Get version from cargo.toml
uses: mikefarah/yq@v4.53.2
uses: mikefarah/yq@v4.52.4
id: get_version
with:
cmd: yq -oy '.package.version' ${{ env.WORKING_DIRECTORY }}/Cargo.toml
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" }
@@ -0,0 +1,42 @@
# Mitigation playbook for CopyFail (CVE-2026-31431) and DirtyFrag (CVE-2026-43284 / CVE-2026-43500)
# This playbook applies interim module blacklists only
# Kernel patches are not yet available (May 2026)
# Once patched kernels ship, use remove_kernel_CVE_mitigations.yml to reverse everything
# This playbook is idempotent - safe to re-run if mitigations were already applied
- name: Mitigate Copy Fail + Dirty Frag
hosts: all
become: true
tasks:
- name: Blacklist algif_aead (Copy Fail)
copy:
dest: /etc/modprobe.d/disable-algif_aead.conf
content: "install algif_aead /bin/false\n"
owner: root
group: root
mode: "0644"
- name: Blacklist esp4, esp6, rxrpc (Dirty Frag)
copy:
dest: /etc/modprobe.d/dirtyfrag.conf
content: |
install esp4 /bin/false
install esp6 /bin/false
install rxrpc /bin/false
owner: root
group: root
mode: "0644"
- name: Unload all affected modules
modprobe:
name: "{{ item }}"
state: absent
loop:
- algif_aead
- esp4
- esp6
- rxrpc
ignore_errors: true
- name: Drop page cache to clear any contamination
shell: echo 3 > /proc/sys/vm/drop_caches
@@ -0,0 +1,111 @@
############################################################################################
############################################################################################
############################################################################################
#### THIS PLAYBOOK IS NOT MEANT TO BE RUN YET, IT IS NOT REFERRED IN ANY DOCUMENTATION! ####
############################################################################################
############################################################################################
############################################################################################
#
# Reversal playbook for mitigate_kernel_CVE.yml (CopyFail CVE-2026-31431 / DirtyFrag CVE-2026-43284 / CVE-2026-43500).
#
# Run this AFTER your distro has shipped the patched kernel.
# This playbook:
# 1. Updates the kernel via apt
# 2. Reboots and waits for reconnect
# 3. Verifies the running kernel is newer than the pre-patch version
# 4. Removes the interim module blacklists
# 5. Re-enables the affected modules live (no second reboot needed)
#
# Debian family only (Debian, Ubuntu). Tested on Debian 11, Debian 12, Ubuntu 20.04, 22.04, 24.04.
#
# For exit-gateway nodes with --wireguard-enabled true:
# After this playbook completes, run the networking restore step on each node via:
# ansible-playbook deploy.yml -t ntm
# See the CVE patch documentation for details.
- name: Remove CVE mitigations and apply patched kernel
hosts: all
become: true
tasks:
- name: Verify OS is Debian family
assert:
that:
- ansible_os_family == "Debian"
fail_msg: "This playbook supports Debian-family distros only (Debian, Ubuntu). For other distros, apply the kernel update and mitigation removal manually."
- name: Update apt cache
apt:
update_cache: true
cache_valid_time: 0
- name: Upgrade kernel packages
apt:
upgrade: full
only_upgrade: false
register: apt_upgrade_result
- name: Record pre-reboot kernel version
command: uname -r
register: kernel_before
changed_when: false
- name: Reboot to load patched kernel
reboot:
msg: "Rebooting to apply patched kernel (CVE-2026-31431 / CVE-2026-43284 / CVE-2026-43500)"
reboot_timeout: 300
pre_reboot_delay: 5
post_reboot_delay: 15
- name: Record post-reboot kernel version
command: uname -r
register: kernel_after
changed_when: false
- name: Show kernel versions before and after reboot
debug:
msg:
- "Kernel before reboot: {{ kernel_before.stdout }}"
- "Kernel after reboot: {{ kernel_after.stdout }}"
- name: Warn if kernel did not change after reboot
debug:
msg: >
WARNING: kernel version did not change after reboot ({{ kernel_after.stdout }}).
The patched kernel may not have been selected by GRUB, or no kernel update was available.
Do NOT remove the interim mitigations until you have confirmed the running kernel is patched.
Check: apt-cache policy linux-image-amd64 # Debian
Check: apt-cache policy linux-image-generic # Ubuntu
when: kernel_before.stdout == kernel_after.stdout
- name: Remove algif_aead blacklist
file:
path: /etc/modprobe.d/disable-algif_aead.conf
state: absent
- name: Remove DirtyFrag blacklist (esp4, esp6, rxrpc)
file:
path: /etc/modprobe.d/dirtyfrag.conf
state: absent
- name: Re-enable affected modules live
modprobe:
name: "{{ item }}"
state: present
loop:
- esp4
- esp6
- rxrpc
- algif_aead
ignore_errors: true
- name: Confirm nym-node service is still running
systemd:
name: nym-node
state: started
register: nym_node_status
failed_when: false
- name: Show nym-node status
debug:
msg: "nym-node service state: {{ nym_node_status.state | default('unknown - service may not exist on this node') }}"
@@ -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;
@@ -0,0 +1,18 @@
import { AccordionTemplate } from 'components/accordion-template.tsx';
<AccordionTemplate name="What does NTM do?">
[Nym network tunnel manager](https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/nym-node-setup/network-tunnel-manager.sh) (NTM) `complete_networking_configuration` runs four phases:
1. **Tunnel setup** - deduplicates existing iptables rules, enables IPv4/IPv6 forwarding persistently via `/etc/sysctl.d/99-nym-forwarding.conf`, applies NAT MASQUERADE and FORWARD rules for both `nymtun0` and `nymwg`
2. **Host firewall** - opens the required INPUT ports for all Nym services: TCP `22, 80, 443, 1789, 1790, 8080, 9000, 9001, 41264` and UDP `4443, 51822, 51264`
3. **Exit policy** - creates the `NYM-EXIT` iptables chain, applies the full port allowlist, rate-limits SMTPS (TCP/465), applies the Spamhaus blocklist, adds a default REJECT at the end of the chain
4. **Verification** - runs automated tests against all firewall rules and reports any issues
All rules are saved to `/etc/iptables/rules.v4` and `/etc/iptables/rules.v6` via `iptables-persistent`, so they survive future reboots.
The script is idempotent - safe to re-run multiple times.
</AccordionTemplate>
@@ -0,0 +1,292 @@
import { Callout } from 'nextra/components';
import { Steps } from 'nextra/components';
import { Tabs } from 'nextra/components';
import { MyTab } from 'components/generic-tabs.tsx';
import { AccordionTemplate } from 'components/accordion-template.tsx';
import NTMExplanation from 'components/operators/snippets/ntm-accordion-explanation.mdx';
## Security Patch: `CopyFail` & `DirtyFrag`
In spring 2026 two critical Linux kernel vulnerabilities had been disclosed. Both allow any local user or process to gain root access on the machine - no special permissions needed.
<Callout type="warning" emoji="⚠️">
**What is exposed?** An attacker (or any compromised process) can take full control of the machine: read or exfiltrate everything, modify files, install backdoors, or escape containers onto the host.
</Callout>
**Security patch required! Follow these steps to patch the vulnerability as soon as possible!**
**CVEs covered**
| CVE | Name | Affected module |
|---|---|---|
| `CVE-2026-31431` | `CopyFail` | `algif_aead` |
| `CVE-2026-43284` | `DirtyFrag` | `esp4`, `esp6` |
| `CVE-2026-43500` | `DirtyFrag` | `rxrpc` |
<div>
<Tabs items={[
<strong>Directly on the hosting server</strong>,
<strong>Using Ansible</strong>,
]} defaultIndex="0">
<MyTab>
<Steps>
<Callout type="warning" emoji="⚠️">
**This guide is written from root perspective, please add `sudo` to your commands if you are non-root user.**
</Callout>
###### 1. Check if mitigations are already applied
Many hosting providers already applied these mitigations automatically via `unattended-upgrades`. Run these checks first:
```sh
cat /etc/modprobe.d/disable-algif_aead.conf 2>/dev/null && echo "Already mitigated" || echo "Not yet mitigated"
cat /etc/modprobe.d/dirtyfrag.conf 2>/dev/null && echo "Already mitigated" || echo "Not yet mitigated"
```
Compare your output to this table:
| Check | Safe output | Action if not safe |
|---|---|---|
| `disable-algif_aead.conf` | `Already mitigated` | Go to [Step 2](#2-apply-the-interim-mitigation---disable-algif_aead) |
| `dirtyfrag.conf` | `Already mitigated` | Go to [Step 3](#3-apply-the-interim-mitigation---disable-esp4-esp6-and-rxrpc) |
If both checks are `Already mitigated` - **you are done, no further action needed.**
###### 2. Apply the interim mitigation - disable `algif_aead`
The fastest fix on Debian/Ubuntu is to update the `kmod` package - this is how [providers applied it](https://ubuntu.com/blog/copy-fail-vulnerability-fixes-available) automatically and avoids a reboot:
```sh
apt update && apt install --only-upgrade kmod
rmmod algif_aead 2>/dev/null || true
```
- Alternatively, apply manually:
```sh
echo "install algif_aead /bin/false" | tee /etc/modprobe.d/disable-algif_aead.conf
rmmod algif_aead 2>/dev/null || true
```
- Drop page cache to clear any contamination:
```sh
echo 3 > /proc/sys/vm/drop_caches
```
<Callout type="info" emoji="️">
No reboot is required unless the module was already in active use by a running process. The `rmmod` command above unloads it live.
</Callout>
<Callout type="warning" emoji="⚠️">
**RHEL, AlmaLinux, Rocky, CentOS** only: this module is baked into the kernel on these distros - the command above does nothing. You must wait for a patched kernel from your distro and apply it when available.
</Callout>
Continue to [Step 4](#4-restore-gateway-egress-routing--wireguard-functionality).
###### 3. Apply the interim mitigation - disable `esp4`, `esp6`, and `rxrpc`
```sh
sh -c "printf 'install esp4 /bin/false\ninstall esp6 /bin/false\ninstall rxrpc /bin/false\n' > /etc/modprobe.d/dirtyfrag.conf"
rmmod esp4 esp6 rxrpc 2>/dev/null || true
```
- Drop page cache to clear any contamination:
```sh
echo 3 > /proc/sys/vm/drop_caches
```
Continue to [Step 4](#4-restore-gateway-egress-routing--wireguard-functionality).
###### 4. Restore Gateway egress routing & WireGuard functionality
<Callout type="warning" emoji="⚠️">
This step applies only to Gateway nodes. Skip this step for Mixnodes.
</Callout>
If your server was rebooted for any reason, all iptables rules including NAT, forwarding rules, and the `NYM-EXIT` chain are dropped. Unless `iptables-persistent` was already configured, these rules are gone.
- Run the [Nym network tunnel manager](https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/nym-node-setup/network-tunnel-manager.sh) (NTM) to restore and persist everything. NTM will also install `iptables-persistent` if not already present, ensuring rules survive future reboots:
```sh
curl -L "https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/nym-node-setup/network-tunnel-manager.sh" -o network-tunnel-manager.sh
```
- Run NTM (Standard SSH on port 22 (default)):
```sh
chmod +x ./network-tunnel-manager.sh &&
./network-tunnel-manager.sh complete_networking_configuration
```
- NTM on non-standart SSH port (replace `2222` with your actual port):
```sh
chmod +x ./network-tunnel-manager.sh &&
HOST_SSH_PORT=2222 ./network-tunnel-manager.sh complete_networking_configuration
```
<NTMExplanation />
###### 5. Verify egress routing & WireGuard functionality
There are a few ways to check that your WireGuard is working correctly:
- Connect to your node in NymVPN as an exit node in the *Fast* mode, if you rebooted it may take up to one hour for the node to be working properly again
- Verify WireGuard handshakes are resuming inside the node:
```sh
watch -n 5 'wg show all | grep -E "peer|latest handshake|transfer"'
```
Within 6090 seconds you should see `latest handshake: X seconds ago` timestamps appearing next to active peers and transfer bytes incrementing. Press `Ctrl+C` to exit the watch loop.
</Steps>
</MyTab>
<MyTab>
If you are using Ansible you can use this playbook [mitigate_kernel_CVE.yml](https://github.com/nymtech/nym/tree/develop/ansible/nym-node/playbooks/mitigate_kernel_CVE.yml).
<Steps>
###### 1. Create new playbook
- Navigate to your Nym node Ansible directory with all playbooks (`/playbooks`)
- You can double check that you are in the right place, running `ls` command and checking this output:
<AccordionTemplate name="Command output">
```
ansible.cfg bond.yml deploy.yml group_vars inventory system-maintenance.yml upgrade.yml
```
</AccordionTemplate>
- Download the playbook
```sh
curl -o mitigate_kernel_CVE.yml -L https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/ansible/nym-node/playbooks/mitigate_kernel_CVE.yml
```
- To control, run `ls` again
<AccordionTemplate name="Command output">
```
ansible.cfg bond.yml deploy.yml group_vars inventory mitigate_kernel_CVE.yml system-maintenance.yml upgrade.yml
```
</AccordionTemplate>
###### 2. Run playbook `mitigate_kernel_CVE.yml`
- For safety start only with one node:
```sh
ansible-playbook mitigate_kernel_CVE.yml -l node1
```
- Check if everything worked smooth on the node machine
- Run on all machines in your inventory
```sh
ansible-playbook mitigate_kernel_CVE.yml
```
###### 3. Ensure routing & WireGuard functionality
<Callout type="warning" emoji="⚠️">
This step applies only to Gateway nodes. Skip this step for Mixnodes.
</Callout>
In case your hosting servers rebooted all iptables rules including NAT, forwarding rules, and the `NYM-EXIT` chain, are dropped. Unless `iptables-persistent` was already configured before the reboot, these rules are gone.
- Run the [Nym network tunnel manager](https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/nym-node-setup/network-tunnel-manager.sh) (NTM) to restore and persist everything, using Ansible playbook `deploy.yml` pointing to the correct role:
- For safety start only with one node:
```sh
ansible-playbook deploy.yml -t ntm -l node1
```
- Check if everything worked smooth on the node machine
- Run on all machines in your inventory
```sh
ansible-playbook deploy.yml -t ntm
```
###### 4. Verify egress routing & WireGuard functionality
- Connect to your node in NymVPN as an exit node in the *Fast* mode, it may take up to one hour for the node to be working properly again
In case something doesn't work fine, please check the manual steps and [troubleshooting steps](#troubleshooting-wireguard-handshake-fails-after-patching) below
</Steps>
</MyTab>
</Tabs>
</div>
### Troubleshooting: WireGuard handshake fails after patching
If after completing all steps above WireGuard peers are still not handshaking and you can't connect to your node via NymVPN, work through this checklist:
<Steps>
###### 1. Confirm blacklists are in place
```sh
cat /etc/modprobe.d/disable-algif_aead.conf 2>/dev/null && echo "algif_aead: mitigated" || echo "algif_aead: NOT mitigated"
cat /etc/modprobe.d/dirtyfrag.conf 2>/dev/null && echo "dirtyfrag: mitigated" || echo "dirtyfrag: NOT mitigated"
```
- Both files must exist - these are what prevent the modules from loading on demand or after reboot
- If either is missing, return to [Step 2](#2-apply-the-interim-mitigation---disable-algif_aead) or [Step 3](#3-apply-the-interim-mitigation---disable-esp4-esp6-and-rxrpc) and apply the missing mitigation
###### 2. Check for stale xfrm state
```sh
ip xfrm policy list && ip xfrm state list
```
- If either returns entries, flush them — WireGuard does not use xfrm and stale entries can intercept handshake packets:
```sh
ip xfrm state flush && ip xfrm policy flush
```
###### 3. Confirm the `nymwg` interface is up
```sh
ip addr show type wireguard
```
- If absent, restart the `nym-node service`:
```sh
systemctl restart nym-node
```
###### 4. Check iptables forwarding rules are present
```sh
iptables -L FORWARD -n -v | grep -E 'nymwg|nymtun'
iptables -L NYM-EXIT -n | head -10
```
- If the `NYM-EXIT` chain is missing or `FORWARD` has no entries for `nymwg`, re-run [Step 4](#4-restore-gateway-egress-routing--wireguard-functionality).
###### 5. Confirm IP forwarding is enabled
```sh
sysctl net.ipv4.ip_forward net.ipv6.conf.all.forwarding
```
- Both should return `= 1`. If not: `sysctl -w net.ipv4.ip_forward=1 && sysctl -w net.ipv6.conf.all.forwarding=1`
###### 6. Watch for live handshake packets
- Install tcpdump if missing
```sh
apt install tcpdump -y # Debian/Ubuntu
dnf install tcpdump -y # RHEL family
# replace with your actual WG port from: ss -ulnp | grep nym-node
tcpdump -ni any udp port 51264
```
Trigger a connection attempt from a client. If you see packets arriving but WireGuard is not responding, check `journalctl -u nym-node -n 100` for errors from the binary itself.
</Steps>
@@ -1,6 +1,6 @@
{
"nodes": 743,
"locations": 77,
"mixnodes": 258,
"exit_gateways": 477
"nodes": 723,
"locations": 76,
"mixnodes": 249,
"exit_gateways": 466
}
@@ -1 +1 @@
Wednesday, May 13th 2026, 10:37:10 UTC
Wednesday, May 20th 2026, 06:27:43 UTC
@@ -11,7 +11,7 @@ options:
--no_routing_history Display node stats without routing history
--no_verloc_metrics Display node stats without verloc metrics
-m, --markdown Display results in markdown format
-o, --output [OUTPUT]
-o [OUTPUT], --output [OUTPUT]
Save results to file (in current dir or supply with
path without filename)
```
@@ -8,10 +8,8 @@ Commands:
help Print this message or the help of the given subcommand(s)
Options:
-c, --config-env-file <CONFIG_ENV_FILE> Path pointing to an env file that configures the Nym API [env:
NYMAPI_CONFIG_ENV_FILE_ARG=]
--no-banner A no-op flag included for consistency with other binaries (and compatibility with
nymvisor, oops) [env: NYMAPI_NO_BANNER_ARG=]
-c, --config-env-file <CONFIG_ENV_FILE> Path pointing to an env file that configures the Nym API [env: NYMAPI_CONFIG_ENV_FILE_ARG=]
--no-banner A no-op flag included for consistency with other binaries (and compatibility with nymvisor, oops) [env: NYMAPI_NO_BANNER_ARG=]
-h, --help Print help
-V, --version Print version
```
@@ -8,12 +8,15 @@ usage: nym-node-cli install [-h] [-V] [-d BRANCH] [-v]
[--public-ip PUBLIC_IP]
[--host-ssh-port HOST_SSH_PORT]
[--nym-node-binary NYM_NODE_BINARY]
[--uplink-dev UPLINK_DEV] [--env KEY=VALUE]
[--uplink-dev UPLINK_DEV]
[--uplink-dev-v4 UPLINK_DEV_V4]
[--uplink-dev-v6 UPLINK_DEV_V6] [--env KEY=VALUE]
options:
-h, --help show this help message and exit
-V, --version show program's version number and exit
-d, --dev BRANCH Define github branch (default: develop)
-d BRANCH, --dev BRANCH
Define github branch (default: develop)
-v, --verbose Show full error tracebacks
--mode {mixnode,entry-gateway,exit-gateway}
Node mode: 'mixnode', 'entry-gateway', or 'exit-
@@ -33,7 +36,13 @@ options:
--nym-node-binary NYM_NODE_BINARY
URL for nym-node binary (autodetected if omitted)
--uplink-dev UPLINK_DEV
Override uplink interface used for NAT/FORWARD (e.g.,
'eth0'; autodetected if omitted)
Backward-compatible override for both IPv4 and IPv6
uplinks, e.g. 'eth0'
--uplink-dev-v4 UPLINK_DEV_V4
Override IPv4 uplink interface used for NAT/FORWARD,
e.g. 'eth0'
--uplink-dev-v6 UPLINK_DEV_V6
Override IPv6 uplink interface used for NAT/FORWARD,
e.g. 'eth1'
--env KEY=VALUE (Optional) Extra ENV VARS, e.g. --env CUSTOM_KEY=value
```
@@ -8,13 +8,11 @@ Commands:
migrate Attempt to migrate an existing mixnode or gateway into a nym-node
run Start this nym-node
sign Use identity key of this node to sign provided message
unsafe-reset-sphinx-keys UNSAFE: reset existing sphinx keys and attempt to generate fresh one for the current network
state
unsafe-reset-sphinx-keys UNSAFE: reset existing sphinx keys and attempt to generate fresh one for the current network state
help Print this message or the help of the given subcommand(s)
Options:
-c, --config-env-file <CONFIG_ENV_FILE> Path pointing to an env file that configures the nym-node and overrides any
preconfigured values [env: NYMNODE_CONFIG_ENV_FILE_ARG=]
-c, --config-env-file <CONFIG_ENV_FILE> Path pointing to an env file that configures the nym-node and overrides any preconfigured values [env: NYMNODE_CONFIG_ENV_FILE_ARG=]
--no-banner Flag used for disabling the printed banner in tty [env: NYMNODE_NO_BANNER=]
-h, --help Print help
-V, --version Print version
@@ -4,138 +4,87 @@ Start this nym-node
Usage: nym-node run [OPTIONS]
Options:
--id <ID>
Id of the nym-node to use [env: NYMNODE_ID=] [default: default-nym-node]
--config-file <CONFIG_FILE>
Path to a configuration file of this node [env: NYMNODE_CONFIG=]
--accept-operator-terms-and-conditions
Explicitly specify whether you agree with the terms and conditions of a nym node operator as defined at
<https://nymtech.net/terms-and-conditions/operators/v1.0.0> [env: NYMNODE_ACCEPT_OPERATOR_TERMS=]
--deny-init
Forbid a new node from being initialised if configuration file for the provided specification doesn't already
exist [env: NYMNODE_DENY_INIT=]
--init-only
If this is a brand new nym-node, specify whether it should only be initialised without actually running the
subprocesses [env: NYMNODE_INIT_ONLY=]
--local
Flag specifying this node will be running in a local setting [env: NYMNODE_LOCAL=]
--mode [<MODE>...]
Specifies the current mode(s) of this nym-node [env: NYMNODE_MODE=] [possible values: mixnode, entry-gateway,
exit-gateway, exit-providers-only]
--modes <MODES>
Specifies the current mode(s) of this nym-node as a single flag [env: NYMNODE_MODES=] [possible values: mixnode,
entry-gateway, exit-gateway, exit-providers-only]
-w, --write-changes
If this node has been initialised before, specify whether to write any new changes to the config file [env:
NYMNODE_WRITE_CONFIG_CHANGES=]
--bonding-information-output <BONDING_INFORMATION_OUTPUT>
Specify output file for bonding information of this nym-node, i.e. its encoded keys. NOTE: the required bonding
information is still a subject to change and this argument should be treated only as a preview of future features
[env: NYMNODE_BONDING_INFORMATION_OUTPUT=]
-o, --output <OUTPUT>
Specify the output format of the bonding information (`text` or `json`) [env: NYMNODE_OUTPUT=] [default: text]
[possible values: text, json]
--public-ips <PUBLIC_IPS>
Comma separated list of public ip addresses that will be announced to the nym-api and subsequently to the clients.
In nearly all circumstances, it's going to be identical to the address you're going to use for bonding [env:
NYMNODE_PUBLIC_IPS=]
--hostname <HOSTNAME>
Optional hostname associated with this gateway that will be announced to the nym-api and subsequently to the
clients [env: NYMNODE_HOSTNAME=]
--location <LOCATION>
Optional **physical** location of this node's server. Either full country name (e.g. 'Poland'), two-letter alpha2
(e.g. 'PL'), three-letter alpha3 (e.g. 'POL') or three-digit numeric-3 (e.g. '616') can be provided [env:
NYMNODE_LOCATION=]
--http-bind-address <HTTP_BIND_ADDRESS>
Socket address this node will use for binding its http API. default: `[::]:8080` [env: NYMNODE_HTTP_BIND_ADDRESS=]
--landing-page-assets-path <LANDING_PAGE_ASSETS_PATH>
Path to assets directory of custom landing page of this node [env: NYMNODE_HTTP_LANDING_ASSETS=]
--http-access-token <HTTP_ACCESS_TOKEN>
An optional bearer token for accessing certain http endpoints. Currently only used for prometheus metrics [env:
NYMNODE_HTTP_ACCESS_TOKEN=]
--expose-system-info <EXPOSE_SYSTEM_INFO>
Specify whether basic system information should be exposed. default: true [env: NYMNODE_HTTP_EXPOSE_SYSTEM_INFO=]
[possible values: true, false]
--expose-system-hardware <EXPOSE_SYSTEM_HARDWARE>
Specify whether basic system hardware information should be exposed. default: true [env:
NYMNODE_HTTP_EXPOSE_SYSTEM_HARDWARE=] [possible values: true, false]
--expose-crypto-hardware <EXPOSE_CRYPTO_HARDWARE>
Specify whether detailed system crypto hardware information should be exposed. default: true [env:
NYMNODE_HTTP_EXPOSE_CRYPTO_HARDWARE=] [possible values: true, false]
--mixnet-bind-address <MIXNET_BIND_ADDRESS>
Address this node will bind to for listening for mixnet packets default: `[::]:1789` [env:
NYMNODE_MIXNET_BIND_ADDRESS=]
--mixnet-announce-port <MIXNET_ANNOUNCE_PORT>
If applicable, custom port announced in the self-described API that other clients and nodes will use. Useful when
the node is behind a proxy [env: NYMNODE_MIXNET_ANNOUNCE_PORT=]
--nym-api-urls <NYM_API_URLS>
Addresses to nym APIs from which the node gets the view of the network [env: NYMNODE_NYM_APIS=]
--nyxd-urls <NYXD_URLS>
Addresses to nyxd chain endpoint which the node will use for chain interactions [env: NYMNODE_NYXD=]
--enable-console-logging <ENABLE_CONSOLE_LOGGING>
Specify whether running statistics of this node should be logged to the console [env:
NYMNODE_ENABLE_CONSOLE_LOGGING=] [possible values: true, false]
--wireguard-enabled <WIREGUARD_ENABLED>
Specifies whether the wireguard service is enabled on this node [env: NYMNODE_WG_ENABLED=] [possible values: true,
false]
--wireguard-bind-address <WIREGUARD_BIND_ADDRESS>
Socket address this node will use for binding its wireguard interface. default: `[::]:51822` [env:
NYMNODE_WG_BIND_ADDRESS=]
--wireguard-tunnel-announced-port <WIREGUARD_TUNNEL_ANNOUNCED_PORT>
Tunnel port announced to external clients wishing to connect to the wireguard interface. Useful in the instances
where the node is behind a proxy [env: NYMNODE_WG_ANNOUNCED_PORT=]
--wireguard-private-network-prefix <WIREGUARD_PRIVATE_NETWORK_PREFIX>
The prefix denoting the maximum number of the clients that can be connected via Wireguard. The maximum value for
IPv4 is 32 and for IPv6 is 128 [env: NYMNODE_WG_PRIVATE_NETWORK_PREFIX=]
--wireguard-userspace <WIREGUARD_USERSPACE>
Use userspace implementation of WireGuard (wireguard-go) instead of kernel module. Useful in containerized
environments without kernel WireGuard support [env: NYMNODE_WG_USERSPACE=] [possible values: true, false]
--verloc-bind-address <VERLOC_BIND_ADDRESS>
Socket address this node will use for binding its verloc API. default: `[::]:1790` [env:
NYMNODE_VERLOC_BIND_ADDRESS=]
--verloc-announce-port <VERLOC_ANNOUNCE_PORT>
If applicable, custom port announced in the self-described API that other clients and nodes will use. Useful when
the node is behind a proxy [env: NYMNODE_VERLOC_ANNOUNCE_PORT=]
--entry-bind-address <ENTRY_BIND_ADDRESS>
Socket address this node will use for binding its client websocket API. default: `[::]:9000` [env:
NYMNODE_ENTRY_BIND_ADDRESS=]
--announce-ws-port <ANNOUNCE_WS_PORT>
Custom announced port for listening for websocket client traffic. If unspecified, the value from the
`bind_address` will be used instead [env: NYMNODE_ENTRY_ANNOUNCE_WS_PORT=]
--announce-wss-port <ANNOUNCE_WSS_PORT>
If applicable, announced port for listening for secure websocket client traffic [env:
NYMNODE_ENTRY_ANNOUNCE_WSS_PORT=]
--enforce-zk-nyms <ENFORCE_ZK_NYMS>
Indicates whether this gateway is accepting only coconut credentials for accessing the mixnet or if it also
accepts non-paying clients [env: NYMNODE_ENFORCE_ZK_NYMS=] [possible values: true, false]
--mnemonic <MNEMONIC>
Custom cosmos wallet mnemonic used for zk-nym redemption. If no value is provided, a fresh mnemonic is going to be
generated [env: NYMNODE_MNEMONIC=]
--upgrade-mode-attestation-url <UPGRADE_MODE_ATTESTATION_URL>
Endpoint to query to retrieve current upgrade mode attestation. This argument should never be set outside testnets
and local networks [env: NYMNODE_UPGRADE_MODE_ATTESTATION_URL=]
--upgrade-mode-attester-public-key <UPGRADE_MODE_ATTESTER_PUBLIC_KEY>
Expected public key of the entity signing the published attestation. This argument should never be set outside
testnets and local networks [env: NYMNODE_UPGRADE_MODE_ATTESTER_PUBKEY=]
--upstream-exit-policy-url <UPSTREAM_EXIT_POLICY_URL>
Specifies the url for an upstream source of the exit policy used by this node [env: NYMNODE_UPSTREAM_EXIT_POLICY=]
--open-proxy <OPEN_PROXY>
Specifies whether this exit node should run in 'open-proxy' mode and thus would attempt to resolve **ANY** request
it receives [env: NYMNODE_OPEN_PROXY=] [possible values: true, false]
--lp-control-bind-address <LP_CONTROL_BIND_ADDRESS>
Bind address for the TCP LP control traffic. default: `[::]:41264` [env: NYMNODE_LP_CONTROL_BIND_ADDRESS=]
--lp-control-announce-port <LP_CONTROL_ANNOUNCE_PORT>
Custom announced port for listening for the TCP LP control traffic. If unspecified, the value from the
`lp_control_bind_address` will be used instead [env: NYMNODE_LP_CONTROL_ANNOUNCE_PORT=]
--lp-data-bind-address <LP_DATA_BIND_ADDRESS>
Bind address for the UDP LP data traffic. default: `[::]:51264` [env: NYMNODE_LP_DATA_BIND_ADDRESS=]
--lp-data-announce-port <LP_DATA_ANNOUNCE_PORT>
Custom announced port for listening for the UDP LP data traffic. If unspecified, the value from the
`lp_data_bind_address` will be used instead [env: NYMNODE_LP_DATA_ANNOUNCE_PORT=]
--lp-use-mock-ecash <LP_USE_MOCK_ECASH>
Use mock ecash manager for LP testing. WARNING: Only use this for local testing! Never enable in production. When
enabled, the LP listener will accept any credential without blockchain verification [env:
NYMNODE_LP_USE_MOCK_ECASH=] [possible values: true, false]
-h, --help
Print help
--id <ID> Id of the nym-node to use [env: NYMNODE_ID=] [default: default-nym-node]
--config-file <CONFIG_FILE> Path to a configuration file of this node [env: NYMNODE_CONFIG=]
--accept-operator-terms-and-conditions Explicitly specify whether you agree with the terms and conditions of a nym node operator as defined at
<https://nymtech.net/terms-and-conditions/operators/v1.0.0> [env: NYMNODE_ACCEPT_OPERATOR_TERMS=]
--deny-init Forbid a new node from being initialised if configuration file for the provided specification doesn't already exist [env:
NYMNODE_DENY_INIT=]
--init-only If this is a brand new nym-node, specify whether it should only be initialised without actually running the subprocesses [env:
NYMNODE_INIT_ONLY=]
--local Flag specifying this node will be running in a local setting [env: NYMNODE_LOCAL=]
--mode [<MODE>...] Specifies the current mode(s) of this nym-node [env: NYMNODE_MODE=] [possible values: mixnode, entry-gateway, exit-gateway,
exit-providers-only]
--modes <MODES> Specifies the current mode(s) of this nym-node as a single flag [env: NYMNODE_MODES=] [possible values: mixnode, entry-gateway,
exit-gateway, exit-providers-only]
-w, --write-changes If this node has been initialised before, specify whether to write any new changes to the config file [env:
NYMNODE_WRITE_CONFIG_CHANGES=]
--bonding-information-output <BONDING_INFORMATION_OUTPUT> Specify output file for bonding information of this nym-node, i.e. its encoded keys. NOTE: the required bonding information is still a
subject to change and this argument should be treated only as a preview of future features [env: NYMNODE_BONDING_INFORMATION_OUTPUT=]
-o, --output <OUTPUT> Specify the output format of the bonding information (`text` or `json`) [env: NYMNODE_OUTPUT=] [default: text] [possible values: text,
json]
--public-ips <PUBLIC_IPS> Comma separated list of public ip addresses that will be announced to the nym-api and subsequently to the clients. In nearly all
circumstances, it's going to be identical to the address you're going to use for bonding [env: NYMNODE_PUBLIC_IPS=]
--hostname <HOSTNAME> Optional hostname associated with this gateway that will be announced to the nym-api and subsequently to the clients [env:
NYMNODE_HOSTNAME=]
--location <LOCATION> Optional **physical** location of this node's server. Either full country name (e.g. 'Poland'), two-letter alpha2 (e.g. 'PL'),
three-letter alpha3 (e.g. 'POL') or three-digit numeric-3 (e.g. '616') can be provided [env: NYMNODE_LOCATION=]
--http-bind-address <HTTP_BIND_ADDRESS> Socket address this node will use for binding its http API. default: `[::]:8080` [env: NYMNODE_HTTP_BIND_ADDRESS=]
--landing-page-assets-path <LANDING_PAGE_ASSETS_PATH> Path to assets directory of custom landing page of this node [env: NYMNODE_HTTP_LANDING_ASSETS=]
--http-access-token <HTTP_ACCESS_TOKEN> An optional bearer token for accessing certain http endpoints. Currently only used for prometheus metrics [env:
NYMNODE_HTTP_ACCESS_TOKEN=]
--expose-system-info <EXPOSE_SYSTEM_INFO> Specify whether basic system information should be exposed. default: true [env: NYMNODE_HTTP_EXPOSE_SYSTEM_INFO=] [possible values:
true, false]
--expose-system-hardware <EXPOSE_SYSTEM_HARDWARE> Specify whether basic system hardware information should be exposed. default: true [env: NYMNODE_HTTP_EXPOSE_SYSTEM_HARDWARE=]
[possible values: true, false]
--expose-crypto-hardware <EXPOSE_CRYPTO_HARDWARE> Specify whether detailed system crypto hardware information should be exposed. default: true [env:
NYMNODE_HTTP_EXPOSE_CRYPTO_HARDWARE=] [possible values: true, false]
--mixnet-bind-address <MIXNET_BIND_ADDRESS> Address this node will bind to for listening for mixnet packets default: `[::]:1789` [env: NYMNODE_MIXNET_BIND_ADDRESS=]
--mixnet-announce-port <MIXNET_ANNOUNCE_PORT> If applicable, custom port announced in the self-described API that other clients and nodes will use. Useful when the node is behind a
proxy [env: NYMNODE_MIXNET_ANNOUNCE_PORT=]
--nym-api-urls <NYM_API_URLS> Addresses to nym APIs from which the node gets the view of the network [env: NYMNODE_NYM_APIS=]
--nyxd-urls <NYXD_URLS> Addresses to nyxd chain endpoint which the node will use for chain interactions [env: NYMNODE_NYXD=]
--enable-console-logging <ENABLE_CONSOLE_LOGGING> Specify whether running statistics of this node should be logged to the console [env: NYMNODE_ENABLE_CONSOLE_LOGGING=] [possible
values: true, false]
--wireguard-enabled <WIREGUARD_ENABLED> Specifies whether the wireguard service is enabled on this node [env: NYMNODE_WG_ENABLED=] [possible values: true, false]
--wireguard-bind-address <WIREGUARD_BIND_ADDRESS> Socket address this node will use for binding its wireguard interface. default: `[::]:51822` [env: NYMNODE_WG_BIND_ADDRESS=]
--wireguard-tunnel-announced-port <WIREGUARD_TUNNEL_ANNOUNCED_PORT> Tunnel port announced to external clients wishing to connect to the wireguard interface. Useful in the instances where the node is
behind a proxy [env: NYMNODE_WG_ANNOUNCED_PORT=]
--wireguard-private-network-prefix <WIREGUARD_PRIVATE_NETWORK_PREFIX> The prefix denoting the maximum number of the clients that can be connected via Wireguard. The maximum value for IPv4 is 32 and for
IPv6 is 128 [env: NYMNODE_WG_PRIVATE_NETWORK_PREFIX=]
--wireguard-userspace <WIREGUARD_USERSPACE> Use userspace implementation of WireGuard (wireguard-go) instead of kernel module. Useful in containerized environments without kernel
WireGuard support [env: NYMNODE_WG_USERSPACE=] [possible values: true, false]
--verloc-bind-address <VERLOC_BIND_ADDRESS> Socket address this node will use for binding its verloc API. default: `[::]:1790` [env: NYMNODE_VERLOC_BIND_ADDRESS=]
--verloc-announce-port <VERLOC_ANNOUNCE_PORT> If applicable, custom port announced in the self-described API that other clients and nodes will use. Useful when the node is behind a
proxy [env: NYMNODE_VERLOC_ANNOUNCE_PORT=]
--entry-bind-address <ENTRY_BIND_ADDRESS> Socket address this node will use for binding its client websocket API. default: `[::]:9000` [env: NYMNODE_ENTRY_BIND_ADDRESS=]
--announce-ws-port <ANNOUNCE_WS_PORT> Custom announced port for listening for websocket client traffic. If unspecified, the value from the `bind_address` will be used
instead [env: NYMNODE_ENTRY_ANNOUNCE_WS_PORT=]
--announce-wss-port <ANNOUNCE_WSS_PORT> If applicable, announced port for listening for secure websocket client traffic [env: NYMNODE_ENTRY_ANNOUNCE_WSS_PORT=]
--enforce-zk-nyms <ENFORCE_ZK_NYMS> Indicates whether this gateway is accepting only coconut credentials for accessing the mixnet or if it also accepts non-paying clients
[env: NYMNODE_ENFORCE_ZK_NYMS=] [possible values: true, false]
--mnemonic <MNEMONIC> Custom cosmos wallet mnemonic used for zk-nym redemption. If no value is provided, a fresh mnemonic is going to be generated [env:
NYMNODE_MNEMONIC=]
--upgrade-mode-attestation-url <UPGRADE_MODE_ATTESTATION_URL> Endpoint to query to retrieve current upgrade mode attestation. This argument should never be set outside testnets and local networks
[env: NYMNODE_UPGRADE_MODE_ATTESTATION_URL=]
--upgrade-mode-attester-public-key <UPGRADE_MODE_ATTESTER_PUBLIC_KEY> Expected public key of the entity signing the published attestation. This argument should never be set outside testnets and local
networks [env: NYMNODE_UPGRADE_MODE_ATTESTER_PUBKEY=]
--upstream-exit-policy-url <UPSTREAM_EXIT_POLICY_URL> Specifies the url for an upstream source of the exit policy used by this node [env: NYMNODE_UPSTREAM_EXIT_POLICY=]
--open-proxy <OPEN_PROXY> Specifies whether this exit node should run in 'open-proxy' mode and thus would attempt to resolve **ANY** request it receives [env:
NYMNODE_OPEN_PROXY=] [possible values: true, false]
--nr-allow-local-ips <NR_ALLOW_LOCAL_IPS> Allow the network requester to forward traffic to non-globally-routable addresses. Intended for local development, private-network
deployments, and testnet scenarios. Not recommended on production exit gateway unless you know what you're doing [env:
NYMNODE_NR_ALLOW_LOCAL_IPS=] [possible values: true, false]
--ipr-allow-local-ips <IPR_ALLOW_LOCAL_IPS> Allow the IP packet router to forward traffic to non-globally-routable addresses. Intended for local development, private-network
deployments, and testnet scenarios. Not recommended on production exit gateway unless you know what you're doing [env:
NYMNODE_IPR_ALLOW_LOCAL_IPS=] [possible values: true, false]
--lp-control-bind-address <LP_CONTROL_BIND_ADDRESS> Bind address for the TCP LP control traffic. default: `[::]:41264` [env: NYMNODE_LP_CONTROL_BIND_ADDRESS=]
--lp-control-announce-port <LP_CONTROL_ANNOUNCE_PORT> Custom announced port for listening for the TCP LP control traffic. If unspecified, the value from the `lp_control_bind_address` will
be used instead [env: NYMNODE_LP_CONTROL_ANNOUNCE_PORT=]
--lp-data-bind-address <LP_DATA_BIND_ADDRESS> Bind address for the UDP LP data traffic. default: `[::]:51264` [env: NYMNODE_LP_DATA_BIND_ADDRESS=]
--lp-data-announce-port <LP_DATA_ANNOUNCE_PORT> Custom announced port for listening for the UDP LP data traffic. If unspecified, the value from the `lp_data_bind_address` will be
used instead [env: NYMNODE_LP_DATA_ANNOUNCE_PORT=]
--lp-use-mock-ecash <LP_USE_MOCK_ECASH> Use mock ecash manager for LP testing. WARNING: Only use this for local testing! Never enable in production. When enabled, the LP
listener will accept any credential without blockchain verification [env: NYMNODE_LP_USE_MOCK_ECASH=] [possible values: true, false]
-h, --help Print help
```
@@ -11,8 +11,7 @@ Commands:
help Print this message or the help of the given subcommand(s)
Options:
-c, --config-env-file <CONFIG_ENV_FILE> Path pointing to an env file that configures the nymvisor and overrides any
preconfigured values
-c, --config-env-file <CONFIG_ENV_FILE> Path pointing to an env file that configures the nymvisor and overrides any preconfigured values
-h, --help Print help
-V, --version Print version
```
@@ -5,11 +5,13 @@ import { Steps } from 'nextra/components';
import { MyTab } from 'components/generic-tabs.tsx';
import PruneLogsVM from 'components/operators/snippets/prune-logs-vm.mdx';
import PruneLogsVPS from 'components/operators/snippets/prune-logs-vps.mdx';
import PatchLinuxVulnerability from 'components/operators/snippets/troubleshooting-linux-vulnerability.mdx';
# Troubleshooting VPS Setup
<VarInfo/>
<PatchLinuxVulnerability />
## System Hygiene
+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,

Some files were not shown because too many files have changed in this diff Show More