Compare commits

..

12 Commits

Author SHA1 Message Date
Jalol Khamroev f77858f260 linux localnet script 2025-11-12 14:46:24 +01:00
durch 41a3f245be Title 2025-11-03 15:37:24 +01:00
durch d7476d1009 MacOS setup instructions 2025-11-03 15:33:23 +01:00
durch b3d5861244 Docker/Container localnet 2025-11-03 12:30:18 +01:00
durch 1a97a53bdb Cleanup 2025-10-24 13:30:42 +02:00
durch 25f1442030 more metrics 2025-10-23 20:50:44 +02:00
durch 19a93c1926 fmt and metrics 2025-10-23 20:14:57 +02:00
durch 1db3a15c97 Cleanup 2025-10-23 20:01:06 +02:00
durch ec568ce8b6 KDF and tests 2025-10-23 18:40:34 +02:00
durch 86d8ed0f5a Remove notes 2025-10-23 11:06:20 +02:00
durch f8906c4514 Client bits 2025-10-23 11:03:30 +02:00
durch 057e07948f Gateway side things 2025-10-23 00:00:45 +02:00
358 changed files with 26652 additions and 8115 deletions
-3
View File
@@ -8,13 +8,10 @@ on:
- 'gateway/**'
- 'integrations/**'
- 'nym-api/**'
- 'nym-authenticator-client/**'
- 'nym-credential-proxy/**'
- 'nym-ip-packet-client/**'
- 'nym-network-monitor/**'
- 'nym-node/**'
- 'nym-node-status-api/**'
- 'nym-registration-client/**'
- 'nym-statistics-api/**'
- 'nym-outfox/**'
- 'nym-validator-rewarder/**'
+9 -10
View File
@@ -6,14 +6,16 @@ on:
paths:
- "ts-packages/**"
- "sdk/typescript/**"
- "nym-connect/desktop/src/**"
- "nym-connect/desktop/package.json"
- "nym-wallet/src/**"
- "nym-wallet/package.json"
- "explorer-v2/**"
- "explorer/**"
- ".github/workflows/ci-lint-typescript.yml"
jobs:
build:
runs-on: arc-linux-latest
runs-on: ubuntu-22.04
env:
RUSTUP_PERMIT_COPY_RENAME: 1
steps:
@@ -23,7 +25,6 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup yarn
run: npm install -g yarn
@@ -36,12 +37,14 @@ jobs:
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
- name: Install wasm-opt
run: cargo install wasm-opt
uses: ./.github/actions/install-wasm-opt
with:
version: '116'
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
go-version: "1.24.6"
go-version: "1.23.7"
- name: Install
run: yarn
@@ -49,11 +52,7 @@ jobs:
- name: Build packages
run: yarn build:ci
- name: Install again
run: yarn
- name: Lint
run: yarn lint
- name: Typecheck with tsc
run: yarn tsc
@@ -8,7 +8,7 @@ on:
jobs:
build:
runs-on: arc-linux-latest
runs-on: custom-linux
steps:
- uses: actions/checkout@v4
+4 -7
View File
@@ -4,7 +4,7 @@ on:
jobs:
publish:
runs-on: ubuntu-latest
runs-on: arc-ubuntu-22.04
steps:
- uses: actions/checkout@v4
@@ -17,13 +17,10 @@ jobs:
- name: Setup yarn
run: npm install -g yarn
- name: Install rust toolchain
- name: Install Rust stable
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
components: rustfmt, clippy
- name: Install wasm-pack
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
@@ -32,9 +29,9 @@ jobs:
run: cargo install wasm-opt
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
go-version: "1.24.6"
go-version: "1.23.7"
- name: Install dependencies
run: yarn
+30 -3
View File
@@ -3,6 +3,11 @@ name: Build and upload Node Status agent container to harbor.nymte.ch
on:
workflow_dispatch:
inputs:
gateway_probe_git_ref:
type: string
default: nym-vpn-core-v1.4.0
required: true
description: Which gateway probe git ref to build the image with
release_image:
description: 'Tag image as a release'
required: true
@@ -38,6 +43,16 @@ jobs:
VERSION=$(yq -oy '.package.version' ${{ env.WORKING_DIRECTORY }}/Cargo.toml)
echo "result=$VERSION" >> $GITHUB_OUTPUT
- name: cleanup-gateway-probe-ref
id: cleanup_gateway_probe_ref
run: |
GATEWAY_PROBE_GIT_REF=${{ github.event.inputs.gateway_probe_git_ref }}
GIT_REF_SLUG="${GATEWAY_PROBE_GIT_REF//\//-}"
echo "git_ref=${GIT_REF_SLUG}" >> $GITHUB_OUTPUT
- name: Set GIT_TAG variable
run: echo "GIT_TAG=${{ env.WORKING_DIRECTORY }}-${{ steps.get_version.outputs.result }}-${{ steps.cleanup_gateway_probe_ref.outputs.git_ref }}" >> $GITHUB_ENV
- name: Initialize RELEASE_TAG
run: echo "RELEASE_TAG=" >> $GITHUB_ENV
@@ -46,12 +61,24 @@ jobs:
run: echo "RELEASE_TAG=golden-" >> $GITHUB_ENV
- name: Set IMAGE_NAME_AND_TAGS variable
run: echo "IMAGE_NAME_AND_TAGS=${{ env.CONTAINER_NAME }}:${{ env.RELEASE_TAG }}${{ steps.get_version.outputs.result }}" >> $GITHUB_ENV
run: echo "IMAGE_NAME_AND_TAGS=${{ env.CONTAINER_NAME }}:${{ env.RELEASE_TAG }}${{ steps.get_version.outputs.result }}-${{ steps.cleanup_gateway_probe_ref.outputs.git_ref }}" >> $GITHUB_ENV
- name: New env vars
run: echo "RELEASE_TAG='$RELEASE_TAG' IMAGE_NAME_AND_TAGS='$IMAGE_NAME_AND_TAGS'"
run: echo "RELEASE_TAG='$RELEASE_TAG' GIT_TAG='$GIT_TAG' IMAGE_NAME_AND_TAGS='$IMAGE_NAME_AND_TAGS'"
# - name: Remove existing tag if exists
# run: |
# if git rev-parse $${{ env.GIT_TAG }} >/dev/null 2>&1; then
# git push --delete origin $${{ env.GIT_TAG }}
# git tag -d $${{ env.GIT_TAG }}
# fi
# - name: Create tag
# run: |
# git tag -a $${{ env.GIT_TAG }} -m "Version ${{ steps.get_version.outputs.result }}-${{ steps.cleanup_gateway_probe_ref.outputs.git_ref }}"
# git push origin $${{ env.GIT_TAG }}
- name: BuildAndPushImageOnHarbor
run: |
docker build -f ${{ env.WORKING_DIRECTORY }}/Dockerfile . -t harbor.nymte.ch/nym/${{ env.IMAGE_NAME_AND_TAGS }}
docker build --build-arg GIT_REF=${{ github.event.inputs.gateway_probe_git_ref }} -f ${{ env.WORKING_DIRECTORY }}/Dockerfile . -t harbor.nymte.ch/nym/${{ env.IMAGE_NAME_AND_TAGS }}
docker push harbor.nymte.ch/nym/${{ env.CONTAINER_NAME }} --all-tags
+1
View File
@@ -63,3 +63,4 @@ nym-api/redocly/formatted-openapi.json
**/settings.sql
**/enter_db.sh
.beads
-40
View File
@@ -4,46 +4,6 @@ Post 1.0.0 release, the changelog format is based on [Keep a Changelog](https://
## [Unreleased]
## [2025.19-kase] (2025-10-30)
- update ns agent workflow ([#6154])
- Cherry pick - request #6143 from nymtech/bugfix/mix-tx-closed-v2 ([#6153])
- bugfix: nym-credential-proxy query params parsing regression ([#6121])
- bugfix: revert some dep updates introduced in #6043 ([#6120])
- Skip ipv6 metadata endpoint request ([#6118])
- update to no longer use 1mb files ([#6117])
- chore: restore pending dkg contract state migration ([#6116])
- Revert "Propagate cancel token to mixnet client" ([#6115])
- Update dirs to 6.0 ([#6109])
- Propagate cancel token to mixnet client ([#6105])
- bugfix: retrieve and update ticketbook in the same query ([#6101])
- bugfix: include network name in the default gateway probe config path ([#6100])
- Bugfix/incompatibility fixes ([#6099])
- [DOCs/operators] QUIC deployment script & docs ([#6098])
- bugfix: testnet manager 02sql migration ([#6096])
- feat: move gateway probe to monorepo (and update to rust edition 2024) ([#6094])
- bugfix: use custom topology provider for list of init gateways ([#6092])
- Max/fix wasm client + build commands ([#6043])
[#6154]: https://github.com/nymtech/nym/pull/6154
[#6153]: https://github.com/nymtech/nym/pull/6153
[#6121]: https://github.com/nymtech/nym/pull/6121
[#6120]: https://github.com/nymtech/nym/pull/6120
[#6118]: https://github.com/nymtech/nym/pull/6118
[#6117]: https://github.com/nymtech/nym/pull/6117
[#6116]: https://github.com/nymtech/nym/pull/6116
[#6115]: https://github.com/nymtech/nym/pull/6115
[#6109]: https://github.com/nymtech/nym/pull/6109
[#6105]: https://github.com/nymtech/nym/pull/6105
[#6101]: https://github.com/nymtech/nym/pull/6101
[#6100]: https://github.com/nymtech/nym/pull/6100
[#6099]: https://github.com/nymtech/nym/pull/6099
[#6098]: https://github.com/nymtech/nym/pull/6098
[#6096]: https://github.com/nymtech/nym/pull/6096
[#6094]: https://github.com/nymtech/nym/pull/6094
[#6092]: https://github.com/nymtech/nym/pull/6092
[#6043]: https://github.com/nymtech/nym/pull/6043
## [2025.18-jarlsberg] (2025-10-14)
- ns-api: add descriptions to dVPN gateway responses ([#6102])
+15 -1
View File
@@ -12,6 +12,16 @@ Nym is a privacy platform that uses mixnet technology to protect against metadat
- Validators for network consensus
- Various service providers and integrations
## Navigation Aids
This repository includes comprehensive navigation documents for efficient code exploration:
- **[CODEMAP.md](./CODEMAP.md)**: Structural overview of the entire repository with directory hierarchy, package descriptions, and navigation hints. Use this to quickly understand the codebase layout and find specific components.
- **[FUNCTION_LEXICON.md](./FUNCTION_LEXICON.md)**: Comprehensive catalog of key functions, signatures, and API patterns across all major modules. Use this to quickly find available functions and understand their usage patterns.
When working with this codebase, start by consulting these documents to understand the structure and available APIs before diving into specific files.
## Build Commands
### Rust Components
@@ -150,7 +160,7 @@ dotenv -f envs/sandbox.env -- cargo run -p nym-api
## Architecture
The Nym platform consists of various components organized as a monorepo:
The Nym platform consists of various components organized as a monorepo. For a detailed structural overview with directory hierarchy and navigation hints, see [CODEMAP.md](./CODEMAP.md).
1. **Core Mixnet Infrastructure**:
- `nym-node`: Core binary supporting mixnode and gateway modes
@@ -422,6 +432,8 @@ The system uses SQLite databases with tables like:
## Development Workflows
**Note**: Before diving into specific workflows, consult [CODEMAP.md](./CODEMAP.md) to understand the repository structure and [FUNCTION_LEXICON.md](./FUNCTION_LEXICON.md) to discover available APIs and functions.
### Running a Node
To run the mixnode or gateway:
@@ -450,6 +462,8 @@ To monitor the health of your node:
## Common Libraries
For a comprehensive catalog of functions and APIs available in these libraries, see [FUNCTION_LEXICON.md](./FUNCTION_LEXICON.md).
- `common/types`: Shared data types across all components
- `common/crypto`: Cryptographic primitives and wrappers
- `common/client-core`: Core client functionality
+452
View File
@@ -0,0 +1,452 @@
# Nym Repository Codemap
<!-- AIDEV-NOTE: This codemap provides structural navigation for the Nym privacy platform monorepo -->
<!-- Last updated: 2024-10-22 (branch: drazen/lp-reg) -->
## Quick Navigation Index
| Component | Location | Purpose |
|-----------|----------|---------|
| [Main Executables](#main-executables) | Root directories | Core binaries and services |
| [Client Implementations](#client-implementations) | `/clients/` | Various client types |
| [Common Libraries](#common-libraries) | `/common/` | 70+ shared modules |
| [Smart Contracts](#smart-contracts) | `/contracts/` | CosmWasm contracts |
| [SDKs](#sdks) | `/sdk/` | Multi-language SDKs |
| [WASM Modules](#wasm-modules) | `/wasm/` | Browser implementations |
| [Service Providers](#service-providers) | `/service-providers/` | Exit nodes & routers |
| [Tools](#tools-and-utilities) | `/tools/` | CLI tools & utilities |
| [Configuration](#configuration-and-environments) | `/envs/` | Environment configs |
## Repository Structure Overview
```
nym/
├── Cargo.toml # Workspace manifest (170+ members)
├── Cargo.lock # Locked dependencies
├── Makefile # Build automation
├── CLAUDE.md # Development guidelines
├── envs/ # Environment configurations
│ ├── local.env # Local development
│ ├── sandbox.env # Test network
│ ├── mainnet.env # Production
│ └── canary.env # Pre-release
├── assets/ # Images, logos, fonts
├── docker/ # Docker configurations
└── scripts/ # Deployment & setup scripts
```
<!-- AIDEV-NOTE: Navigation hint - Use envs/ for network-specific configurations -->
## Main Executables
### Core Network Nodes
#### **nym-node** (v1.19.0) - Universal Node Binary
- **Path**: `/nym-node/`
- **Entry**: `src/main.rs`
- **Modes**: `mixnode`, `gateway`
- **Key Modules**:
- `cli/` - Command-line interface
- `config/` - Configuration management
- `node/` - Core node logic
- `wireguard/` - WireGuard VPN integration
- `throughput_tester/` - Performance testing
<!-- AIDEV-NOTE: Complex area - nym-node replaces legacy gateway and mixnode binaries -->
#### **nym-api** - Network API Server
- **Path**: `/nym-api/`
- **Entry**: `src/main.rs`
- **Database**: PostgreSQL with SQLx
- **Migrations**: `/migrations/` (25+ migration files)
- **Key Subsystems**:
- `circulating_supply_api/` - Token supply tracking
- `ecash/` - E-cash credential management
- `epoch_operations/` - Epoch advancement
- `network_monitor/` - Health monitoring
- `node_performance/` - Performance metrics
- `nym_nodes/` - Node registry
#### **gateway** (Legacy, v1.1.36)
- **Path**: `/gateway/`
- **Status**: Being phased out for nym-node
- **New**: `src/node/lp_listener/` (branch: drazen/lp-reg)
### Supporting Services
| Service | Path | Purpose |
|---------|------|---------|
| `nym-network-monitor` | `/nym-network-monitor/` | Network reliability testing |
| `nym-validator-rewarder` | `/nym-validator-rewarder/` | Reward calculation |
| `nyx-chain-watcher` | `/nyx-chain-watcher/` | Blockchain monitoring |
| `nym-credential-proxy` | `/nym-credential-proxy/` | Credential services |
| `nym-statistics-api` | `/nym-statistics-api/` | Statistics aggregation |
| `nym-node-status-api` | `/nym-node-status-api/` | Node status tracking |
## Client Implementations
### Directory: `/clients/`
```
clients/
├── native/ # Native Rust client
│ └── websocket-requests/ # WebSocket protocol
├── socks5/ # SOCKS5 proxy client
├── validator/ # Blockchain validator client
└── webassembly/ # Browser-based client
```
<!-- AIDEV-NOTE: Pattern reference - All clients use common/client-core for shared functionality -->
## Common Libraries
### Directory: `/common/` (70+ modules)
### Core Infrastructure
| Module | Purpose | Key Types |
|--------|---------|-----------|
| `nym-common` | Shared utilities | Constants, helpers |
| `types` | Common data types | NodeId, MixId |
| `config` | Configuration system | Config traits |
| `commands` | CLI structures | Command builders |
| `bin-common` | Binary utilities | Logging, banners |
### Cryptography & Security
| Module | Purpose | Dependencies |
|--------|---------|-------------|
| `crypto` | Crypto primitives | Ed25519, X25519 |
| `credentials` | Credential system | BLS12-381 |
| `credentials-interface` | Interface definitions | - |
| `credential-verification` | Validation logic | - |
| `pemstore` | PEM storage | - |
### Network Protocol (Sphinx)
<!-- AIDEV-NOTE: Complex area - Sphinx is the core privacy protocol -->
```
nymsphinx/
├── types/ # Core types
├── chunking/ # Message fragmentation
├── forwarding/ # Packet forwarding
├── routing/ # Route selection
├── addressing/ # Address handling
├── anonymous-replies/ # SURB system
├── acknowledgements/ # ACK handling
├── cover/ # Cover traffic
├── params/ # Protocol parameters
└── framing/ # Wire format
```
### New Components (Branch: drazen/lp-reg)
<!-- AIDEV-NOTE: Current branch changes - These are new additions -->
| Module | Path | Status |
|--------|------|--------|
| `nym-lp` | `/common/nym-lp/` | New LP protocol |
| `nym-lp-common` | `/common/nym-lp-common/` | LP utilities |
| `nym-kcp` | `/common/nym-kcp/` | KCP protocol |
### Client Systems
```
client-core/
├── config-types/ # Configuration types
├── gateways-storage/ # Gateway persistence
└── surb-storage/ # SURB storage
client-libs/
├── gateway-client/ # Gateway connection
├── mixnet-client/ # Mixnet interaction
└── validator-client/ # Blockchain queries
```
### Additional Common Modules
**Storage & Data**:
- `statistics/` - Statistical collection
- `topology/` - Network topology
- `node-tester-utils/` - Testing utilities
- `ticketbooks-merkle/` - Merkle trees
**Advanced Features**:
- `dkg/` - Distributed Key Generation
- `ecash-signer-check/` - E-cash validation
- `nym_offline_compact_ecash/` - Offline e-cash
**Blockchain**:
- `ledger/` - Ledger operations
- `nyxd-scraper/` - Chain scraping
- `cosmwasm-smart-contracts/` - Contract interfaces
**Utilities**:
- `task/` - Async task management
- `async-file-watcher/` - File watching
- `nym-cache/` - Caching layer
- `nym-metrics/` - Metrics (Prometheus)
- `bandwidth-controller/` - Bandwidth accounting
## Smart Contracts
### Directory: `/contracts/`
<!-- AIDEV-NOTE: Navigation hint - All contracts use CosmWasm 2.2.2 -->
```
contracts/
├── Cargo.toml # Workspace config
├── .cargo/config.toml # WASM build config
├── coconut-dkg/ # DKG contract
├── ecash/ # E-cash contract
├── mixnet/ # Node registry
├── vesting/ # Token vesting
├── nym-pool/ # Liquidity pool
├── multisig/ # Multi-sig wallet
├── performance/ # Performance tracking
└── mixnet-vesting-integration-tests/
```
### Contract Build Process
```bash
make contracts # Build all
make contract-schema # Generate schemas
make wasm-opt-contracts # Optimize
```
## SDKs
### Directory: `/sdk/`
```
sdk/
├── rust/
│ └── nym-sdk/ # Primary Rust SDK
├── typescript/
│ ├── packages/ # NPM packages
│ ├── codegen/ # Code generation
│ └── examples/ # Usage examples
└── ffi/
├── cpp/ # C++ bindings
├── go/ # Go bindings
└── shared/ # Shared FFI code
```
## WASM Modules
### Directory: `/wasm/`
| Module | Purpose | Build Command |
|--------|---------|---------------|
| `client` | Browser client | `make` in directory |
| `mix-fetch` | Privacy fetch API | `make` in directory |
| `node-tester` | Network testing | `make` in directory |
| `zknym-lib` | Zero-knowledge lib | `make` in directory |
<!-- AIDEV-NOTE: Pattern reference - WASM modules compile from Rust using wasm-pack -->
## Service Providers
### Directory: `/service-providers/`
```
service-providers/
├── network-requester/ # Exit node for external requests
├── ip-packet-router/ # IP packet routing (VPN-like)
└── common/ # Shared utilities
```
## Tools and Utilities
### Directory: `/tools/`
### Public Tools
| Tool | Path | Purpose |
|------|------|---------|
| `nym-cli` | `/tools/nym-cli/` | Node management CLI |
| `nym-id-cli` | `/tools/nym-id-cli/` | Identity management |
| `nymvisor` | `/tools/nymvisor/` | Process supervisor |
| `nym-nr-query` | `/tools/nym-nr-query/` | Network queries |
| `echo-server` | `/tools/echo-server/` | Testing server |
### Internal Tools
```
internal/
├── mixnet-connectivity-check/ # Network diagnostics
├── contract-state-importer/ # Migration tools
├── validator-status-check/ # Validator health
├── ssl-inject/ # SSL injection
├── testnet-manager/ # Testnet management
└── sdk-version-bump/ # Version management
```
## Configuration and Environments
### Environment Files: `/envs/`
<!-- AIDEV-NOTE: Navigation hint - Always use dotenv -f envs/[env].env for proper configuration -->
| Environment | File | API Endpoint | Use Case |
|------------|------|--------------|----------|
| Local | `local.env` | localhost | Development |
| Sandbox | `sandbox.env` | sandbox-nym-api1.nymtech.net | Testing |
| Mainnet | `mainnet.env` | validator.nymtech.net | Production |
| Canary | `canary.env` | - | Pre-release |
### Key Environment Variables
```bash
NETWORK_NAME # Network identifier
NYM_API # API endpoint
NYXD # Blockchain RPC
MIXNET_CONTRACT_ADDRESS # Contract addresses
MNEMONIC # Test mnemonic (NEVER in production)
RUST_LOG # Logging level
DATABASE_URL # PostgreSQL connection
```
## Build System
### Primary Build Commands
```bash
make build # Debug build
make build-release # Release build
make test # Run tests
make clippy # Lint code
make fmt # Format code
make contracts # Build contracts
make sdk-wasm-build # Build WASM
```
### Workspace Configuration
<!-- AIDEV-NOTE: Complex area - Root Cargo.toml manages 170+ workspace members -->
**Root Cargo.toml Structure**:
- `[workspace]` - Lists all 170+ members
- `[workspace.dependencies]` - Shared dependency versions
- `[workspace.lints]` - Shared lint rules
- `[profile.*]` - Build profiles
## Database Structure
### SQLx Usage Pattern
- **Compile-time verified**: All queries checked at build
- **Migration files**: In package `/migrations/` directories
- **Query cache**: `.sqlx/` directory
### Key Tables (nym-api)
```sql
-- Network monitoring
mixnode_status
gateway_status
routes
monitor_run
-- Node registry
nym_nodes
node_descriptions
-- Performance
node_uptime
node_performance
```
## Current Branch Context (drazen/lp-reg)
### New Additions
- `/common/nym-lp/` - Low-level protocol implementation
- `/common/nym-lp-common/` - LP common utilities
- `/common/nym-kcp/` - KCP protocol
- `/gateway/src/node/lp_listener/` - LP listener
### Modified Files
```
M Cargo.lock
M Cargo.toml
M common/registration/
M common/wireguard/
M gateway/
M nym-node/
M nym-node/nym-node-metrics/
```
## Navigation Patterns
<!-- AIDEV-NOTE: Navigation hint - Use these patterns to quickly find code -->
### Finding Code by Type
| Code Type | Look In |
|-----------|---------|
| Main executables | Root directories with `src/main.rs` |
| Libraries | `/common/` with descriptive names |
| Contracts | `/contracts/[name]/src/contract.rs` |
| Tests | Colocated with source, `#[cfg(test)]` |
| Configurations | `/envs/` and `config/` subdirs |
| Database queries | Files with `.sql` or SQLx macros |
| API endpoints | `/nym-api/src/` subdirectories |
| CLI commands | `/cli/commands/` in executables |
### Common Import Locations
```rust
// Crypto
use nym_crypto::asymmetric::{ed25519, x25519};
// Network
use nym_sphinx::forwarding::packet::MixPacket;
use nym_topology::NymTopology;
// Client
use nym_client_core::client::Client;
// Configuration
use nym_network_defaults::NymNetworkDetails;
// Contracts
use nym_mixnet_contract_common::*;
```
## Module Relationships
<!-- AIDEV-NOTE: Complex area - Understanding dependencies helps navigation -->
### Dependency Graph (Simplified)
```
nym-node
├── common/nym-common
├── common/crypto
├── common/nymsphinx
├── common/topology
├── common/client-libs/validator-client
└── common/wireguard
nym-api
├── common/nym-common
├── nym-api-requests
├── common/client-libs/validator-client
├── common/credentials
└── sqlx (database)
clients/native
├── common/client-core
├── common/client-libs/gateway-client
├── common/nymsphinx
└── common/credentials
```
## Development Workflows
### Adding New Feature
1. Check `/envs/` for configuration
2. Find similar code in `/common/`
3. Implement in appropriate module
4. Add tests colocated with code
5. Update `/nym-api/` if needed
6. Run `make test` and `make clippy`
### Debugging Network Issues
1. Start with `/nym-network-monitor/`
2. Check `/common/topology/` for routing
3. Review `/common/nymsphinx/` for protocol
4. Examine logs with `RUST_LOG=debug`
### Contract Development
1. Create in `/contracts/[name]/`
2. Use existing contracts as templates
3. Build with `make contracts`
4. Test with `cw-multi-test`
Generated
+91 -49
View File
@@ -133,9 +133,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "ammonia"
version = "4.1.2"
version = "4.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
checksum = "d6b346764dd0814805de8abf899fe03065bcee69bb1a4771c785817e39f3978f"
dependencies = [
"cssparser",
"html5ever",
@@ -165,6 +165,15 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "ansi_term"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2"
dependencies = [
"winapi",
]
[[package]]
name = "anstream"
version = "0.6.19"
@@ -991,6 +1000,12 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7"
[[package]]
name = "byte_string"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11aade7a05aa8c3a351cedc44c3fc45806430543382fcc4743a9b757a2a0b4ed"
[[package]]
name = "bytecodec"
version = "0.4.15"
@@ -2579,7 +2594,7 @@ dependencies = [
[[package]]
name = "extension-storage"
version = "1.4.1"
version = "1.4.0-rc.0"
dependencies = [
"bip39",
"console_error_panic_hook",
@@ -4478,7 +4493,7 @@ dependencies = [
[[package]]
name = "mix-fetch-wasm"
version = "1.4.1"
version = "1.4.0-rc.0"
dependencies = [
"async-trait",
"futures",
@@ -4488,7 +4503,6 @@ dependencies = [
"nym-ordered-buffer",
"nym-service-providers-common",
"nym-socks5-requests",
"nym-validator-client",
"rand 0.8.5",
"serde",
"serde-wasm-bindgen 0.6.5",
@@ -4825,7 +4839,7 @@ dependencies = [
[[package]]
name = "nym-api"
version = "1.1.68"
version = "1.1.67"
dependencies = [
"anyhow",
"async-trait",
@@ -5051,7 +5065,7 @@ dependencies = [
[[package]]
name = "nym-cli"
version = "1.1.65"
version = "1.1.64"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -5134,7 +5148,7 @@ dependencies = [
[[package]]
name = "nym-client"
version = "1.1.65"
version = "1.1.64"
dependencies = [
"bs58",
"clap",
@@ -5284,7 +5298,7 @@ dependencies = [
[[package]]
name = "nym-client-wasm"
version = "1.4.1"
version = "1.4.0-rc.0"
dependencies = [
"anyhow",
"futures",
@@ -5443,7 +5457,7 @@ dependencies = [
[[package]]
name = "nym-credential-proxy"
version = "0.3.0-test"
version = "0.3.0"
dependencies = [
"anyhow",
"axum",
@@ -5463,11 +5477,8 @@ dependencies = [
"nym-crypto",
"nym-ecash-contract-common",
"nym-ecash-signer-check",
"nym-http-api-client",
"nym-http-api-common",
"nym-network-defaults",
"nym-pemstore",
"nym-upgrade-mode-check",
"nym-validator-client",
"rand 0.8.5",
"reqwest 0.12.22",
@@ -5538,7 +5549,6 @@ dependencies = [
"nym-http-api-client",
"nym-http-api-common",
"nym-serde-helpers",
"nym-upgrade-mode-check",
"reqwest 0.12.22",
"schemars 0.8.22",
"serde",
@@ -5608,6 +5618,7 @@ dependencies = [
"nym-ecash-contract-common",
"nym-gateway-requests",
"nym-gateway-storage",
"nym-metrics",
"nym-task",
"nym-validator-client",
"rand 0.8.5",
@@ -5666,12 +5677,12 @@ dependencies = [
"aead",
"aes",
"aes-gcm-siv",
"anyhow",
"base64 0.22.1",
"blake3",
"bs58",
"cipher",
"ctr",
"curve25519-dalek",
"digest 0.10.7",
"ed25519-dalek",
"generic-array 0.14.7",
@@ -5680,12 +5691,10 @@ dependencies = [
"jwt-simple",
"nym-pemstore",
"nym-sphinx-types",
"nym-test-utils",
"rand 0.8.5",
"rand_chacha 0.3.1",
"serde",
"serde_bytes",
"serde_json",
"sha2 0.10.9",
"subtle-encoding",
"thiserror 2.0.12",
@@ -5805,6 +5814,7 @@ dependencies = [
"bincode",
"bip39",
"bs58",
"bytes",
"dashmap",
"defguard_wireguard_rs",
"fastrand 2.3.0",
@@ -5823,11 +5833,15 @@ dependencies = [
"nym-gateway-storage",
"nym-id",
"nym-ip-packet-router",
"nym-kcp",
"nym-lp",
"nym-metrics",
"nym-mixnet-client",
"nym-mixnode-common",
"nym-network-defaults",
"nym-network-requester",
"nym-node-metrics",
"nym-registration-common",
"nym-sdk",
"nym-service-provider-requests-common",
"nym-sphinx",
@@ -6058,7 +6072,6 @@ dependencies = [
"thiserror 2.0.12",
"tokio",
"tracing",
"tracing-subscriber",
"url",
"wasmtimer",
]
@@ -6209,6 +6222,19 @@ dependencies = [
"url",
]
[[package]]
name = "nym-kcp"
version = "0.1.0"
dependencies = [
"ansi_term",
"byte_string",
"bytes",
"env_logger",
"log",
"thiserror 2.0.12",
"tokio-util",
]
[[package]]
name = "nym-ledger"
version = "0.1.0"
@@ -6220,6 +6246,33 @@ dependencies = [
"thiserror 2.0.12",
]
[[package]]
name = "nym-lp"
version = "0.1.0"
dependencies = [
"ansi_term",
"bincode",
"bs58",
"bytes",
"criterion",
"dashmap",
"nym-crypto",
"nym-lp-common",
"nym-sphinx",
"parking_lot",
"rand 0.8.5",
"rand_chacha 0.3.1",
"serde",
"sha2 0.10.9",
"snow",
"thiserror 2.0.12",
"utoipa",
]
[[package]]
name = "nym-lp-common"
version = "0.1.0"
[[package]]
name = "nym-metrics"
version = "0.1.0"
@@ -6363,7 +6416,7 @@ dependencies = [
[[package]]
name = "nym-network-requester"
version = "1.1.66"
version = "1.1.65"
dependencies = [
"addr",
"anyhow",
@@ -6413,7 +6466,7 @@ dependencies = [
[[package]]
name = "nym-node"
version = "1.20.0"
version = "1.19.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -6555,7 +6608,7 @@ dependencies = [
[[package]]
name = "nym-node-status-api"
version = "4.0.11-rc1"
version = "4.0.10"
dependencies = [
"ammonia",
"anyhow",
@@ -6651,7 +6704,7 @@ dependencies = [
[[package]]
name = "nym-node-tester-wasm"
version = "1.3.1"
version = "1.3.0-rc.0"
dependencies = [
"futures",
"js-sys",
@@ -6794,20 +6847,25 @@ dependencies = [
name = "nym-registration-client"
version = "0.1.0"
dependencies = [
"bincode",
"bytes",
"futures",
"nym-authenticator-client",
"nym-bandwidth-controller",
"nym-credential-storage",
"nym-credentials-interface",
"nym-crypto",
"nym-ip-packet-client",
"nym-lp",
"nym-registration-common",
"nym-sdk",
"nym-validator-client",
"nym-wireguard-types",
"rand 0.8.5",
"thiserror 2.0.12",
"tokio",
"tokio-util",
"tracing",
"typed-builder",
"url",
]
@@ -6815,10 +6873,15 @@ dependencies = [
name = "nym-registration-common"
version = "0.1.0"
dependencies = [
"bincode",
"nym-authenticator-requests",
"nym-credentials-interface",
"nym-crypto",
"nym-ip-packet-requests",
"nym-sphinx",
"nym-wireguard-types",
"serde",
"time",
"tokio-util",
]
@@ -6940,7 +7003,7 @@ dependencies = [
[[package]]
name = "nym-socks5-client"
version = "1.1.65"
version = "1.1.64"
dependencies = [
"bs58",
"clap",
@@ -7387,14 +7450,12 @@ dependencies = [
"jwt-simple",
"nym-crypto",
"nym-http-api-client",
"nym-test-utils",
"reqwest 0.12.22",
"serde",
"serde_json",
"thiserror 2.0.12",
"time",
"tracing",
"utoipa",
]
[[package]]
@@ -7590,6 +7651,7 @@ dependencies = [
"nym-crypto",
"nym-gateway-requests",
"nym-gateway-storage",
"nym-metrics",
"nym-network-defaults",
"nym-node-metrics",
"nym-task",
@@ -7681,7 +7743,7 @@ dependencies = [
[[package]]
name = "nymvisor"
version = "0.1.30"
version = "0.1.29"
dependencies = [
"anyhow",
"bytes",
@@ -7835,9 +7897,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.110"
version = "0.9.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2"
checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
dependencies = [
"cc",
"libc",
@@ -11403,26 +11465,6 @@ dependencies = [
"utf-8",
]
[[package]]
name = "typed-builder"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d0dd654273fc253fde1df4172c31fb6615cf8b041d3a4008a028ef8b1119e66"
dependencies = [
"typed-builder-macro",
]
[[package]]
name = "typed-builder-macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "016c26257f448222014296978b2c8456e2cad4de308c35bdb1e383acd569ef5b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "typenum"
version = "1.18.0"
+17 -7
View File
@@ -72,6 +72,9 @@ members = [
"common/nym-cache",
"common/nym-connection-monitor",
"common/nym-id",
"common/nym-kcp",
"common/nym-lp",
"common/nym-lp-common",
"common/nym-metrics",
"common/nym_offline_compact_ecash",
"common/nymnoise",
@@ -150,7 +153,7 @@ members = [
"tools/internal/contract-state-importer/importer-cli",
"tools/internal/contract-state-importer/importer-contract",
"tools/internal/mixnet-connectivity-check",
# "tools/internal/sdk-version-bump",
# "tools/internal/sdk-version-bump",
"tools/internal/ssl-inject",
"tools/internal/testnet-manager",
"tools/internal/testnet-manager/dkg-bypass-contract",
@@ -203,6 +206,7 @@ aes = "0.8.1"
aes-gcm = "0.10.1"
aes-gcm-siv = "0.11.1"
ammonia = "4"
ansi_term = "0.12"
anyhow = "1.0.98"
arc-swap = "1.7.1"
argon2 = "0.5.0"
@@ -215,6 +219,7 @@ base64 = "0.22.1"
base85rs = "0.1.3"
bincode = "1.3.3"
bip39 = { version = "2.0.0", features = ["zeroize"] }
bit-vec = "0.7.0" # can we unify those?
bitvec = "1.0.0"
blake3 = "1.7.0"
bloomfilter = "3.0.1"
@@ -242,11 +247,13 @@ criterion = "0.5"
csv = "1.3.1"
ctr = "0.9.1"
cupid = "0.6.1"
curve25519-dalek = "4.1"
dashmap = "5.5.3"
# We want https://github.com/DefGuard/wireguard-rs/pull/64 , but there's no crates.io release being pushed out anymore
defguard_wireguard_rs = { git = "https://github.com/DefGuard/wireguard-rs.git", rev = "v0.4.7" }
digest = "0.10.7"
dirs = "6.0"
doc-comment = "0.3"
dotenvy = "0.15.6"
dyn-clone = "1.0.19"
ecdsa = "0.16"
@@ -262,8 +269,11 @@ futures = "0.3.31"
futures-util = "0.3"
generic-array = "0.14.7"
getrandom = "0.2.10"
getset = "0.1.5"
handlebars = "3.5.5"
headers = "0.4.0"
hex = "0.4.3"
hex-literal = "0.3.3"
hickory-resolver = "0.25"
hkdf = "0.12.3"
hmac = "0.12.1"
@@ -287,10 +297,12 @@ lazy_static = "1.5.0"
ledger-transport = "0.10.0"
ledger-transport-hid = "0.10.0"
log = "0.4"
maxminddb = "0.23.0"
mime = "0.3.17"
moka = { version = "0.12", features = ["future"] }
nix = "0.27.1"
notify = "5.1.0"
okapi = "0.7.0"
once_cell = "1.21.3"
opentelemetry = "0.19.0"
opentelemetry-jaeger = "0.18.0"
@@ -299,6 +311,7 @@ pem = "0.8"
petgraph = "0.6.5"
pin-project = "1.1"
pnet_packet = "0.35.0"
pin-project-lite = "0.2.16"
publicsuffix = "2.3.0"
proc_pidinfo = "0.1.3"
quote = "1"
@@ -306,10 +319,13 @@ rand = "0.8.5"
rand_chacha = "0.3"
rand_core = "0.6.3"
rand_distr = "0.4"
rand_pcg = "0.3.1"
rand_seeder = "0.2.3"
rayon = "1.5.1"
regex = "1.10.6"
reqwest = { version = "0.12.15", default-features = false }
rs_merkle = "1.5.0"
safer-ffi = "0.1.13"
schemars = "0.8.22"
semver = "1.0.26"
serde = "1.0.219"
@@ -356,7 +372,6 @@ tracing-indicatif = "0.3.9"
tracing-test = "0.2.5"
ts-rs = "10.1.0"
tungstenite = { version = "0.20.1", default-features = false }
typed-builder = "0.23.0"
uniffi = "0.29.2"
uniffi_build = "0.29.0"
url = "2.5"
@@ -453,11 +468,6 @@ opt-level = 'z'
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tokio_unstable)'] }
[workspace.lints.clippy]
suspicious = "deny"
complexity = "deny"
perf = "deny"
style = "deny"
unwrap_used = "deny"
expect_used = "deny"
todo = "deny"
+909
View File
@@ -0,0 +1,909 @@
# Nym Function Lexicon
<!-- AIDEV-NOTE: This lexicon catalogs key functions, signatures, and API patterns across the Nym codebase -->
<!-- Last updated: 2024-10-22 (branch: drazen/lp-reg) -->
## Quick Reference Index
| Category | Section | Key Operations |
|----------|---------|----------------|
| [Node Operations](#1-node-operations) | Mixnode & Gateway | Initialization, key management, tasks |
| [Sphinx Protocol](#2-sphinx-packet-protocol) | Packet Processing | Message creation, chunking, routing |
| [Client APIs](#3-client-apis) | Client Operations | Connection, sending, receiving |
| [Network Topology](#4-network-topology) | Routing | Topology queries, route selection |
| [Blockchain](#5-blockchain-operations) | Validator Client | Queries, transactions, contracts |
| [REST APIs](#6-rest-api-endpoints) | HTTP Handlers | API routes and responses |
| [Credentials](#7-credential--ecash) | E-cash | Credential creation, verification |
| [Smart Contracts](#8-smart-contracts) | CosmWasm | Entry points, messages |
| [Common Patterns](#9-common-patterns) | Conventions | Naming, errors, async |
---
## 1. Node Operations
### nym-node Core Functions
<!-- AIDEV-NOTE: Complex area - nym-node unifies mixnode and gateway functionality -->
**Module**: `nym-node/src/node/mod.rs`
```rust
// Node initialization
pub async fn initialise_node(
config: &Config,
rng: &mut impl CryptoRng + RngCore,
) -> Result<NodeData, NymNodeError>
// Key management
pub fn load_x25519_wireguard_keypair(
paths: &KeysPaths,
) -> Result<x25519::KeyPair, NymNodeError>
pub fn load_ed25519_identity_keypair(
paths: &KeysPaths,
) -> Result<ed25519::KeyPair, NymNodeError>
// Gateway-specific initialization
impl GatewayTasksData {
pub async fn new(
config: &GatewayTasksConfig,
client_storage: ClientStorage,
) -> Result<GatewayTasksData, GatewayError>
pub fn initialise(
config: &GatewayTasksConfig,
force_init: bool,
) -> Result<(), GatewayError>
}
// Service provider initialization
impl ServiceProvidersData {
pub fn initialise_client_keys<R: RngCore + CryptoRng>(
rng: &mut R,
gateway_paths: &GatewayPaths,
) -> Result<ed25519::KeyPair, GatewayError>
pub async fn initialise_network_requester<R>(
rng: &mut R,
config: &Config,
) -> Result<Option<LocalNetworkRequester>, GatewayError>
}
```
### Gateway Task Builder Pattern
**Module**: `gateway/src/node/mod.rs`
```rust
pub struct GatewayTasksBuilder {
// Builder methods
pub fn new(
identity_keypair: Arc<ed25519::KeyPair>,
config: Config,
client_storage: ClientStorage,
) -> GatewayTasksBuilder
pub fn set_network_requester_opts(
&mut self,
opts: Option<LocalNetworkRequesterOpts>
) -> &mut Self
pub fn set_ip_packet_router_opts(
&mut self,
opts: Option<LocalIpPacketRouterOpts>
) -> &mut Self
pub async fn build_and_run(
self,
shutdown: TaskManager,
) -> Result<(), GatewayError>
}
```
<!-- AIDEV-NOTE: Pattern reference - Builder pattern is common for complex initialization -->
---
## 2. Sphinx Packet Protocol
### Message Construction & Processing
**Module**: `common/nymsphinx/src/message.rs`
```rust
// Core message types
pub enum NymMessage {
Plain(Vec<u8>),
Repliable(RepliableMessage),
Reply(ReplyMessage),
}
impl NymMessage {
// Constructors
pub fn new_plain(msg: Vec<u8>) -> NymMessage
pub fn new_repliable(msg: RepliableMessage) -> NymMessage
pub fn new_reply(msg: ReplyMessage) -> NymMessage
pub fn new_additional_surbs_request(
recipient: Recipient,
amount: u32
) -> NymMessage
// Processing
pub fn pad_to_full_packet_lengths(
self,
plaintext_per_packet: usize
) -> PaddedMessage
pub fn split_into_fragments<R: Rng>(
self,
rng: &mut R,
packet_size: PacketSize,
) -> Vec<Fragment>
pub fn remove_padding(self) -> Result<NymMessage, NymMessageError>
// Queries
pub fn is_reply_surb_request(&self) -> bool
pub fn available_sphinx_plaintext_per_packet(
&self,
packet_size: PacketSize
) -> usize
pub fn required_packets(&self, packet_size: PacketSize) -> usize
}
```
### Payload Building & Preparation
**Module**: `common/nymsphinx/src/preparer.rs`
```rust
pub struct NymPayloadBuilder {
// Main preparation methods
pub async fn prepare_chunk_for_sending(
&mut self,
message: NymMessage,
topology: &NymTopology,
) -> Result<Vec<MixPacket>, NymPayloadBuilderError>
pub async fn prepare_reply_chunk_for_sending(
&mut self,
reply: NymMessage,
reply_surb: ReplySurb,
) -> Result<Vec<MixPacket>, NymPayloadBuilderError>
// SURB generation
pub fn generate_reply_surbs(
&mut self,
amount: u32,
topology: &NymTopology,
) -> Result<Vec<SurbAck>, NymPayloadBuilderError>
// Fragment splitting
pub fn pad_and_split_message(
&mut self,
message: NymMessage,
) -> Result<Vec<Fragment>, NymPayloadBuilderError>
}
// Builder constructors
pub fn build_regular<R: CryptoRng + Rng>(
rng: R,
sender_address: Option<Recipient>,
) -> NymPayloadBuilder
pub fn build_reply(
sender_address: Recipient,
sender_tag: AnonymousSenderTag,
) -> NymPayloadBuilder
```
### Chunking & Fragmentation
**Module**: `common/nymsphinx/chunking/src/lib.rs`
<!-- AIDEV-NOTE: Complex area - Chunking splits messages into Sphinx-sized packets -->
```rust
// Main chunking function
pub fn split_into_sets(
message: &[u8],
max_plaintext_size: usize,
max_fragments_per_set: usize,
) -> Result<Vec<Vec<Fragment>>, ChunkingError>
// Fragment monitoring (optional feature)
pub mod monitoring {
pub fn enable()
pub fn enabled() -> bool
pub fn fragment_received(fragment: &Fragment)
pub fn fragment_sent(
fragment: &Fragment,
client_nonce: i32,
destination: PublicKey
)
}
```
---
## 3. Client APIs
### Gateway Client
**Module**: `common/client-libs/gateway-client/src/lib.rs`
```rust
pub struct GatewayClient {
// Connection management
pub async fn connect(
config: GatewayClientConfig,
) -> Result<GatewayClient, GatewayClientError>
pub async fn authenticate(
&mut self,
credentials: Credentials,
) -> Result<(), GatewayClientError>
// Message operations
pub async fn send_mix_packet(
&self,
packet: MixPacket,
) -> Result<(), GatewayClientError>
pub async fn receive_messages(
&mut self,
) -> Result<Vec<ReconstructedMessage>, GatewayClientError>
}
// Packet routing
pub struct PacketRouter {
pub fn new(
mix_tx: MixnetMessageSender,
ack_tx: AcknowledgementSender,
) -> PacketRouter
pub async fn route_packet(
&self,
packet: MixPacket,
) -> Result<(), PacketRouterError>
}
```
### Mixnet Client
**Module**: `common/client-libs/mixnet-client/src/lib.rs`
```rust
pub struct Client {
// Core client operations
pub async fn new(config: Config) -> Result<Client, ClientError>
pub async fn send_message(
&mut self,
recipient: Recipient,
message: Vec<u8>,
) -> Result<(), ClientError>
pub async fn receive_message(
&mut self,
) -> Result<ReconstructedMessage, ClientError>
// Connection management
pub fn is_connected(&self) -> bool
pub async fn reconnect(&mut self) -> Result<(), ClientError>
}
// Send without response trait
pub trait SendWithoutResponse {
fn send_without_response(
&self,
packet: MixPacket,
) -> io::Result<()>
}
```
<!-- AIDEV-NOTE: Pattern reference - Async/await is standard for network operations -->
### Client Core Initialization
**Module**: `common/client-core/src/init.rs`
```rust
// Key generation
pub fn generate_new_client_keys<R: CryptoRng + Rng>(
rng: &mut R,
) -> (ed25519::KeyPair, x25519::KeyPair)
// Storage initialization
pub async fn init_storage(
paths: &ClientPaths,
) -> Result<ClientStorage, ClientCoreError>
// Configuration setup
pub fn setup_client_config(
id: &str,
network: Network,
) -> Result<Config, ClientCoreError>
```
---
## 4. Network Topology
### Topology Management
**Module**: `common/topology/src/lib.rs`
```rust
pub struct NymTopology {
// Query methods
pub fn mixnodes(&self) -> &[RoutingNode]
pub fn gateways(&self) -> &[RoutingNode]
pub fn layer_nodes(&self, layer: MixLayer) -> Vec<&RoutingNode>
// Route selection
pub fn random_route<R: Rng>(
&self,
rng: &mut R,
) -> Option<Vec<RoutingNode>>
pub fn get_node_by_id(&self, node_id: NodeId) -> Option<&RoutingNode>
}
// Route provider
pub struct NymRouteProvider {
pub fn new(topology: NymTopology) -> NymRouteProvider
pub fn random_route<R: Rng>(
&self,
rng: &mut R,
) -> Option<Vec<RoutingNode>>
}
// Topology provider trait
pub trait TopologyProvider {
async fn get_topology(&self) -> Result<NymTopology, NymTopologyError>
async fn refresh_topology(&mut self) -> Result<(), NymTopologyError>
}
```
### Routing Node
**Module**: `common/topology/src/node.rs`
```rust
pub struct RoutingNode {
pub fn node_id(&self) -> NodeId
pub fn identity_key(&self) -> &ed25519::PublicKey
pub fn sphinx_key(&self) -> &x25519::PublicKey
pub fn mix_host(&self) -> &SocketAddr
pub fn clients_ws_address(&self) -> Option<&Url>
}
```
---
## 5. Blockchain Operations
### Validator Client
**Module**: `common/client-libs/validator-client/src/client.rs`
<!-- AIDEV-NOTE: Complex area - Handles all blockchain interactions -->
```rust
pub struct Client<C, S = NoSigner> {
// Contract queries
pub async fn query_contract_state<T>(
&self,
contract: &str,
query: T,
) -> Result<ContractStateResponse, ValidatorClientError>
where T: Into<Binary>
// Transaction execution (requires signer)
pub async fn execute_contract_message<M>(
&self,
contract: &str,
msg: M,
funds: Vec<Coin>,
) -> Result<TxResponse, ValidatorClientError>
where M: Into<Binary>
// Specific contract operations
pub async fn bond_mixnode(
&self,
mixnode: MixNode,
cost_params: MixNodeCostParams,
pledge: Coin,
) -> Result<TxResponse, ValidatorClientError>
pub async fn unbond_mixnode(&self) -> Result<TxResponse, ValidatorClientError>
pub async fn delegate_to_mixnode(
&self,
mix_id: MixId,
amount: Coin,
) -> Result<TxResponse, ValidatorClientError>
}
// Nyxd-specific client
pub type DirectSigningHttpRpcNyxdClient =
nyxd::NyxdClient<HttpRpcClient, DirectSecp256k1HdWallet>;
```
### Contract Queries
**Module**: `common/client-libs/validator-client/src/nyxd/contract_traits/`
```rust
// Mixnet contract queries
pub trait MixnetQueryClient {
async fn get_mixnodes(&self) -> Result<Vec<MixNodeDetails>, NyxdError>
async fn get_gateways(&self) -> Result<Vec<Gateway>, NyxdError>
async fn get_current_epoch(&self) -> Result<Epoch, NyxdError>
async fn get_rewarded_set(&self) -> Result<EpochRewardedSet, NyxdError>
}
// Vesting contract queries
pub trait VestingQueryClient {
async fn get_vesting_details(&self, address: &str)
-> Result<VestingDetails, NyxdError>
}
// E-cash contract queries
pub trait EcashQueryClient {
async fn get_deposit(&self, id: DepositId)
-> Result<Deposit, NyxdError>
}
```
---
## 6. REST API Endpoints
### nym-api Main Routes
**Module**: `nym-api/src/main.rs` and submodules
<!-- AIDEV-NOTE: Navigation hint - Each module contains router setup and handlers -->
```rust
// Main API setup
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
// Router configuration
let app = Router::new()
.merge(api_routes())
.merge(swagger_ui())
.layer(cors_layer())
.layer(trace_layer());
}
// Core API routes (various modules)
pub fn api_routes() -> Router {
Router::new()
.nest("/v1/status", status_routes())
.nest("/v1/mixnodes", mixnode_routes())
.nest("/v1/gateways", gateway_routes())
.nest("/v1/network", network_routes())
.nest("/v1/ecash", ecash_routes())
}
```
### Status Routes
**Module**: `nym-api/src/status/mod.rs`
```rust
pub async fn status_handler() -> impl IntoResponse {
Json(ApiStatusResponse {
status: "ok",
uptime: get_uptime(),
})
}
pub async fn health_check() -> impl IntoResponse {
StatusCode::OK
}
```
### Network Monitor Routes
**Module**: `nym-api/src/network_monitor/mod.rs`
```rust
pub async fn get_monitor_report(
State(state): State<AppState>,
) -> Result<Json<MonitorReport>, ApiError> {
// Returns network reliability report
}
pub async fn get_node_reliability(
Path(node_id): Path<NodeId>,
State(state): State<AppState>,
) -> Result<Json<NodeReliability>, ApiError> {
// Returns specific node reliability
}
```
### E-cash API
**Module**: `nym-api/src/ecash/mod.rs`
```rust
pub async fn verify_credential(
Json(credential): Json<Credential>,
State(state): State<AppState>,
) -> Result<Json<VerificationResponse>, ApiError> {
// Verifies e-cash credentials
}
pub async fn issue_credential(
Json(request): Json<IssuanceRequest>,
State(state): State<AppState>,
) -> Result<Json<IssuedCredential>, ApiError> {
// Issues new e-cash credentials
}
```
---
## 7. Credential & E-cash
### Credential Operations
**Module**: `common/credentials/src/ecash/mod.rs`
```rust
// Credential spending
pub struct CredentialSpendingData {
pub fn new(
ticketbook: IssuedTicketBook,
gateway_identity: ed25519::PublicKey,
) -> CredentialSpendingData
pub fn prepare_for_spending(
&self,
request_id: i64,
) -> PreparedCredential
}
// Credential signing
pub struct CredentialSigningData {
pub fn sign_credential(
&self,
blinded_credential: BlindedCredential,
) -> Result<BlindedSignature, CredentialError>
}
// Aggregation utilities
pub fn aggregate_verification_keys(
keys: Vec<VerificationKey>,
) -> AggregatedVerificationKey
pub fn obtain_aggregate_wallet(
verification_keys: Vec<VerificationKey>,
commitments: Vec<Commitment>,
) -> Result<AggregateWallet, CredentialError>
```
### Ticketbook Operations
**Module**: `common/credentials/src/ecash/bandwidth/mod.rs`
<!-- AIDEV-NOTE: Complex area - Ticketbooks contain bandwidth credentials -->
```rust
pub struct IssuedTicketBook {
pub fn new(
tickets: Vec<IssuedTicket>,
expiration: OffsetDateTime,
) -> IssuedTicketBook
pub fn total_bandwidth(&self) -> Bandwidth
pub fn is_expired(&self) -> bool
pub fn consume_ticket(&mut self) -> Option<IssuedTicket>
}
pub struct ImportableTicketBook {
pub fn try_from_base58(s: &str) -> Result<Self, CredentialError>
pub fn into_issued(self) -> Result<IssuedTicketBook, CredentialError>
}
```
---
## 8. Smart Contracts
### Mixnet Contract Entry Points
**Module**: `contracts/mixnet/src/contract.rs`
```rust
#[entry_point]
pub fn instantiate(
deps: DepsMut,
env: Env,
info: MessageInfo,
msg: InstantiateMsg,
) -> Result<Response, ContractError>
#[entry_point]
pub fn execute(
deps: DepsMut,
env: Env,
info: MessageInfo,
msg: ExecuteMsg,
) -> Result<Response, ContractError>
#[entry_point]
pub fn query(
deps: Deps,
env: Env,
msg: QueryMsg,
) -> StdResult<Binary>
```
### Execute Message Handlers
**Module**: `contracts/mixnet/src/contract.rs`
```rust
// Node operations
fn try_bond_mixnode(
deps: DepsMut,
env: Env,
info: MessageInfo,
mixnode: MixNode,
) -> Result<Response, ContractError>
fn try_unbond_mixnode(
deps: DepsMut,
env: Env,
info: MessageInfo,
) -> Result<Response, ContractError>
// Delegation operations
fn try_delegate(
deps: DepsMut,
env: Env,
info: MessageInfo,
mix_id: MixId,
) -> Result<Response, ContractError>
fn try_undelegate(
deps: DepsMut,
env: Env,
info: MessageInfo,
mix_id: MixId,
) -> Result<Response, ContractError>
// Reward operations
fn try_reward_mixnode(
deps: DepsMut,
env: Env,
mix_id: MixId,
performance: Performance,
) -> Result<Response, ContractError>
```
### Query Message Handlers
```rust
fn query_mixnode(deps: Deps, mix_id: MixId) -> StdResult<MixnodeDetails>
fn query_gateways(deps: Deps) -> StdResult<Vec<Gateway>>
fn query_rewarded_set(deps: Deps, epoch: Epoch) -> StdResult<EpochRewardedSet>
fn query_current_epoch(deps: Deps) -> StdResult<Epoch>
```
---
## 9. Common Patterns
### Function Naming Conventions
<!-- AIDEV-NOTE: Pattern reference - Consistent naming helps code discovery -->
```rust
// Constructors
pub fn new(...) -> Self // Standard constructor
pub fn with_defaults() -> Self // Constructor with defaults
pub fn from_config(config: Config) -> Self // From configuration
// Async initialization
pub async fn init(...) -> Result<T> // Async initialization
pub async fn initialise(...) -> Result<T> // British spelling variant
pub async fn setup(...) -> Result<T> // Setup function
// Builder pattern
pub fn builder() -> TBuilder // Create builder
pub fn set_field(mut self, val: T) -> Self // Builder setter
pub fn build(self) -> Result<T> // Build final object
// Getters
pub fn field(&self) -> &T // Immutable reference
pub fn field_mut(&mut self) -> &mut T // Mutable reference
pub fn into_inner(self) -> T // Consume and return inner
// Queries
pub fn is_valid(&self) -> bool // Boolean check
pub fn has_field(&self) -> bool // Existence check
pub fn contains(&self, item: &T) -> bool // Contains check
// Transformations
pub fn to_type(&self) -> Type // Convert to type
pub fn into_type(self) -> Type // Consume and convert
pub fn try_into_type(self) -> Result<Type> // Fallible conversion
```
### Error Handling Patterns
```rust
// Custom error types with thiserror
#[derive(Error, Debug)]
pub enum ModuleError {
#[error("Network error: {0}")]
Network(#[from] NetworkError),
#[error("Invalid configuration: {reason}")]
InvalidConfig { reason: String },
#[error(transparent)]
Other(#[from] anyhow::Error),
}
// Result type alias
pub type Result<T> = std::result::Result<T, ModuleError>;
// Error conversion
impl From<io::Error> for ModuleError {
fn from(err: io::Error) -> Self {
ModuleError::Io(err)
}
}
```
### Async Patterns
```rust
// Async trait (with async-trait crate)
#[async_trait]
pub trait AsyncOperation {
async fn perform(&self) -> Result<()>;
}
// Spawning tasks
tokio::spawn(async move {
// Task code
});
// Channels for communication
let (tx, mut rx) = mpsc::channel(100);
// Select on multiple futures
tokio::select! {
result = future1 => { /* handle */ },
result = future2 => { /* handle */ },
_ = shutdown.recv() => { /* shutdown */ },
}
```
### Storage Patterns
```rust
// SQLx queries
sqlx::query!(
"SELECT * FROM nodes WHERE id = ?",
node_id
)
.fetch_optional(&pool)
.await?;
// In-memory caching
use dashmap::DashMap;
let cache: DashMap<Key, Value> = DashMap::new();
// File storage
use std::fs;
fs::write(path, data)?;
let content = fs::read_to_string(path)?;
```
---
## 10. Import Reference
### Standard Imports by Category
```rust
// Nym crypto
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_crypto::symmetric::stream_cipher;
// Sphinx protocol
use nym_sphinx::forwarding::packet::MixPacket;
use nym_sphinx::framing::codec::NymCodec;
use nym_sphinx::addressing::nodes::NymNodeRoutingAddress;
use nym_sphinx::params::{PacketSize, DEFAULT_PACKET_SIZE};
// Client libraries
use nym_client_core::client::Client;
use nym_gateway_client::GatewayClient;
use nym_validator_client::ValidatorClient;
// Topology
use nym_topology::{NymTopology, RoutingNode};
use nym_mixnet_contract_common::NodeId;
// Configuration
use nym_network_defaults::NymNetworkDetails;
use nym_config::defaults::NymNetwork;
// Async runtime
use tokio::sync::{mpsc, RwLock, Mutex};
use tokio::time::{sleep, Duration};
use futures::{StreamExt, SinkExt};
// Error handling
use thiserror::Error;
use anyhow::{anyhow, Result, Context};
// Logging
use tracing::{debug, info, warn, error, instrument};
// Serialization
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
// Web framework (API)
use axum::{Router, extract::{Path, Query, State}, response::IntoResponse};
use axum::Json;
```
---
## 11. Feature Flags
### Common Feature Gates
```rust
// Client-specific features
#[cfg(feature = "client")]
#[cfg(feature = "cli")]
// Platform-specific
#[cfg(not(target_arch = "wasm32"))]
#[cfg(target_arch = "wasm32")]
// Testing
#[cfg(test)]
#[cfg(feature = "testing")]
#[cfg(feature = "contract-testing")]
// Storage backends
#[cfg(feature = "fs-surb-storage")]
#[cfg(feature = "fs-credentials-storage")]
// Network features
#[cfg(feature = "http-client")]
#[cfg(feature = "websocket")]
```
---
## Quick Lookup Tables
### Async vs Sync Functions
| Operation Type | Typically Async | Typically Sync |
|---------------|-----------------|----------------|
| Network I/O | ✓ | |
| Database queries | ✓ | |
| Contract execution | ✓ | |
| Cryptographic ops | | ✓ |
| Message construction | | ✓ |
| Configuration parsing | | ✓ |
| Topology queries | Both | Both |
### Return Type Patterns
| Pattern | Usage | Example |
|---------|-------|---------|
| `Result<T, E>` | Fallible operations | `connect() -> Result<Client>` |
| `Option<T>` | May not exist | `get_node() -> Option<Node>` |
| `impl Trait` | Return trait impl | `handler() -> impl IntoResponse` |
| `Box<dyn Trait>` | Dynamic dispatch | `create() -> Box<dyn Storage>` |
| Direct type | Infallible ops | `new() -> Self` |
### Module Organization
| Module Type | Location Pattern | Naming Convention |
|------------|------------------|-------------------|
| Binary entry | `/src/main.rs` | - |
| Library root | `/src/lib.rs` | - |
| Submodules | `/src/module/mod.rs` | snake_case |
| Tests | `/src/module/tests.rs` | #[cfg(test)] |
| Errors | `/src/error.rs` | ModuleError |
| Config | `/src/config.rs` | Config struct |
---
<!-- AIDEV-NOTE: This lexicon provides rapid function lookup. Use Ctrl+F to search for specific operations -->
+4 -4
View File
@@ -107,16 +107,16 @@ sdk-wasm-build:
$(MAKE) -C nym-browser-extension/storage wasm-pack
$(MAKE) -C wasm/client
$(MAKE) -C wasm/node-tester
$(MAKE) -C wasm/mix-fetch
# $(MAKE) -C wasm/mix-fetch
$(MAKE) -C wasm/zknym-lib
# $(MAKE) -C wasm/full-nym-wasm
# run this from npm/yarn to ensure tools are in the path, e.g. yarn build:sdk from root of repo
sdk-typescript-build:
npx lerna run --scope @nymproject/sdk build --stream
npx lerna run --scope @nymproject/mix-fetch build --stream
npx lerna run --scope @nymproject/node-tester build --stream
yarn --cwd sdk/typescript/codegen/contract-clients build
# npx lerna run --scope @nymproject/mix-fetch build --stream
# npx lerna run --scope @nymproject/node-tester build --stream
# yarn --cwd sdk/typescript/codegen/contract-clients build
# NOTE: These targets are part of the main workspace (but not as wasm32-unknown-unknown)
WASM_CRATES = extension-storage nym-client-wasm nym-node-tester-wasm zknym-lib
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nym-client"
version = "1.1.65"
version = "1.1.64"
authors = ["Dave Hrycyszyn <futurechimp@users.noreply.github.com>", "Jędrzej Stuczyński <andrew@nymtech.net>"]
description = "Implementation of the Nym Client"
edition = "2021"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nym-socks5-client"
version = "1.1.65"
version = "1.1.64"
authors = ["Dave Hrycyszyn <futurechimp@users.noreply.github.com>"]
description = "A SOCKS5 localhost proxy that converts incoming messages to Sphinx and sends them to a Nym address"
edition = "2021"
@@ -801,7 +801,7 @@ where
event_tx,
);
let mix_tx = mix_traffic_controller.mix_tx();
let mix_tx = mix_traffic_controller.mix_rx();
let client_tx = mix_traffic_controller.client_tx();
shutdown_tracker.try_spawn_named(
@@ -1004,8 +1004,8 @@ where
// Create a shutdown tracker for this client - either as a child of provided tracker
// or get one from the registry
let shutdown_tracker = match self.shutdown {
Some(parent_tracker) => parent_tracker.clone(),
None => nym_task::create_sdk_shutdown_tracker()?,
Some(parent_tracker) => parent_tracker.child_tracker(),
None => nym_task::get_sdk_shutdown_tracker()?,
};
Self::start_event_control(self.event_tx, event_receiver, &shutdown_tracker);
@@ -1044,7 +1044,7 @@ where
self.user_agent.clone(),
generate_client_stats_id(*self_address.identity()),
input_sender.clone(),
&shutdown_tracker.clone(),
&shutdown_tracker.child_tracker(),
);
// needs to be started as the first thing to block if required waiting for the gateway
@@ -1054,7 +1054,7 @@ where
shared_topology_accessor.clone(),
self_address.gateway(),
self.wait_for_gateway,
&shutdown_tracker.clone(),
&shutdown_tracker.child_tracker(),
)
.await?;
@@ -1074,7 +1074,7 @@ where
stats_reporter.clone(),
#[cfg(unix)]
self.connection_fd_callback,
&shutdown_tracker.clone(),
&shutdown_tracker.child_tracker(),
)
.await?;
let gateway_ws_fd = gateway_transceiver.ws_fd();
@@ -1082,7 +1082,7 @@ where
let reply_storage = Self::setup_persistent_reply_storage(
reply_storage_backend,
key_rotation_config,
&shutdown_tracker.clone(),
&shutdown_tracker.child_tracker(),
)
.await?;
@@ -1093,7 +1093,7 @@ where
reply_storage.key_storage(),
reply_controller_sender.clone(),
stats_reporter.clone(),
&shutdown_tracker.clone(),
&shutdown_tracker.child_tracker(),
);
// The message_sender is the transmitter for any component generating sphinx packets
@@ -1103,7 +1103,7 @@ where
let (message_sender, client_request_sender) = Self::start_mix_traffic_controller(
gateway_transceiver,
&shutdown_tracker.clone(),
&shutdown_tracker.child_tracker(),
EventSender(event_sender),
);
@@ -1134,7 +1134,7 @@ where
shared_lane_queue_lengths.clone(),
client_connection_rx,
stats_reporter.clone(),
&shutdown_tracker.clone(),
&shutdown_tracker.child_tracker(),
);
if !self
@@ -1150,7 +1150,7 @@ where
shared_topology_accessor.clone(),
message_sender,
stats_reporter.clone(),
&shutdown_tracker.clone(),
&shutdown_tracker.child_tracker(),
);
}
@@ -205,7 +205,7 @@ impl LoopCoverTrafficStream<OsRng> {
TrySendError::Full(_) => {
// This isn't a problem, if the channel is full means we're already sending the
// max amount of messages downstream can handle.
tracing::trace!("Failed to send cover message - channel full");
tracing::debug!("Failed to send cover message - channel full");
}
TrySendError::Closed(_) => {
tracing::warn!("Failed to send cover message - channel closed");
@@ -20,10 +20,7 @@ pub mod transceiver;
// We remind ourselves that 32 x 32kb = 1024kb, a reasonable size for a network buffer.
pub const MIX_MESSAGE_RECEIVER_BUFFER_SIZE: usize = 32;
/// Reduced from 100 to 20 to fail fast (~1-2 seconds instead of ~6 seconds).
/// If we can't send 20 packets in a row, the gateway is unreachable.
const MAX_FAILURE_COUNT: usize = 20;
const MAX_FAILURE_COUNT: usize = 100;
// that's also disgusting.
pub struct Empty;
@@ -87,7 +84,7 @@ impl MixTrafficController {
self.client_tx.clone()
}
pub fn mix_tx(&self) -> BatchMixMessageSender {
pub fn mix_rx(&self) -> BatchMixMessageSender {
self.mix_tx.clone()
}
@@ -159,11 +156,6 @@ impl MixTrafficController {
// Do we need to handle the embedded mixnet client case
// separately?
self.event_tx.send(MixnetClientEvent::Traffic(MixTrafficEvent::FailedSendingSphinx));
// IMO it shouldn't be signalled from there but it is how it is
// TODO : report the failure upwards and shutdown from upwards
// Gateway is dead, we have to shut down currently
error!("Signalling shutdown from the MixTrafficController");
self.shutdown_token.cancel();
break;
}
}
@@ -298,8 +298,6 @@ where
"failed to send mixnet packet due to closed channel (outside of shutdown!)"
);
}
// Early return to avoid further processing when channel is closed
return;
}
Ok(_) => {
let event = if fragment_id.is_some() {
+1 -1
View File
@@ -45,7 +45,7 @@ pub enum ClientCoreError {
#[cfg(not(target_arch = "wasm32"))]
#[error("resolution failed: {0}")]
ResolutionFailed(#[from] nym_http_api_client::ResolveError),
ResolutionFailed(#[from] nym_http_api_client::HickoryDnsError),
#[error("no gateways on network")]
NoGatewaysOnNetwork,
+1 -1
View File
@@ -441,7 +441,7 @@ mod tests {
#[test]
fn test_multiple_urls_prepared_for_retries() {
let urls = [
let urls = vec![
Url::parse("https://api1.nym.com").unwrap(),
Url::parse("https://api2.nym.com").unwrap(),
Url::parse("https://api3.nym.com").unwrap(),
@@ -30,6 +30,7 @@ pub(crate) async fn connect_async(
resolver
.resolve_str(domain)
.await?
.into_iter()
.map(|a| SocketAddr::new(a, port))
.collect()
}
@@ -39,6 +39,7 @@ pub(crate) async fn connect_async(
resolver
.resolve_str(domain)
.await?
.into_iter()
.map(|a| SocketAddr::new(a, port))
.collect()
}
@@ -54,7 +54,7 @@ pub enum GatewayClientError {
#[cfg(not(target_arch = "wasm32"))]
#[error("resolution failed: {0}")]
ResolutionFailed(#[from] nym_http_api_client::ResolveError),
ResolutionFailed(#[from] nym_http_api_client::HickoryDnsError),
#[error("No shared key was provided or obtained")]
NoSharedKeyAvailable,
@@ -27,6 +27,9 @@ pub struct Args {
#[clap(long)]
pub identity_key: String,
#[clap(long, help = "LP (Lewes Protocol) listener port (default: 41264)")]
pub lp_port: Option<u16>,
#[clap(long)]
pub profit_margin_percent: Option<u64>,
@@ -57,10 +60,13 @@ pub async fn bond_nymnode(args: Args, client: SigningClient) {
return;
}
let lp_address = args.lp_port.map(|port| format!("{}:{}", args.host, port));
let nymnode = nym_mixnet_contract_common::NymNode {
host: args.host,
custom_http_port: args.http_api_port,
identity_key: args.identity_key,
lp_address,
};
let coin = Coin::new(args.amount, denom);
@@ -25,6 +25,9 @@ pub struct Args {
#[clap(long)]
pub custom_http_api_port: Option<u16>,
#[clap(long, help = "LP (Lewes Protocol) listener port (default: 41264)")]
pub lp_port: Option<u16>,
#[clap(long)]
pub profit_margin_percent: Option<u64>,
@@ -47,10 +50,13 @@ pub struct Args {
pub async fn create_payload(args: Args, client: SigningClient) {
let denom = client.current_chain_details().mix_denom.base.as_str();
let lp_address = args.lp_port.map(|port| format!("{}:{}", args.host, port));
let mixnode = nym_mixnet_contract_common::NymNode {
host: args.host,
custom_http_port: args.custom_http_api_port,
identity_key: args.identity_key,
lp_address,
};
let coin = Coin::new(args.amount, denom);
@@ -19,6 +19,16 @@ pub struct Args {
// equivalent to setting `custom_http_port` to `None`
#[clap(long)]
pub restore_default_http_port: bool,
#[clap(
long,
help = "LP (Lewes Protocol) listener address (format: host:port)"
)]
pub lp_address: Option<String>,
// equivalent to setting `lp_address` to `None`
#[clap(long)]
pub restore_default_lp_address: bool,
}
pub async fn update_config(args: Args, client: SigningClient) {
@@ -39,6 +49,8 @@ pub async fn update_config(args: Args, client: SigningClient) {
host: args.host,
custom_http_port: args.custom_http_port,
restore_default_http_port: args.restore_default_http_port,
lp_address: args.lp_address,
restore_default_lp_address: args.restore_default_lp_address,
};
let res = client
@@ -241,28 +241,23 @@ impl Epoch {
//
// Note: It's important that the variant ordering is not changed otherwise it would mess up the derived `PartialOrd`
#[cw_serde]
#[derive(Copy, Default)]
#[derive(Copy)]
pub enum EpochState {
#[default]
WaitingInitialisation,
PublicKeySubmission {
resharing: bool,
},
DealingExchange {
resharing: bool,
},
VerificationKeySubmission {
resharing: bool,
},
VerificationKeyValidation {
resharing: bool,
},
VerificationKeyFinalization {
resharing: bool,
},
PublicKeySubmission { resharing: bool },
DealingExchange { resharing: bool },
VerificationKeySubmission { resharing: bool },
VerificationKeyValidation { resharing: bool },
VerificationKeyFinalization { resharing: bool },
InProgress,
}
impl Default for EpochState {
fn default() -> Self {
Self::WaitingInitialisation
}
}
impl Display for EpochState {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
@@ -373,6 +373,11 @@ pub struct NymNode {
/// Base58-encoded ed25519 EdDSA public key.
#[cfg_attr(feature = "utoipa", schema(value_type = String))]
pub identity_key: IdentityKey,
/// Optional LP (Lewes Protocol) listener address for direct gateway connections.
/// Format: "host:port", for example "1.1.1.1:41264" or "gateway.example.com:41264"
#[serde(default)]
pub lp_address: Option<String>,
// TODO: I don't think we want to include sphinx keys here,
// given we want to rotate them and keeping that in sync with contract will be a PITA
}
@@ -405,6 +410,7 @@ impl From<MixNode> for NymNode {
host: value.host,
custom_http_port: Some(value.http_api_port),
identity_key: value.identity_key,
lp_address: None,
}
}
}
@@ -415,6 +421,7 @@ impl From<Gateway> for NymNode {
host: value.host,
custom_http_port: None,
identity_key: value.identity_key,
lp_address: None,
}
}
}
@@ -437,6 +444,13 @@ pub struct NodeConfigUpdate {
// equivalent to setting `custom_http_port` to `None`
#[serde(default)]
pub restore_default_http_port: bool,
/// LP listener address for direct gateway connections (format: "host:port")
pub lp_address: Option<String>,
// equivalent to setting `lp_address` to `None`
#[serde(default)]
pub restore_default_lp_address: bool,
}
#[cw_serde]
+1 -1
View File
@@ -8,7 +8,7 @@ async fn main() -> anyhow::Result<()> {
use sqlx::{Connection, SqliteConnection};
use std::env;
let out_dir = env::var("OUT_DIR").context("missing OUT_DIR env variable")?;
let out_dir = env::var("OUT_DIR")?;
let database_path = format!("{out_dir}/nym-credential-proxy-example.sqlite");
// remove the db file if it already existed from previous build
-7
View File
@@ -127,13 +127,6 @@ pub enum CredentialProxyError {
#[error("failed to create deposit")]
DepositFailure,
#[error("failed to load jwt signing key from {path}: {err}")]
JWTSigningKeyLoadFailure {
path: String,
#[source]
err: std::io::Error,
},
#[error("can't obtain sufficient number of credential shares due to unavailable quorum")]
UnavailableSigningQuorum,
+1 -100
View File
@@ -27,8 +27,6 @@ pub struct QuorumStateChecker {
cancellation_token: CancellationToken,
check_interval: Duration,
quorum_state: QuorumState,
max_retries: u32,
retry_initial_delay: Duration,
}
impl QuorumStateChecker {
@@ -44,8 +42,6 @@ impl QuorumStateChecker {
quorum_state: QuorumState {
available: Arc::new(Default::default()),
},
max_retries: 3,
retry_initial_delay: Duration::from_secs(2),
};
// first check MUST succeed, otherwise we shouldn't start
@@ -60,102 +56,7 @@ impl QuorumStateChecker {
self.quorum_state.clone()
}
fn is_retryable_error(&self, err: &CredentialProxyError) -> bool {
let err_str = err.to_string().to_lowercase();
// Check for DNS-related errors
if err_str.contains("dns")
|| err_str.contains("lookup")
|| err_str.contains("name resolution")
|| err_str.contains("temporary failure")
|| err_str.contains("failed to lookup address")
{
return true;
}
// Check if it's a Tendermint RPC error (which could be DNS/timeout related)
if let CredentialProxyError::NyxdFailure { source: nyxd_err } = err {
let nyxd_err_str = nyxd_err.to_string().to_lowercase();
if nyxd_err_str.contains("tendermint rpc request failed") {
return true;
}
if nyxd_err.is_tendermint_response_timeout() {
return true;
}
}
false
}
async fn check_quorum_state(&self) -> Result<bool, CredentialProxyError> {
self.check_quorum_state_with_retry().await
}
async fn check_quorum_state_with_retry(&self) -> Result<bool, CredentialProxyError> {
let mut last_error_msg = None;
let delay = self.retry_initial_delay;
for attempt in 0..=self.max_retries {
match self.check_quorum_state_once().await {
Ok(result) => {
if attempt > 0 {
info!("quorum check succeeded after {} retry attempt(s)", attempt);
}
return Ok(result);
}
Err(err) => {
let err_msg = err.to_string();
// Check if this error is retryable
if !self.is_retryable_error(&err) {
return Err(err);
}
last_error_msg = Some(err_msg.clone());
if attempt >= self.max_retries {
break;
}
// Log the retry attempt
warn!(
"quorum check failed (attempt {}/{}): {}. Retrying in {:?}...",
attempt + 1,
self.max_retries + 1,
err_msg,
delay
);
// Wait before retrying with exponential backoff
tokio::time::sleep(delay).await;
}
}
}
// try one final time to get the actual error
match self.check_quorum_state_once().await {
Ok(result) => {
warn!(
"quorum check succeeded on final attempt after {} retries",
self.max_retries
);
Ok(result)
}
Err(err) => {
if let Some(error_msg) = last_error_msg {
error!(
"quorum check failed after {} retry attempts. Last error: {}",
self.max_retries + 1,
error_msg
);
}
Err(err)
}
}
}
async fn check_quorum_state_once(&self) -> Result<bool, CredentialProxyError> {
let client_guard = self.client.query_chain().await;
// split the operation as we only need to hold the reference to chain client for the first part
@@ -192,7 +93,7 @@ impl QuorumStateChecker {
break
}
_ = tokio::time::sleep(self.check_interval) => {
match self.check_quorum_state_with_retry().await {
match self.check_quorum_state().await {
Ok(available) => self.quorum_state.available.store(available, Ordering::SeqCst),
Err(err) => error!("failed to check current quorum state: {err}"),
}
@@ -7,9 +7,9 @@ use crate::ticketbook_manager::TicketbookManager;
use nym_compact_ecash::Base58;
use nym_credential_proxy_requests::api::v1::ticketbook::models::{
CurrentEpochResponse, DepositResponse, GlobalDataParams, MasterVerificationKeyResponse,
ObtainTicketBookSharesAsyncResponse, PartialVerificationKey, PartialVerificationKeysResponse,
TicketbookAsyncRequest, TicketbookObtainParams, TicketbookRequest,
TicketbookWalletSharesAsyncResponse, TicketbookWalletSharesResponse,
PartialVerificationKey, PartialVerificationKeysResponse, TicketbookAsyncRequest,
TicketbookObtainParams, TicketbookRequest, TicketbookWalletSharesAsyncResponse,
TicketbookWalletSharesResponse,
};
use time::OffsetDateTime;
use tracing::{Instrument, Level, error, info, span, warn};
@@ -65,7 +65,7 @@ impl TicketbookManager {
uuid: Uuid,
request: TicketbookAsyncRequest,
params: TicketbookObtainParams,
) -> Result<ObtainTicketBookSharesAsyncResponse, CredentialProxyError> {
) -> Result<TicketbookWalletSharesAsyncResponse, CredentialProxyError> {
let requested_on = OffsetDateTime::now_utc();
let span = span!(Level::INFO, "[async] obtain ticketboook", uuid = %uuid);
async move {
@@ -110,7 +110,7 @@ impl TicketbookManager {
}
// 4. in the meantime, return the id to the user
Ok(TicketbookWalletSharesAsyncResponse { id, uuid }.into())
Ok(TicketbookWalletSharesAsyncResponse { id, uuid })
}
.instrument(span)
.await
@@ -246,7 +246,7 @@ mod tests {
let _exp_date_sigs = generate_expiration_date_signatures(
sig_req.expiration_date.ecash_unix_timestamp(),
&[signing_keys.secret_key()],
&[signing_keys.verification_key()],
&vec![signing_keys.verification_key()],
&signing_keys.verification_key(),
&[1],
)?;
@@ -263,7 +263,7 @@ mod tests {
let wallet = issuance.aggregate_signature_shares(
&signing_keys.verification_key(),
&[partial_wallet],
&vec![partial_wallet],
sig_req,
)?;
@@ -30,5 +30,6 @@ nym-credentials-interface = { path = "../credentials-interface" }
nym-ecash-contract-common = { path = "../cosmwasm-smart-contracts/ecash-contract" }
nym-gateway-requests = { path = "../gateway-requests" }
nym-gateway-storage = { path = "../gateway-storage" }
nym-metrics = { path = "../nym-metrics" }
nym-task = { path = "../task" }
nym-validator-client = { path = "../client-libs/validator-client" }
@@ -59,9 +59,13 @@ impl traits::EcashManager for EcashManager {
.verify(aggregated_verification_key)
.map_err(|err| match err {
CompactEcashError::ExpirationDateSignatureValidity => {
nym_metrics::inc!("ecash_verification_failures_invalid_date_signature");
EcashTicketError::MalformedTicketInvalidDateSignatures
}
_ => EcashTicketError::MalformedTicket,
_ => {
nym_metrics::inc!("ecash_verification_failures_signature");
EcashTicketError::MalformedTicket
}
})?;
self.insert_pay_info(credential.pay_info.into(), insert_index)
@@ -222,9 +222,13 @@ impl SharedState {
RwLockReadGuard::try_map(guard, |data| data.get(&epoch_id).map(|d| &d.master_key))
{
trace!("we already had cached api clients for epoch {epoch_id}");
nym_metrics::inc!("ecash_verification_key_cache_hits");
return Ok(mapped);
}
// Cache miss - need to fetch and set epoch data
nym_metrics::inc!("ecash_verification_key_cache_misses");
let write_guard = self.set_epoch_data(epoch_id).await?;
let guard = write_guard.downgrade();
+35 -2
View File
@@ -8,6 +8,7 @@ use nym_credentials::ecash::utils::{EcashTime, cred_exp_date, ecash_today};
use nym_credentials_interface::{Bandwidth, ClientTicket, TicketType};
use nym_gateway_requests::models::CredentialSpendingRequest;
use std::sync::Arc;
use std::time::Instant;
use time::{Date, OffsetDateTime};
use tracing::*;
@@ -19,6 +20,11 @@ mod client_bandwidth;
pub mod ecash;
pub mod error;
// Histogram buckets for ecash verification duration (in seconds)
const ECASH_VERIFICATION_DURATION_BUCKETS: &[f64] = &[
0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 2.0, 5.0,
];
pub struct CredentialVerifier {
credential: CredentialSpendingRequest,
ecash_verifier: Arc<dyn EcashManager + Send + Sync>,
@@ -62,6 +68,7 @@ impl CredentialVerifier {
.await?;
if spent {
trace!("the credential has already been spent before at this gateway");
nym_metrics::inc!("ecash_verification_failures_double_spending");
return Err(Error::BandwidthCredentialAlreadySpent);
}
Ok(())
@@ -103,6 +110,9 @@ impl CredentialVerifier {
}
pub async fn verify(&mut self) -> Result<i64> {
let start = Instant::now();
nym_metrics::inc!("ecash_verification_attempts");
let received_at = OffsetDateTime::now_utc();
let spend_date = ecash_today();
@@ -111,15 +121,36 @@ impl CredentialVerifier {
let credential_type = TicketType::try_from_encoded(self.credential.data.payment.t_type)?;
if self.credential.data.payment.spend_value != 1 {
nym_metrics::inc!("ecash_verification_failures_multiple_tickets");
return Err(Error::MultipleTickets);
}
self.check_credential_spending_date(spend_date.ecash_date())?;
if let Err(e) = self.check_credential_spending_date(spend_date.ecash_date()) {
nym_metrics::inc!("ecash_verification_failures_invalid_spend_date");
return Err(e);
}
self.check_local_db_for_double_spending(&serial_number)
.await?;
// TODO: do we HAVE TO do it?
self.cryptographically_verify_ticket().await?;
let verify_result = self.cryptographically_verify_ticket().await;
// Track verification duration
let duration = start.elapsed().as_secs_f64();
nym_metrics::add_histogram_obs!(
"ecash_verification_duration_seconds",
duration,
ECASH_VERIFICATION_DURATION_BUCKETS
);
// Track epoch ID - use dynamic metric name via registry
let epoch_id = self.credential.data.epoch_id;
let epoch_metric = format!("nym_credential_verification_ecash_epoch_{}_verifications", epoch_id);
nym_metrics::metrics_registry().maybe_register_and_inc(&epoch_metric, None);
// Check verification result after timing
verify_result?;
let ticket_id = self.store_received_ticket(received_at).await?;
self.async_verify_ticket(ticket_id);
@@ -133,6 +164,8 @@ impl CredentialVerifier {
.increase_bandwidth(bandwidth, cred_exp_date())
.await?;
nym_metrics::inc!("ecash_verification_success");
Ok(self
.bandwidth_storage_manager
.client_bandwidth
+2 -5
View File
@@ -15,6 +15,7 @@ base64.workspace = true
bs58 = { workspace = true }
blake3 = { workspace = true, features = ["traits-preview"], optional = true }
ctr = { workspace = true, optional = true }
curve25519-dalek = { workspace = true, optional = true }
digest = { workspace = true, optional = true }
generic-array = { workspace = true, optional = true }
hkdf = { workspace = true, optional = true }
@@ -36,18 +37,14 @@ nym-sphinx-types = { path = "../nymsphinx/types", version = "0.2.0", default-fea
nym-pemstore = { path = "../../common/pemstore", version = "0.3.0" }
[dev-dependencies]
anyhow = { workspace = true }
rand_chacha = { workspace = true }
serde_json = { workspace = true }
nym-test-utils = { path = "../test-utils" }
[features]
default = []
aead = ["dep:aead", "aead/std", "aes-gcm-siv", "generic-array"]
naive_jwt = ["asymmetric", "jwt-simple"]
serde = ["dep:serde", "serde_bytes", "ed25519-dalek/serde", "x25519-dalek/serde"]
asymmetric = ["x25519-dalek", "ed25519-dalek", "zeroize"]
asymmetric = ["x25519-dalek", "ed25519-dalek", "curve25519-dalek", "sha2", "zeroize"]
hashing = ["blake3", "digest", "hkdf", "hmac", "generic-array", "sha2"]
stream_cipher = ["aes", "ctr", "cipher", "generic-array"]
sphinx = ["nym-sphinx-types/sphinx"]
@@ -213,6 +213,37 @@ impl PublicKey {
) -> Result<(), SignatureError> {
self.0.verify(message.as_ref(), &signature.0)
}
/// Converts this Ed25519 public key to an X25519 public key for ECDH.
///
/// Uses the standard ed25519→x25519 conversion by converting the Edwards point
/// to Montgomery form. This is the same approach as libsodium's
/// `crypto_sign_ed25519_pk_to_curve25519`.
///
/// # Returns
/// * `Ok(x25519::PublicKey)` - The converted X25519 public key
/// * `Err(Ed25519RecoveryError)` - If the conversion fails (e.g., low-order point)
pub fn to_x25519(&self) -> Result<crate::asymmetric::x25519::PublicKey, Ed25519RecoveryError> {
use curve25519_dalek::edwards::CompressedEdwardsY;
// Decompress the Ed25519 point
let compressed = CompressedEdwardsY((*self).to_bytes());
let edwards_point = compressed.decompress().ok_or_else(|| {
Ed25519RecoveryError::MalformedBytes(SignatureError::from_source(
"Failed to decompress Ed25519 point".to_string(),
))
})?;
// Convert to Montgomery form
let montgomery = edwards_point.to_montgomery();
// Create X25519 public key
crate::asymmetric::x25519::PublicKey::from_bytes(montgomery.as_bytes()).map_err(|_| {
Ed25519RecoveryError::MalformedBytes(SignatureError::from_source(
"Failed to convert to X25519".to_string(),
))
})
}
}
#[cfg(feature = "sphinx")]
@@ -334,6 +365,28 @@ impl PrivateKey {
let signature_bytes = self.sign(text).to_bytes();
bs58::encode(signature_bytes).into_string()
}
/// Converts this Ed25519 private key to an X25519 private key for ECDH.
///
/// Uses the standard ed25519→x25519 conversion via SHA-512 hash and clamping.
/// This is the same approach as libsodium's `crypto_sign_ed25519_sk_to_curve25519`.
///
/// # Returns
/// The converted X25519 private key
pub fn to_x25519(&self) -> crate::asymmetric::x25519::PrivateKey {
use sha2::{Digest, Sha512};
// Hash the Ed25519 secret key with SHA-512
let hash = Sha512::digest(self.0);
// Take first 32 bytes (clamping is done automatically by x25519_dalek::StaticSecret)
let mut x25519_bytes = [0u8; 32];
x25519_bytes.copy_from_slice(&hash[..32]);
#[allow(clippy::expect_used)]
crate::asymmetric::x25519::PrivateKey::from_bytes(&x25519_bytes)
.expect("x25519 key conversion should never fail")
}
}
#[cfg(feature = "serde")]
@@ -517,4 +570,27 @@ mod tests {
assert_eq!(sig1.to_vec(), sig2);
}
#[test]
#[cfg(feature = "rand")]
fn test_ed25519_to_x25519_ecdh() {
let mut rng = thread_rng();
// Create two ed25519 keypairs
let alice_ed = KeyPair::new(&mut rng);
let bob_ed = KeyPair::new(&mut rng);
// Convert to x25519
let alice_x25519_private = alice_ed.private_key().to_x25519();
let alice_x25519_public = alice_ed.public_key().to_x25519().unwrap();
let bob_x25519_private = bob_ed.private_key().to_x25519();
let bob_x25519_public = bob_ed.public_key().to_x25519().unwrap();
// Perform ECDH both ways
let alice_shared = alice_x25519_private.diffie_hellman(&bob_x25519_public);
let bob_shared = bob_x25519_private.diffie_hellman(&alice_x25519_public);
// Both should produce the same shared secret
assert_eq!(alice_shared, bob_shared);
}
}
@@ -17,51 +17,6 @@ pub mod bs58_ed25519_pubkey {
}
}
pub mod vec_bs58_ed25519_pubkey {
use super::*;
use serde::{Deserialize, Deserializer, Serializer, ser::SerializeSeq};
pub fn serialize<S: Serializer>(
keys: &Vec<PublicKey>,
serializer: S,
) -> Result<S::Ok, S::Error> {
let mut seq = serializer.serialize_seq(Some(keys.len()))?;
for key in keys {
seq.serialize_element(&Bs58KeyWrapper(*key))?;
}
seq.end()
}
pub fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<PublicKey>, D::Error> {
let wrapped = Vec::<Bs58KeyWrapper>::deserialize(deserializer)?;
Ok(wrapped.into_iter().map(|k| k.0).collect())
}
struct Bs58KeyWrapper(PublicKey);
impl serde::Serialize for Bs58KeyWrapper {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
bs58_ed25519_pubkey::serialize(&self.0, serializer)
}
}
impl<'de> Deserialize<'de> for Bs58KeyWrapper {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
Ok(Bs58KeyWrapper(bs58_ed25519_pubkey::deserialize(
deserializer,
)?))
}
}
}
pub mod bs58_ed25519_signature {
use crate::asymmetric::ed25519::Signature;
use serde::{Deserialize, Deserializer, Serializer};
@@ -78,53 +33,3 @@ pub mod bs58_ed25519_signature {
Signature::from_base58_string(s).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
use jwt_simple::reexports::{anyhow, serde_json};
use nym_test_utils::helpers::deterministic_rng;
use serde::{Deserialize, Serialize};
#[test]
fn vec_bs58_ed25519_pubkey_json() -> anyhow::Result<()> {
#[derive(Serialize, Deserialize, Debug, PartialEq)]
struct KeysWrapper(#[serde(with = "vec_bs58_ed25519_pubkey")] Vec<PublicKey>);
use crate::asymmetric::ed25519;
let mut rng = deterministic_rng();
let empty = KeysWrapper(vec![]);
let single_key = KeysWrapper(vec![PublicKey::from_base58_string(
"Be9wH7xuXBRJAuV1pC7MALZv6a61RvWQ3SypsNarqTt",
)?]);
let three_keys = KeysWrapper(vec![
ed25519::KeyPair::new(&mut rng).public_key,
ed25519::KeyPair::new(&mut rng).public_key,
ed25519::KeyPair::new(&mut rng).public_key,
]);
let se_empty = serde_json::to_string(&empty)?;
let se_single_key = serde_json::to_string(&single_key)?;
let se_three_keys = serde_json::to_string(&three_keys)?;
assert_eq!(se_empty, r#"[]"#);
assert_eq!(
se_single_key,
r#"["Be9wH7xuXBRJAuV1pC7MALZv6a61RvWQ3SypsNarqTt"]"#
);
assert_eq!(
se_three_keys,
r#"["HmgHDV79LpnEaSUp8QZQwSroxVvS4RewF7yM9e7qu8y3","311xRh859qCd5MVqoPRCoNx26eYhLknGwtjzkkTJFGhf","A5BMp8WJ6Uk91U4JpWRv2Bc6X35AaRaSEy8QEWeAkaBv"]"#
);
let empty_de = serde_json::from_str::<KeysWrapper>(&se_empty)?;
let single_key_de = serde_json::from_str::<KeysWrapper>(&se_single_key)?;
let three_keys_de = serde_json::from_str::<KeysWrapper>(&se_three_keys)?;
assert_eq!(empty, empty_de);
assert_eq!(single_key, single_key_de);
assert_eq!(three_keys, three_keys_de);
Ok(())
}
}
+98
View File
@@ -0,0 +1,98 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Key Derivation Functions using Blake3.
/// Derives a 32-byte key using Blake3's key derivation mode.
///
/// Uses Blake3's built-in `derive_key` function with domain separation via context string.
///
/// # Arguments
/// * `context` - Context string for domain separation (e.g., "nym-lp-psk-v1")
/// * `key_material` - Input key material (shared secret from ECDH, etc.)
/// * `salt` - Additional salt for freshness (timestamp + nonce)
///
/// # Returns
/// 32-byte derived key suitable for use as PSK
///
/// # Example
/// ```ignore
/// let psk = derive_key_blake3("nym-lp-psk-v1", shared_secret.as_bytes(), &salt);
/// ```
pub fn derive_key_blake3(context: &str, key_material: &[u8], salt: &[u8]) -> [u8; 32] {
// Concatenate key_material and salt as input
let input = [key_material, salt].concat();
// Use Blake3's derive_key with context for domain separation
// blake3::derive_key returns [u8; 32] directly
blake3::derive_key(context, &input)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deterministic_derivation() {
let context = "test-context";
let key_material = b"shared_secret_12345";
let salt = b"salt_67890";
let key1 = derive_key_blake3(context, key_material, salt);
let key2 = derive_key_blake3(context, key_material, salt);
assert_eq!(key1, key2, "Same inputs should produce same output");
}
#[test]
fn test_different_contexts_produce_different_keys() {
let key_material = b"shared_secret";
let salt = b"salt";
let key1 = derive_key_blake3("context1", key_material, salt);
let key2 = derive_key_blake3("context2", key_material, salt);
assert_ne!(
key1, key2,
"Different contexts should produce different keys"
);
}
#[test]
fn test_different_salts_produce_different_keys() {
let context = "test-context";
let key_material = b"shared_secret";
let key1 = derive_key_blake3(context, key_material, b"salt1");
let key2 = derive_key_blake3(context, key_material, b"salt2");
assert_ne!(key1, key2, "Different salts should produce different keys");
}
#[test]
fn test_different_key_material_produces_different_keys() {
let context = "test-context";
let salt = b"salt";
let key1 = derive_key_blake3(context, b"secret1", salt);
let key2 = derive_key_blake3(context, b"secret2", salt);
assert_ne!(
key1, key2,
"Different key material should produce different keys"
);
}
#[test]
fn test_output_length() {
let key = derive_key_blake3("test", b"key", b"salt");
assert_eq!(key.len(), 32, "Output should be exactly 32 bytes");
}
#[test]
fn test_empty_inputs() {
// Should not panic with empty inputs
let key = derive_key_blake3("test", b"", b"");
assert_eq!(key.len(), 32);
}
}
+2
View File
@@ -10,6 +10,8 @@ pub mod crypto_hash;
pub mod hkdf;
#[cfg(feature = "hashing")]
pub mod hmac;
#[cfg(feature = "hashing")]
pub mod kdf;
#[cfg(all(feature = "asymmetric", feature = "hashing", feature = "stream_cipher"))]
pub mod shared_key;
pub mod symmetric;
+3 -3
View File
@@ -82,7 +82,7 @@ mod tests {
let exp_date_sigs = generate_expiration_date_signatures(
sig_req.expiration_date.ecash_unix_timestamp(),
&[keypair.secret_key()],
&[keypair.verification_key()],
&vec![keypair.verification_key()],
&keypair.verification_key(),
&[keypair.index.unwrap()],
)
@@ -106,14 +106,14 @@ mod tests {
.unwrap();
let wallet = issuance
.aggregate_signature_shares(&keypair.verification_key(), &[partial_wallet], sig_req)
.aggregate_signature_shares(&keypair.verification_key(), &vec![partial_wallet], sig_req)
.unwrap();
let mut issued = issuance.into_issued_ticketbook(wallet, 1);
let coin_indices_signatures = generate_coin_indices_signatures(
nym_credentials_interface::ecash_parameters(),
&[keypair.secret_key()],
&[keypair.verification_key()],
&vec![keypair.verification_key()],
&keypair.verification_key(),
&[keypair.index.unwrap()],
)
+3 -2
View File
@@ -32,7 +32,7 @@ thiserror = { workspace = true }
tracing = { workspace = true }
itertools = { workspace = true }
inventory = { workspace = true }
tokio = { workspace = true, features = ["rt", "macros", "time"] }
# used for decoding text responses (they were already implicitly included)
bytes = { workspace = true }
encoding_rs = { workspace = true }
@@ -52,4 +52,5 @@ workspace = true
features = ["tokio"]
[dev-dependencies]
tracing-subscriber.workspace = true
tokio = { workspace = true, features = ["rt", "macros"] }
+92 -312
View File
@@ -30,26 +30,19 @@
use crate::ClientBuilder;
use std::{
collections::HashMap,
net::{IpAddr, SocketAddr},
str::FromStr,
net::SocketAddr,
sync::{Arc, LazyLock},
time::Duration,
};
use hickory_resolver::{
TokioResolver,
config::{LookupIpStrategy, NameServerConfigGroup, ResolverConfig},
lookup_ip::LookupIpIntoIter,
ResolveError, TokioResolver,
config::{LookupIpStrategy, NameServerConfigGroup, ResolverConfig, ServerOrderingStrategy},
lookup_ip::{LookupIp, LookupIpIntoIter},
name_server::TokioConnectionProvider,
};
use once_cell::sync::OnceCell;
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
use tracing::*;
mod constants;
mod static_resolver;
pub use static_resolver::*;
use tracing::warn;
impl ClientBuilder {
/// Override the DNS resolver implementation used by the underlying http client.
@@ -66,6 +59,10 @@ impl ClientBuilder {
}
}
struct SocketAddrs {
iter: LookupIpIntoIter,
}
// n.b. static items do not call [`Drop`] on program termination, so this won't be deallocated.
// this is fine, as the OS can deallocate the terminated program faster than we can free memory
// but tools like valgrind might report "memory leaks" as it isn't obvious this is intentional.
@@ -75,17 +72,11 @@ static SHARED_RESOLVER: LazyLock<HickoryDnsResolver> = LazyLock::new(|| {
});
#[derive(Debug, thiserror::Error)]
#[allow(missing_docs)]
#[error("hickory-dns resolver error: {hickory_error}")]
/// Error occurring while resolving a hostname into an IP address.
pub enum ResolveError {
#[error("invalid name: {0}")]
InvalidNameError(String),
#[error("hickory-dns resolver error: {0}")]
ResolveError(#[from] hickory_resolver::ResolveError),
#[error("high level lookup timed out")]
Timeout,
#[error("hostname not found in static lookup table")]
StaticLookupMiss,
pub struct HickoryDnsError {
#[from]
hickory_error: ResolveError,
}
/// Wrapper around an `AsyncResolver`, which implements the `Resolve` trait.
@@ -96,118 +87,71 @@ pub enum ResolveError {
/// The default initialization uses a shared underlying `AsyncResolver`. If a thread local resolver
/// is required use `thread_resolver()` to build a resolver with an independently instantiated
/// internal `AsyncResolver`.
#[derive(Debug, Clone)]
#[derive(Debug, Default, Clone)]
pub struct HickoryDnsResolver {
// Since we might not have been called in the context of a
// Tokio Runtime in initialization, so we must delay the actual
// construction of the resolver.
state: Arc<OnceCell<TokioResolver>>,
fallback: Option<Arc<OnceCell<TokioResolver>>>,
static_base: Option<Arc<OnceCell<StaticResolver>>>,
dont_use_shared: bool,
/// Overall timeout for dns lookup associated with any individual host resolution. For example,
/// use of retries, server_ordering_strategy, etc. ends absolutely if this timeout is reached.
overall_dns_timeout: Duration,
}
impl Default for HickoryDnsResolver {
fn default() -> Self {
Self {
state: Default::default(),
fallback: Default::default(),
static_base: Default::default(),
dont_use_shared: Default::default(),
overall_dns_timeout: Duration::from_secs(10),
}
}
}
impl Resolve for HickoryDnsResolver {
fn resolve(&self, name: Name) -> Resolving {
let resolver = self.state.clone();
let maybe_fallback = self.fallback.clone();
let maybe_static = self.static_base.clone();
let independent = self.dont_use_shared;
let overall_dns_timeout = self.overall_dns_timeout;
Box::pin(async move {
resolve(
name,
resolver,
maybe_fallback,
maybe_static,
independent,
overall_dns_timeout,
)
.await
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
let resolver = resolver.get_or_try_init(|| {
// using a closure here is slightly gross, but this makes sure that if the
// lazy-init returns an error it can be handled by the client
if independent {
new_resolver()
} else {
Ok(SHARED_RESOLVER.state.get_or_try_init(new_resolver)?.clone())
}
})?;
// try the primary DNS resolver that we set up (DoH or DoT or whatever)
let lookup = match resolver.lookup_ip(name.as_str()).await {
Ok(res) => res,
Err(e) => {
if let Some(ref fallback) = maybe_fallback {
// on failure use the fall back system configured DNS resolver
if !e.is_no_records_found() {
warn!("primary DNS failed w/ error {e}: using system fallback");
}
let resolver = fallback.get_or_try_init(|| {
// using a closure here is slightly gross, but this makes sure that if the
// lazy-init returns an error it can be handled by the client
if independent {
new_resolver_system()
} else {
Ok(SHARED_RESOLVER
.fallback
.as_ref()
.ok_or(e)? // if the shared resolver has no fallback return the original error
.get_or_try_init(new_resolver_system)?
.clone())
}
})?;
resolver.lookup_ip(name.as_str()).await?
} else {
return Err(e.into());
}
}
};
let addrs: Addrs = Box::new(SocketAddrs {
iter: lookup.into_iter(),
});
Ok(addrs)
})
}
}
async fn resolve(
name: Name,
resolver: Arc<OnceCell<TokioResolver>>,
maybe_fallback: Option<Arc<OnceCell<TokioResolver>>>,
maybe_static: Option<Arc<OnceCell<StaticResolver>>>,
independent: bool,
overall_dns_timeout: Duration,
) -> Result<Addrs, ResolveError> {
let resolver = resolver.get_or_try_init(|| HickoryDnsResolver::new_resolver(independent))?;
// Attempt a lookup using the primary resolver
let resolve_fut = tokio::time::timeout(overall_dns_timeout, resolver.lookup_ip(name.as_str()));
let primary_err = match resolve_fut.await {
Err(_) => ResolveError::Timeout,
Ok(Ok(lookup)) => {
let addrs: Addrs = Box::new(SocketAddrs {
iter: lookup.into_iter(),
});
return Ok(addrs);
}
Ok(Err(e)) => {
// on failure use the fall back system configured DNS resolver
if !e.is_no_records_found() {
warn!("primary DNS failed w/ error: {e}");
}
e.into()
}
};
// If the primary resolver encountered an error, attempt a lookup using the fallback
// resolver if one is configured.
if let Some(ref fallback) = maybe_fallback {
let resolver =
fallback.get_or_try_init(|| HickoryDnsResolver::new_resolver_system(independent))?;
let resolve_fut =
tokio::time::timeout(overall_dns_timeout, resolver.lookup_ip(name.as_str()));
if let Ok(Ok(lookup)) = resolve_fut.await {
let addrs: Addrs = Box::new(SocketAddrs {
iter: lookup.into_iter(),
});
return Ok(addrs);
}
}
// If no record has been found and a static map of fallback addresses is configured
// check the table for our entry
if let Some(ref static_resolver) = maybe_static {
debug!("checking static");
let resolver =
static_resolver.get_or_init(|| HickoryDnsResolver::new_static_fallback(independent));
if let Ok(addrs) = resolver.resolve(name).await {
return Ok(addrs);
}
}
Err(primary_err)
}
struct SocketAddrs {
iter: LookupIpIntoIter,
}
impl Iterator for SocketAddrs {
type Item = SocketAddr;
@@ -218,22 +162,28 @@ impl Iterator for SocketAddrs {
impl HickoryDnsResolver {
/// Attempt to resolve a domain name to a set of ['IpAddr']s
pub async fn resolve_str(
&self,
name: &str,
) -> Result<impl Iterator<Item = IpAddr> + use<>, ResolveError> {
let n =
Name::from_str(name).map_err(|_| ResolveError::InvalidNameError(name.to_string()))?;
resolve(
n,
self.state.clone(),
self.fallback.clone(),
self.static_base.clone(),
self.dont_use_shared,
self.overall_dns_timeout,
)
.await
.map(|addrs| addrs.map(|socket_addr| socket_addr.ip()))
pub async fn resolve_str(&self, name: &str) -> Result<LookupIp, HickoryDnsError> {
let resolver = self.state.get_or_try_init(|| self.new_resolver())?;
// try the primary DNS resolver that we set up (DoH or DoT or whatever)
let lookup = match resolver.lookup_ip(name).await {
Ok(res) => res,
Err(e) => {
if let Some(ref fallback) = self.fallback {
// on failure use the fall back system configured DNS resolver
if !e.is_no_records_found() {
warn!("primary DNS failed w/ error {e}: using system fallback");
}
let resolver = fallback.get_or_try_init(|| self.new_resolver_system())?;
resolver.lookup_ip(name).await?
} else {
return Err(e.into());
}
}
};
Ok(lookup)
}
/// Create a (lazy-initialized) resolver that is not shared across threads.
@@ -244,20 +194,16 @@ impl HickoryDnsResolver {
}
}
fn new_resolver(dont_use_shared: bool) -> Result<TokioResolver, ResolveError> {
// using a closure here is slightly gross, but this makes sure that if the
// lazy-init returns an error it can be handled by the client
if dont_use_shared {
fn new_resolver(&self) -> Result<TokioResolver, HickoryDnsError> {
if self.dont_use_shared {
new_resolver()
} else {
Ok(SHARED_RESOLVER.state.get_or_try_init(new_resolver)?.clone())
}
}
fn new_resolver_system(dont_use_shared: bool) -> Result<TokioResolver, ResolveError> {
// using a closure here is slightly gross, but this makes sure that if the
// lazy-init returns an error it can be handled by the client
if dont_use_shared || SHARED_RESOLVER.fallback.is_none() {
fn new_resolver_system(&self) -> Result<TokioResolver, HickoryDnsError> {
if self.dont_use_shared || SHARED_RESOLVER.fallback.is_none() {
new_resolver_system()
} else {
Ok(SHARED_RESOLVER
@@ -269,18 +215,8 @@ impl HickoryDnsResolver {
}
}
fn new_static_fallback(dont_use_shared: bool) -> StaticResolver {
if !dont_use_shared && let Some(ref shared_resolver) = SHARED_RESOLVER.static_base {
shared_resolver
.get_or_init(new_default_static_fallback)
.clone()
} else {
new_default_static_fallback()
}
}
/// Enable fallback to the system default resolver if the primary (DoX) resolver fails
pub fn enable_system_fallback(&mut self) -> Result<(), ResolveError> {
pub fn enable_system_fallback(&mut self) -> Result<(), HickoryDnsError> {
self.fallback = Some(Default::default());
let _ = self
.fallback
@@ -295,51 +231,22 @@ impl HickoryDnsResolver {
pub fn disable_system_fallback(&mut self) {
self.fallback = None;
}
/// Get the current map of hostname to address in use by the fallback static lookup if one
/// exists.
pub fn get_static_fallbacks(&self) -> Option<HashMap<String, Vec<IpAddr>>> {
Some(self.static_base.as_ref()?.get()?.get_addrs())
}
/// Set (or overwrite) the map of addresses used in the fallback static hostname lookup
pub fn set_static_fallbacks(&mut self, addrs: HashMap<String, Vec<IpAddr>>) {
let cell = OnceCell::new();
cell.set(StaticResolver::new(addrs))
.expect("infallible assign");
self.static_base = Some(Arc::new(cell));
}
}
/// Create a new resolver with a custom DoT based configuration. The options are overridden to look
/// up for both IPv4 and IPv6 addresses to work with "happy eyeballs" algorithm.
///
/// Timeout Defaults to 5 seconds
/// Number of retries after lookup failure before giving up Defaults to 2
///
/// Caches successfully resolved addresses for 30 minutes to prevent continual use of remote lookup.
/// This resolver is intended to be used for OUR API endpoints that do not rapidly rotate IPs.
fn new_resolver() -> Result<TokioResolver, ResolveError> {
info!("building new configured resolver");
fn new_resolver() -> Result<TokioResolver, HickoryDnsError> {
let mut name_servers = NameServerConfigGroup::quad9_tls();
name_servers.merge(NameServerConfigGroup::quad9_https());
name_servers.merge(NameServerConfigGroup::cloudflare_tls());
name_servers.merge(NameServerConfigGroup::cloudflare_https());
configure_and_build_resolver(name_servers)
}
fn configure_and_build_resolver(
name_servers: NameServerConfigGroup,
) -> Result<TokioResolver, ResolveError> {
let config = ResolverConfig::from_parts(None, Vec::new(), name_servers);
let mut resolver_builder =
TokioResolver::builder_with_config(config, TokioConnectionProvider::default());
resolver_builder.options_mut().ip_strategy = LookupIpStrategy::Ipv4AndIpv6;
// Cache successful responses for queries received by this resolver for 30 min minimum.
resolver_builder.options_mut().positive_min_ttl = Some(Duration::from_secs(1800));
resolver_builder.options_mut().server_ordering_strategy = ServerOrderingStrategy::RoundRobin;
Ok(resolver_builder.build())
}
@@ -347,27 +254,20 @@ fn configure_and_build_resolver(
/// Create a new resolver with the default configuration, which reads from the system DNS config
/// (i.e. `/etc/resolve.conf` in unix). The options are overridden to look up for both IPv4 and IPv6
/// addresses to work with "happy eyeballs" algorithm.
fn new_resolver_system() -> Result<TokioResolver, ResolveError> {
fn new_resolver_system() -> Result<TokioResolver, HickoryDnsError> {
let mut resolver_builder = TokioResolver::builder_tokio()?;
resolver_builder.options_mut().ip_strategy = LookupIpStrategy::Ipv4AndIpv6;
Ok(resolver_builder.build())
}
fn new_default_static_fallback() -> StaticResolver {
StaticResolver::new(constants::default_static_addrs())
}
#[cfg(test)]
mod test {
use super::*;
use itertools::Itertools;
use std::collections::HashMap;
#[tokio::test]
async fn reqwest_with_custom_dns() {
let var_name = HickoryDnsResolver::default();
let resolver = var_name;
async fn reqwest_hickory_doh() {
let resolver = HickoryDnsResolver::default();
let client = reqwest::ClientBuilder::new()
.dns_resolver(resolver.into())
.build()
@@ -386,7 +286,7 @@ mod test {
}
#[tokio::test]
async fn dns_lookup() -> Result<(), ResolveError> {
async fn dns_lookup() -> Result<(), HickoryDnsError> {
let resolver = HickoryDnsResolver::default();
let domain = "ifconfig.me";
@@ -396,124 +296,4 @@ mod test {
Ok(())
}
#[tokio::test]
async fn static_resolver_as_fallback() -> Result<(), ResolveError> {
let example_domain = "non-existent.nymvpn.com";
let mut resolver = HickoryDnsResolver {
..Default::default()
};
let result = resolver.resolve_str(example_domain).await;
assert!(result.is_err()); // should be NXDomain
resolver.static_base = Some(Default::default());
let mut addr_map = HashMap::new();
let example_ip4: IpAddr = "10.10.10.10".parse().unwrap();
let example_ip6: IpAddr = "dead::beef".parse().unwrap();
addr_map.insert(example_domain.to_string(), vec![example_ip4, example_ip6]);
resolver.set_static_fallbacks(addr_map);
let mut addrs = resolver.resolve_str(example_domain).await?;
assert!(addrs.contains(&example_ip4));
assert!(addrs.contains(&example_ip6));
Ok(())
}
}
#[cfg(test)]
mod failure_test {
use super::*;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
/// IP addresses guaranteed to fail attempts to resolve
///
/// Addresses drawn from blocks set off by RFC5737 (ipv4) and RFC3849 (ipv6)
const GUARANTEED_BROKEN_IPS_1: &[IpAddr] = &[
IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)),
IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)),
IpAddr::V6(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 0x1111)),
IpAddr::V6(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 0x1001)),
];
// Create a resolver that behaves the same as the custom configured router, except for the fact
// that it is guaranteed to fail.
fn build_broken_resolver() -> Result<TokioResolver, ResolveError> {
info!("building new faulty resolver");
let mut broken_ns_group = NameServerConfigGroup::from_ips_tls(
GUARANTEED_BROKEN_IPS_1,
853,
"cloudflare-dns.com".to_string(),
true,
);
let broken_ns_https = NameServerConfigGroup::from_ips_https(
GUARANTEED_BROKEN_IPS_1,
443,
"cloudflare-dns.com".to_string(),
true,
);
broken_ns_group.merge(broken_ns_https);
configure_and_build_resolver(broken_ns_group)
}
#[tokio::test]
async fn dns_lookup_failures() -> Result<(), ResolveError> {
let time_start = std::time::Instant::now();
let r = OnceCell::new();
r.set(build_broken_resolver().expect("failed to build resolver"))
.expect("broken resolver init error");
// create a new resolver that won't mess with the shared resolver used by other tests
let resolver = HickoryDnsResolver {
dont_use_shared: true,
state: Arc::new(r),
overall_dns_timeout: Duration::from_secs(5),
..Default::default()
};
build_broken_resolver()?;
let domain = "ifconfig.me";
let result = resolver.resolve_str(domain).await;
assert!(result.is_err_and(|e| matches!(e, ResolveError::Timeout)));
let duration = time_start.elapsed();
assert!(duration < resolver.overall_dns_timeout + Duration::from_secs(1));
Ok(())
}
#[tokio::test]
async fn fallback_to_static() -> Result<(), ResolveError> {
let r = OnceCell::new();
r.set(build_broken_resolver().expect("failed to build resolver"))
.expect("broken resolver init error");
// create a new resolver that won't mess with the shared resolver used by other tests
let resolver = HickoryDnsResolver {
dont_use_shared: true,
state: Arc::new(r),
static_base: Some(Default::default()),
overall_dns_timeout: Duration::from_secs(5),
..Default::default()
};
build_broken_resolver()?;
// successful lookup using fallback to static resolver
let domain = "nymvpn.com";
let _ = resolver
.resolve_str(domain)
.await
.expect("failed to resolve address in static lookup");
// unsuccessful lookup - primary times out, and not in
let domain = "non-existent.nymtech.net";
let result = resolver.resolve_str(domain).await;
assert!(result.is_err_and(|e| matches!(e, ResolveError::Timeout)));
Ok(())
}
}
@@ -1,95 +0,0 @@
#![allow(missing_docs)]
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
pub const NYM_API_DOMAIN: &str = "validator.nymtech.net";
pub const NYM_API_IPS: &[IpAddr] = &[IpAddr::V4(Ipv4Addr::new(212, 71, 233, 232))];
pub const NYM_VPN_API_DOMAIN: &str = "nymvpn.com";
pub const NYM_VPN_API_IPS: &[IpAddr] = &[IpAddr::V4(Ipv4Addr::new(76, 76, 21, 21))];
pub const NYM_FRONTDOOR_VERCEL_DOMAIN: &str = "nym-frontdoor.vercel.app";
pub const NYM_FRONTDOOR_VERCEL_IPS: &[IpAddr] = &[
IpAddr::V4(Ipv4Addr::new(64, 29, 17, 195)),
IpAddr::V4(Ipv4Addr::new(216, 198, 79, 195)),
];
pub const NYM_FRONTDOOR_FASTLY_DOMAIN: &str = "nym-frontdoor.global.ssl.fastly.net";
pub const NYM_FRONTDOOR_FASTLY_IPS: &[IpAddr] = &[
IpAddr::V4(Ipv4Addr::new(151, 101, 193, 194)),
IpAddr::V4(Ipv4Addr::new(151, 101, 129, 194)),
IpAddr::V4(Ipv4Addr::new(151, 101, 1, 194)),
IpAddr::V4(Ipv4Addr::new(151, 101, 65, 194)),
];
pub const NYMVPN_FRONTDOOR_FASTLY_DOMAIN: &str = "nymvpn-frontdoor.global.ssl.fastly.net";
pub const NYMVPN_FRONTDOOR_FASTLY_IPS: &[IpAddr] = &[
IpAddr::V4(Ipv4Addr::new(151, 101, 193, 194)),
IpAddr::V4(Ipv4Addr::new(151, 101, 129, 194)),
IpAddr::V4(Ipv4Addr::new(151, 101, 1, 194)),
IpAddr::V4(Ipv4Addr::new(151, 101, 65, 194)),
];
pub const YELP_FASTLY_DOMAIN: &str = "yelp.global.ssl.fastly.net";
pub const YELP_FASTLY_IPS: &[IpAddr] = &[
IpAddr::V4(Ipv4Addr::new(151, 101, 193, 194)),
IpAddr::V4(Ipv4Addr::new(151, 101, 129, 194)),
IpAddr::V4(Ipv4Addr::new(151, 101, 1, 194)),
IpAddr::V4(Ipv4Addr::new(151, 101, 65, 194)),
];
pub const VERCEL_APP_DOMAIN: &str = "vercel.app";
pub const VERCEL_APP_IPS: &[IpAddr] = &[
IpAddr::V4(Ipv4Addr::new(64, 29, 17, 195)),
IpAddr::V4(Ipv4Addr::new(216, 198, 79, 195)),
];
pub const VERCEL_COM_DOMAIN: &str = "vercel.com";
pub const VERCEL_COM_IPS: &[IpAddr] = &[
IpAddr::V4(Ipv4Addr::new(198, 169, 2, 129)),
IpAddr::V4(Ipv4Addr::new(198, 169, 1, 193)),
];
pub const NYM_COM_DOMAIN: &str = "nym.com";
pub const NYM_COM_IPS: &[IpAddr] = &[IpAddr::V4(Ipv4Addr::new(76, 76, 21, 22))];
pub const NYM_STATS_API_DOMAIN: &str = "nym-statistics-api.nymtech.cc";
pub const NYM_STATS_API_IPS: &[IpAddr] = &[IpAddr::V4(Ipv4Addr::new(91, 92, 153, 96))];
pub const NYM_RPC_DOMAIN: &str = "rpc.nymtech.net";
pub const NYM_RPC_IPS: &[IpAddr] = &[
IpAddr::V4(Ipv4Addr::new(194, 182, 169, 49)),
IpAddr::V4(Ipv4Addr::new(91, 92, 200, 116)),
IpAddr::V6(Ipv6Addr::new(
0x2a04, 0xc43, 0xe00, 0x6f28, 0x400, 0xd8ff, 0xfe00, 0x1483,
)),
IpAddr::V6(Ipv6Addr::new(
0x2a04, 0xc46, 0xe00, 0x6f28, 0x4b3, 0x68ff, 0xfe00, 0x460,
)),
];
pub fn default_static_addrs() -> HashMap<String, Vec<IpAddr>> {
let mut m = HashMap::new();
m.insert(NYM_API_DOMAIN.to_string(), NYM_API_IPS.to_vec());
m.insert(NYM_VPN_API_DOMAIN.to_string(), NYM_VPN_API_IPS.to_vec());
m.insert(
NYM_FRONTDOOR_VERCEL_DOMAIN.to_string(),
NYM_FRONTDOOR_VERCEL_IPS.to_vec(),
);
m.insert(
NYM_FRONTDOOR_FASTLY_DOMAIN.to_string(),
NYM_FRONTDOOR_FASTLY_IPS.to_vec(),
);
m.insert(
NYMVPN_FRONTDOOR_FASTLY_DOMAIN.to_string(),
NYMVPN_FRONTDOOR_FASTLY_IPS.to_vec(),
);
m.insert(YELP_FASTLY_DOMAIN.to_string(), YELP_FASTLY_IPS.to_vec());
m.insert(VERCEL_APP_DOMAIN.to_string(), VERCEL_APP_IPS.to_vec());
m.insert(VERCEL_COM_DOMAIN.to_string(), VERCEL_COM_IPS.to_vec());
m.insert(NYM_COM_DOMAIN.to_string(), NYM_COM_IPS.to_vec());
m.insert(NYM_STATS_API_DOMAIN.to_string(), NYM_STATS_API_IPS.to_vec());
m.insert(NYM_RPC_DOMAIN.to_string(), NYM_RPC_IPS.to_vec());
m
}
@@ -1,89 +0,0 @@
use crate::dns::ResolveError;
use std::{
collections::HashMap,
net::{IpAddr, SocketAddr},
sync::{Arc, Mutex},
};
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
use tracing::*;
#[derive(Debug, Default, Clone)]
pub struct StaticResolver {
static_addr_map: Arc<Mutex<HashMap<String, Vec<IpAddr>>>>,
}
impl StaticResolver {
pub fn new(static_entries: HashMap<String, Vec<IpAddr>>) -> StaticResolver {
debug!("building static resolver");
Self {
static_addr_map: Arc::new(Mutex::new(static_entries)),
}
}
pub fn get_addrs(&self) -> HashMap<String, Vec<IpAddr>> {
self.static_addr_map.lock().unwrap().clone()
}
}
impl Resolve for StaticResolver {
fn resolve(&self, name: Name) -> Resolving {
debug!("looking up {name:?} in static resolver");
let addr_map = self.static_addr_map.clone();
Box::pin(async move {
let addr_map = addr_map.lock().unwrap();
let lookup = match addr_map.get(name.as_str()) {
None => return Err(ResolveError::StaticLookupMiss.into()),
Some(addrs) => addrs,
};
let addrs: Addrs = Box::new(
lookup
.clone()
.into_iter()
.map(|ip_addr| SocketAddr::new(ip_addr, 0)),
);
Ok(addrs)
})
}
}
#[cfg(test)]
mod test {
use itertools::Itertools;
use super::*;
use std::error::Error as StdError;
use std::str::FromStr;
#[tokio::test]
async fn lookup_using_static_resolver() -> Result<(), Box<dyn StdError + Send + Sync>> {
let example_domain = String::from("static.nymvpn.com");
// lookup for domain for which there is no entry
let resolver = StaticResolver::new(HashMap::new());
let url = reqwest::dns::Name::from_str(&example_domain).unwrap();
let result = resolver.resolve(url).await;
assert!(result.is_err());
match result {
Ok(_) => panic!("lookup with empty map should fail"),
Err(e) => assert_eq!(e.to_string(), ResolveError::StaticLookupMiss.to_string()),
}
// Successful lookup
let mut addr_map = HashMap::new();
let example_ip4: IpAddr = "10.10.10.10".parse().unwrap();
let example_ip6: IpAddr = "dead::beef".parse().unwrap();
addr_map.insert(example_domain.clone(), vec![example_ip4, example_ip6]);
let url = reqwest::dns::Name::from_str(&example_domain).unwrap();
let resolver = StaticResolver::new(addr_map);
let mut addrs = resolver.resolve(url).await?;
assert!(addrs.contains(&SocketAddr::new(example_ip4, 0)));
assert!(addrs.contains(&SocketAddr::new(example_ip6, 0)));
Ok(())
}
}
+1 -3
View File
@@ -179,7 +179,7 @@ mod dns;
mod path;
#[cfg(not(target_arch = "wasm32"))]
pub use dns::{HickoryDnsResolver, ResolveError};
pub use dns::{HickoryDnsError, HickoryDnsResolver};
// helper for generating user agent based on binary information
#[cfg(not(target_arch = "wasm32"))]
@@ -902,8 +902,6 @@ impl Client {
if self.base_urls.len() > 1 {
let orig = self.current_idx.load(Ordering::Relaxed);
#[allow(unused_mut)]
let mut next = (orig + 1) % self.base_urls.len();
// if fronting is enabled we want to update to a host that has fronts configured
+81
View File
@@ -0,0 +1,81 @@
# CLAUDE.md - nym-kcp
KCP (Fast and Reliable ARQ Protocol) implementation providing reliability over UDP for the Nym network. This crate ensures ordered, reliable delivery of packets.
## Architecture Overview
### Core Components
**KcpDriver** (src/driver.rs)
- High-level interface for KCP operations
- Manages single KCP session and I/O buffer
- Handles packet encoding/decoding
**KcpSession** (src/session.rs)
- Core KCP state machine
- Manages send/receive windows, RTT, congestion control
- Implements ARQ (Automatic Repeat Request) logic
**KcpPacket** (src/packet.rs)
- Wire format: conv(4B) | cmd(1B) | frg(1B) | wnd(2B) | ts(4B) | sn(4B) | una(4B) | len(4B) | data
- Commands: PSH (data), ACK, WND (window probe), ERR
## Key Concepts
### Conversation ID (conv)
- Unique identifier for each KCP connection
- Generated from hash of destination in nym-lp-node
- Must match on both ends for successful communication
### Packet Flow
1. **Send Path**: `send()` → Queue in send buffer → `fetch_outgoing()` → Wire
2. **Receive Path**: Wire → `input()` → Process ACKs/data → Application buffer
3. **Update Loop**: Call `update()` regularly to handle timeouts/retransmissions
### Reliability Mechanisms
- **Sequence Numbers (sn)**: Track packet ordering
- **Fragment Numbers (frg)**: Handle message fragmentation
- **UNA (Unacknowledged)**: Cumulative ACK up to this sequence
- **Selective ACK**: Via individual ACK packets
- **Fast Retransmit**: Triggered by duplicate ACKs
- **RTO Calculation**: Smoothed RTT with variance
## Configuration Parameters
```rust
// In KcpSession
MSS: 1400 // Maximum segment size
WINDOW_SIZE: 128 // Send/receive window
RTO_MIN: 100ms // Minimum retransmission timeout
RTO_MAX: 60000ms // Maximum retransmission timeout
FAST_RESEND: 2 // Fast retransmit threshold
```
## Common Operations
### Processing Incoming Data
```rust
driver.input(data)?; // Decode and process packets
let packets = driver.fetch_outgoing(); // Get any response packets
```
### Sending Data
```rust
driver.send(&data); // Queue for sending
driver.update(current_time); // Trigger flush
let packets = driver.fetch_outgoing(); // Get packets to send
```
## Debugging Tips
- Enable `trace!` logs to see packet-level details
- Monitor `ts_flush` vs `ts_current` for timing issues
- Check `snd_wnd` and `rcv_wnd` for flow control problems
- Watch for "fast retransmit" messages indicating packet loss
## Integration Notes
- AIDEV-NOTE: MSS must account for Sphinx packet overhead
- AIDEV-NOTE: Window size affects memory usage and throughput
- Update frequency impacts latency vs CPU usage tradeoff
- Conv ID must be consistent across session lifecycle
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "nym-kcp"
version = "0.1.0"
edition = "2021"
[lib]
name = "nym_kcp"
path = "src/lib.rs"
[[bin]]
name = "wire_format"
path = "bin/wire_format/main.rs"
[[bin]]
name = "session"
path = "bin/session/main.rs"
[dependencies]
tokio-util = { workspace = true, features = ["codec"] }
byte_string = "1.0"
bytes = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
ansi_term = { workspace = true }
[dev-dependencies]
env_logger = "0.11"
+80
View File
@@ -0,0 +1,80 @@
use bytes::BytesMut;
use log::info;
use nym_kcp::{packet::KcpPacket, session::KcpSession};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create two KcpSessions, simulating two endpoints
let mut local_sess = KcpSession::new(42);
let mut remote_sess = KcpSession::new(42);
// Set an MSS (max segment size) smaller than our data to force fragmentation
local_sess.set_mtu(40);
remote_sess.set_mtu(40);
// Some data larger than 30 bytes to demonstrate multi-fragment
let big_data = b"The quick brown fox jumps over the lazy dog. This is a test.";
// --- LOCAL sends data ---
info!(
"Local: sending data: {:?}",
String::from_utf8_lossy(big_data)
);
local_sess.send(big_data);
// Update local session's logic at time=0
local_sess.update(100);
// LOCAL fetches outgoing (to be sent across the network)
let outgoing_pkts = local_sess.fetch_outgoing();
info!("Local: outgoing pkts: {:?}", outgoing_pkts);
// Here you'd normally encrypt and send them. Well just encode them into a buffer.
// Then that buffer is "transferred" to the remote side.
let mut wire_buf = BytesMut::new();
for pkt in &outgoing_pkts {
pkt.encode(&mut wire_buf);
}
// --- REMOTE receives data ---
// The remote side "decrypts" (here we just clone) and decodes
let mut remote_in = wire_buf.clone();
// Decode zero or more KcpPackets from remote_in
while let Some(decoded_pkt) = KcpPacket::decode(&mut remote_in)? {
info!(
"Decoded packet, sn: {}, frg: {}",
decoded_pkt.sn(),
decoded_pkt.frg()
);
remote_sess.input(&decoded_pkt);
}
// Update remote session to process newly received data
remote_sess.update(100);
// The remote session likely generated ACK packets
let ack_pkts = remote_sess.fetch_outgoing();
// --- LOCAL receives ACKs ---
// The local side decodes them
let mut ack_buf = BytesMut::new();
for pkt in &ack_pkts {
pkt.encode(&mut ack_buf);
}
while let Some(decoded_pkt) = KcpPacket::decode(&mut ack_buf)? {
local_sess.input(&decoded_pkt);
}
// Update local again with some arbitrary time, e.g. 50 ms later
local_sess.update(100);
// Just for completeness, local might produce more packets, though typically it's just empty now
let _ = local_sess.fetch_outgoing();
// --- REMOTE reads reassembled data ---
let incoming = remote_sess.fetch_incoming();
info!("Remote: incoming pkts: {:?}", incoming);
Ok(())
}
+83
View File
@@ -0,0 +1,83 @@
use std::{
fs::File,
io::{BufRead as _, BufReader},
};
use bytes::BytesMut;
use log::info;
use nym_kcp::{
codec::KcpCodec,
packet::{KcpCommand, KcpPacket},
};
use tokio_util::codec::{Decoder as _, Encoder as _};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1) Open a file and read lines
let file = File::open("bin/wire_format/packets.txt")?;
let reader = BufReader::new(file);
// 2) Create our KcpCodec
let mut codec = KcpCodec {};
// We'll use out_buf for encoded data from *all* lines
let mut out_buf = BytesMut::new();
let mut input_lines = vec![];
// Read lines & encode them all
for (i, line) in reader.lines().enumerate() {
let line = line?;
info!("Original line #{}: {}", i + 1, line);
// Construct a KcpPacket
let pkt = KcpPacket::new(
42,
KcpCommand::Push,
0,
128,
0,
i as u32,
0,
line.as_bytes().to_vec(),
);
input_lines.push(pkt.clone_data());
// Encode (serialize) the packet into out_buf
codec.encode(pkt, &mut out_buf)?;
}
// === Simulate encryption & transmission ===
// In reality, you might do `encrypt(&out_buf)` and then
// send it over the network. We'll just clone here:
let mut received_buf = out_buf.clone();
// 3) Now decode (deserialize) all packets at once
// For demonstration, read them back out
let mut count = 0;
let mut decoded_lines = vec![];
#[allow(clippy::while_let_loop)]
loop {
match codec.decode(&mut received_buf)? {
Some(decoded_pkt) => {
count += 1;
// Convert packet data back to a string
let decoded_str = String::from_utf8_lossy(decoded_pkt.data());
info!("Decoded line #{}: {}", decoded_pkt.sn() + 1, decoded_str);
decoded_lines.push(decoded_pkt.clone_data());
}
None => break,
}
}
for (i, j) in input_lines.iter().zip(decoded_lines.iter()) {
assert_eq!(i, j);
}
info!("Decoded {} lines total.", count);
Ok(())
}
@@ -0,0 +1,10 @@
packet 1
packet 2
packet 3
packet 4
packet 5
packet 6
packet 7
packet 8
packet 9
packet 10
+30
View File
@@ -0,0 +1,30 @@
use std::io;
use bytes::BytesMut;
use tokio_util::codec::{Decoder, Encoder};
use super::packet::KcpPacket;
/// Our codec for encoding/decoding KCP packets
#[derive(Debug, Default)]
pub struct KcpCodec;
impl Decoder for KcpCodec {
type Item = KcpPacket;
type Error = io::Error;
fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
// We simply delegate to `KcpPacket::decode`
KcpPacket::decode(src).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
}
impl Encoder<KcpPacket> for KcpCodec {
type Error = io::Error;
fn encode(&mut self, item: KcpPacket, dst: &mut BytesMut) -> Result<(), Self::Error> {
// We just call `item.encode` to append the bytes
item.encode(dst);
Ok(())
}
}
+60
View File
@@ -0,0 +1,60 @@
use bytes::BytesMut;
use log::{debug, trace};
use crate::{error::KcpError, packet::KcpPacket, session::KcpSession};
pub struct KcpDriver {
session: KcpSession,
buffer: BytesMut,
}
impl KcpDriver {
pub fn conv_id(&self) -> Result<u32, KcpError> {
Ok(self.session.conv)
}
pub fn send(&mut self, data: &[u8]) {
self.session.send(data);
}
pub fn input(&mut self, data: &[u8]) -> Result<Vec<KcpPacket>, KcpError> {
self.buffer.extend_from_slice(data);
let mut pkts = Vec::new();
while let Ok(Some(pkt)) = KcpPacket::decode(&mut self.buffer) {
debug!(
"Decoded packet, cmd: {}, sn: {}, frg: {}",
pkt.command(),
pkt.sn(),
pkt.frg()
);
self._input(&pkt)?;
pkts.push(pkt);
}
Ok(pkts)
}
fn _input(&mut self, pkt: &KcpPacket) -> Result<(), KcpError> {
self.session.input(pkt);
Ok(())
}
pub fn fetch_outgoing(&mut self) -> Vec<KcpPacket> {
trace!(
"ts_flush: {}, ts_current: {}",
self.session.ts_flush(),
self.session.ts_current()
);
self.session.fetch_outgoing()
}
pub fn update(&mut self, tick: u64) {
self.session.update(tick as u32);
}
pub fn new(session: KcpSession) -> Self {
KcpDriver {
session,
buffer: BytesMut::new(),
}
}
}
+10
View File
@@ -0,0 +1,10 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum KcpError {
#[error("Invalid KCP command value: {0}")]
InvalidCommand(u8),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
+5
View File
@@ -0,0 +1,5 @@
pub mod codec;
pub mod driver;
pub mod error;
pub mod packet;
pub mod session;
+219
View File
@@ -0,0 +1,219 @@
use bytes::{Buf, BufMut, BytesMut};
use log::{debug, trace};
use super::error::KcpError;
pub const KCP_HEADER: usize = 24;
/// Typed enumeration for KCP commands.
#[repr(u8)]
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum KcpCommand {
Push = 81, // cmd: push data
Ack = 82, // cmd: ack
Wask = 83, // cmd: window probe (ask)
Wins = 84, // cmd: window size (tell)
}
impl std::fmt::Display for KcpCommand {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
KcpCommand::Push => write!(f, "Push"),
KcpCommand::Ack => write!(f, "Ack"),
KcpCommand::Wask => write!(f, "Window Probe (ask)"),
KcpCommand::Wins => write!(f, "Window Size (tell)"),
}
}
}
impl TryFrom<u8> for KcpCommand {
type Error = KcpError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
81 => Ok(KcpCommand::Push),
82 => Ok(KcpCommand::Ack),
83 => Ok(KcpCommand::Wask),
84 => Ok(KcpCommand::Wins),
_ => Err(KcpError::InvalidCommand(value)),
}
}
}
#[allow(clippy::from_over_into)]
impl Into<u8> for KcpCommand {
fn into(self) -> u8 {
self as u8
}
}
/// A single KCP packet (on-wire format).
#[derive(Debug, Clone)]
pub struct KcpPacket {
conv: u32,
cmd: KcpCommand,
frg: u8,
wnd: u16,
ts: u32,
sn: u32,
una: u32,
data: Vec<u8>,
}
#[allow(clippy::too_many_arguments)]
impl KcpPacket {
pub fn new(
conv: u32,
cmd: KcpCommand,
frg: u8,
wnd: u16,
ts: u32,
sn: u32,
una: u32,
data: Vec<u8>,
) -> Self {
Self {
conv,
cmd,
frg,
wnd,
ts,
sn,
una,
data,
}
}
pub fn command(&self) -> KcpCommand {
self.cmd
}
pub fn data(&self) -> &[u8] {
&self.data
}
pub fn clone_data(&self) -> Vec<u8> {
self.data.clone()
}
pub fn conv(&self) -> u32 {
self.conv
}
pub fn cmd(&self) -> KcpCommand {
self.cmd
}
pub fn frg(&self) -> u8 {
self.frg
}
pub fn wnd(&self) -> u16 {
self.wnd
}
pub fn ts(&self) -> u32 {
self.ts
}
pub fn sn(&self) -> u32 {
self.sn
}
pub fn una(&self) -> u32 {
self.una
}
}
impl Default for KcpPacket {
fn default() -> Self {
// We must pick some default command, e.g. `Push`.
// Or omit `Default` if you don't need it.
KcpPacket {
conv: 0,
cmd: KcpCommand::Push,
frg: 0,
wnd: 0,
ts: 0,
sn: 0,
una: 0,
data: Vec::new(),
}
}
}
impl KcpPacket {
/// Attempt to decode a `KcpPacket` from `src`.
/// Returns Ok(Some(pkt)) if fully available, Ok(None) if not enough data,
/// or Err(...) if there's an invalid command or other error.
pub fn decode(src: &mut BytesMut) -> Result<Option<Self>, KcpError> {
trace!("Decoding buffer with len: {}", src.len());
if src.len() < KCP_HEADER {
// Not enough for even the header, this is usually fine, more data will arrive
debug!("Not enough data for header");
return Ok(None);
}
// Peek into the first 28 bytes
let mut header = &src[..KCP_HEADER];
let conv = header.get_u32_le();
let cmd_byte = header.get_u8();
let frg = header.get_u8();
let wnd = header.get_u16_le();
let ts = header.get_u32_le();
let sn = header.get_u32_le();
let una = header.get_u32_le();
let len = header.get_u32_le() as usize;
let total_needed = KCP_HEADER + len;
if src.len() < total_needed {
// We don't have the full packet yet
debug!(
"Not enough data for packet, want {}, have {}",
total_needed,
src.len()
);
return Ok(None);
}
// Convert the raw u8 into our KcpCommand enum
let cmd = KcpCommand::try_from(cmd_byte)?;
// Now we can read out the data portion
let data = src[KCP_HEADER..KCP_HEADER + len].to_vec();
// Advance the buffer so it no longer contains this packet
src.advance(total_needed);
Ok(Some(Self {
conv,
cmd,
frg,
wnd,
ts,
sn,
una,
data,
}))
}
/// Encode this packet into `dst`.
pub fn encode(&self, dst: &mut BytesMut) {
let total_len = KCP_HEADER + self.data.len();
trace!("Encoding packet: {:?}, len: {}", self, total_len);
dst.reserve(total_len);
dst.put_u32_le(self.conv);
dst.put_u8(self.cmd.into()); // Convert enum -> u8
dst.put_u8(self.frg);
dst.put_u16_le(self.wnd);
dst.put_u32_le(self.ts);
dst.put_u32_le(self.sn);
dst.put_u32_le(self.una);
dst.put_u32_le(self.data.len() as u32);
dst.extend_from_slice(&self.data);
trace!("Encoded packet: {:?}, len: {}", dst, dst.len());
}
}
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
[package]
name = "nym-lp-common"
version = "0.1.0"
edition = "2021"
[dependencies]
+28
View File
@@ -0,0 +1,28 @@
use std::fmt;
use std::fmt::Write;
pub fn format_debug_bytes(bytes: &[u8]) -> Result<String, fmt::Error> {
let mut out = String::new();
const LINE_LEN: usize = 16;
for (i, chunk) in bytes.chunks(LINE_LEN).enumerate() {
let line_prefix = format!("[{}:{}]", 1 + i * LINE_LEN, i * LINE_LEN + chunk.len());
write!(out, "{line_prefix:12}")?;
let mut line = String::new();
for b in chunk {
line.push_str(format!("{:02x} ", b).as_str());
}
write!(
out,
"{line:48} {}",
chunk
.iter()
.map(|&b| b as char)
.map(|c| if c.is_alphanumeric() { c } else { '.' })
.collect::<String>()
)?;
writeln!(out)?;
}
Ok(out)
}
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "nym-lp"
version = "0.1.0"
edition = "2021"
[dependencies]
bincode = { workspace = true }
thiserror = { workspace = true }
parking_lot = { workspace = true }
snow = { workspace = true }
bs58 = { workspace = true }
serde = { workspace = true }
bytes = { workspace = true }
dashmap = { workspace = true }
sha2 = { workspace = true }
ansi_term = { workspace = true }
utoipa = { workspace = true, features = ["macros", "non_strict_integers"] }
rand = { workspace = true }
nym-crypto = { path = "../crypto", features = ["hashing", "asymmetric"] }
nym-lp-common = { path = "../nym-lp-common" }
nym-sphinx = { path = "../nymsphinx" }
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
rand_chacha = "0.3"
[[bench]]
name = "replay_protection"
harness = false
+71
View File
@@ -0,0 +1,71 @@
# Nym Lewes Protocol
The Lewes Protocol (LP) is a secure network communication protocol implemented in Rust. This README provides an overview of the protocol's session management and replay protection mechanisms.
## Architecture Overview
```
+-----------------+ +----------------+ +---------------+
| Transport Layer |<--->| LP Session |<--->| LP Codec |
| (UDP/TCP) | | - Replay prot. | | - Enc/dec only|
+-----------------+ | - Crypto state | +---------------+
+----------------+
```
## Packet Structure
The protocol uses a structured packet format:
```
+------------------+-------------------+------------------+
| Header (16B) | Message | Trailer (16B) |
| - Version (1B) | - Type (2B) | - Authentication |
| - Reserved (3B) | - Content | - tag/MAC |
| - SenderIdx (4B) | | |
| - Counter (8B) | | |
+------------------+-------------------+------------------+
```
- Header contains protocol version, sender identification, and counter for replay protection
- Message carries the actual payload with a type identifier
- Trailer provides authentication and integrity verification (16 bytes)
- Total packet size is constrained by MTU (1500 bytes), accounting for network overhead
## Sessions
- Sessions are managed by `LPSession` and `SessionManager` classes
- Each session has unique receiving and sending indices to identify connections
- Sessions maintain:
- Cryptographic state (currently mocked implementations)
- Counter for outgoing packets
- Replay protection mechanism for incoming packets
## Session Management
- `SessionManager` handles session lifecycle (creation, retrieval, removal)
- Sessions are stored in a thread-safe HashMap indexed by receiving index
- The manager generates unique indices for new sessions
- Sessions are Arc-wrapped for safe concurrent access
## Replay Protection
- Implemented in the `ReceivingKeyCounterValidator` class
- Uses a bitmap-based approach to track received packet counters
- The bitmap allows reordering of up to 1024 packets (configurable)
- SIMD optimizations are used when available for performance
## Replay Protection Process
1. Quick validation (`will_accept_branchless`):
- Checks if counter is valid before expensive operations
- Detects duplicates, out-of-window packets
2. Marking packets (`mark_did_receive_branchless`):
- Updates the bitmap to mark counter as received
- Updates statistics and sliding window as needed
3. Window Sliding:
- Automatically slides the acceptance window when new higher counters arrive
- Clears bits for old counters that fall outside the window
This architecture effectively prevents replay attacks while allowing some packet reordering, an essential feature for secure network protocols.
+238
View File
@@ -0,0 +1,238 @@
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use nym_lp::replay::ReceivingKeyCounterValidator;
use parking_lot::Mutex;
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha8Rng;
use std::sync::Arc;
fn bench_sequential_counters(c: &mut Criterion) {
let mut group = c.benchmark_group("replay_sequential");
group.sample_size(1000);
for size in [100, 1000, 10000] {
group.throughput(Throughput::Elements(size));
group.bench_with_input(
BenchmarkId::new("sequential_counters", size),
&size,
|b, &size| {
let validator = ReceivingKeyCounterValidator::default();
let counters: Vec<u64> = (0..size).collect();
b.iter(|| {
let mut validator = validator.clone();
for &counter in &counters {
let _ = black_box(validator.will_accept_branchless(counter));
let _ = black_box(validator.mark_did_receive_branchless(counter));
}
});
},
);
}
group.finish();
}
fn bench_out_of_order_counters(c: &mut Criterion) {
let mut group = c.benchmark_group("replay_out_of_order");
group.sample_size(1000);
for size in [100, 1000, 10000] {
group.throughput(Throughput::Elements(size as u64));
group.bench_with_input(
BenchmarkId::new("out_of_order_counters", size),
&size,
|b, &size| {
let validator = ReceivingKeyCounterValidator::default();
// Create random counters within a valid window
let mut rng = ChaCha8Rng::seed_from_u64(42);
let counters: Vec<u64> = (0..size).map(|_| rng.gen_range(0..1024)).collect();
b.iter(|| {
let mut validator = validator.clone();
for &counter in &counters {
let _ = black_box(validator.will_accept_branchless(counter));
let _ = black_box(validator.mark_did_receive_branchless(counter));
}
});
},
);
}
group.finish();
}
fn bench_thread_safety(c: &mut Criterion) {
let mut group = c.benchmark_group("replay_thread_safety");
group.sample_size(1000);
for size in [100, 1000, 10000] {
group.throughput(Throughput::Elements(size));
group.bench_with_input(
BenchmarkId::new("thread_safe_validator", size),
&size,
|b, &size| {
let validator = Arc::new(Mutex::new(ReceivingKeyCounterValidator::default()));
let counters: Vec<u64> = (0..size).collect();
b.iter(|| {
for &counter in &counters {
let result = {
let guard = validator.lock();
black_box(guard.will_accept_branchless(counter))
};
if result.is_ok() {
let mut guard = validator.lock();
let _ = black_box(guard.mark_did_receive_branchless(counter));
}
}
});
},
);
}
group.finish();
}
fn bench_window_sliding(c: &mut Criterion) {
let mut group = c.benchmark_group("replay_window_sliding");
group.sample_size(100);
for window_size in [128, 512, 1024] {
group.throughput(Throughput::Elements(window_size));
group.bench_with_input(
BenchmarkId::new("window_sliding", window_size),
&window_size,
|b, &window_size| {
b.iter(|| {
let mut validator = ReceivingKeyCounterValidator::default();
// First fill the window with sequential packets
for i in 0..window_size {
let _ = black_box(validator.mark_did_receive_branchless(i));
}
// Then jump ahead to force window sliding
let _ = black_box(validator.mark_did_receive_branchless(window_size * 3));
// Try some packets in the new window
for i in (window_size * 2 + 1)..(window_size * 3) {
let _ = black_box(validator.will_accept_branchless(i));
}
});
},
);
}
group.finish();
}
/// Benchmark operations that would benefit from SIMD optimization
fn bench_core_operations(c: &mut Criterion) {
let mut group = c.benchmark_group("replay_core_operations");
group.sample_size(1000);
// Create validators with different states
let empty_validator = ReceivingKeyCounterValidator::default();
let mut half_full_validator = ReceivingKeyCounterValidator::default();
let mut full_validator = ReceivingKeyCounterValidator::default();
// Fill validators with different patterns
for i in 0..512 {
half_full_validator.mark_did_receive_branchless(i).unwrap();
}
for i in 0..1024 {
full_validator.mark_did_receive_branchless(i).unwrap();
}
// Benchmark clearing operations
group.bench_function("clear_empty_window", |b| {
b.iter(|| {
let mut validator = empty_validator.clone();
// Force window sliding that will clear bitmap
let _: () = validator.mark_did_receive_branchless(2000).unwrap();
black_box(());
})
});
group.bench_function("clear_half_full_window", |b| {
b.iter(|| {
let mut validator = half_full_validator.clone();
// Force window sliding that will clear bitmap
let _: () = validator.mark_did_receive_branchless(2000).unwrap();
black_box(());
})
});
group.bench_function("clear_full_window", |b| {
b.iter(|| {
let mut validator = full_validator.clone();
// Force window sliding that will clear bitmap
let _: () = validator.mark_did_receive_branchless(2000).unwrap();
black_box(());
})
});
group.finish();
}
/// Benchmark thread safety with different thread counts
fn bench_concurrency_scaling(c: &mut Criterion) {
let mut group = c.benchmark_group("replay_concurrency_scaling");
group.sample_size(50);
for thread_count in [1, 2, 4, 8] {
group.bench_with_input(
BenchmarkId::new("mutex_threads", thread_count),
&thread_count,
|b, &thread_count| {
b.iter(|| {
let validator = Arc::new(Mutex::new(ReceivingKeyCounterValidator::default()));
let mut handles = Vec::new();
for t in 0..thread_count {
let validator_clone = Arc::clone(&validator);
let handle = std::thread::spawn(move || {
let mut success_count = 0;
for i in 0..100 {
let counter = t * 1000 + i;
let mut guard = validator_clone.lock();
if guard.mark_did_receive_branchless(counter as u64).is_ok() {
success_count += 1;
}
}
success_count
});
handles.push(handle);
}
let mut total = 0;
for handle in handles {
total += handle.join().unwrap();
}
black_box(total)
})
},
);
}
group.finish();
}
criterion_group!(
replay_benches,
bench_sequential_counters,
bench_out_of_order_counters,
bench_thread_safety,
bench_window_sliding,
bench_core_operations,
bench_concurrency_scaling
);
criterion_main!(replay_benches);
+560
View File
@@ -0,0 +1,560 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::message::{ClientHelloData, LpMessage, MessageType};
use crate::packet::{LpHeader, LpPacket, TRAILER_LEN};
use crate::LpError;
use bytes::BytesMut;
/// Parses a complete Lewes Protocol packet from a byte slice (e.g., a UDP datagram payload).
///
/// Assumes the input `src` contains exactly one complete packet. It does not handle
/// stream fragmentation or provide replay protection checks (these belong at the session level).
pub fn parse_lp_packet(src: &[u8]) -> Result<LpPacket, LpError> {
// Minimum size check: LpHeader + Type + Trailer (for 0-payload message)
let min_size = LpHeader::SIZE + 2 + TRAILER_LEN;
if src.len() < min_size {
return Err(LpError::InsufficientBufferSize);
}
// Parse LpHeader
let header = LpHeader::parse(&src[..LpHeader::SIZE])?; // Uses the new LpHeader::parse
// Parse Message Type
let type_start = LpHeader::SIZE;
let type_end = type_start + 2;
let mut message_type_bytes = [0u8; 2];
message_type_bytes.copy_from_slice(&src[type_start..type_end]);
let message_type_raw = u16::from_le_bytes(message_type_bytes);
let message_type = MessageType::from_u16(message_type_raw)
.ok_or_else(|| LpError::invalid_message_type(message_type_raw))?;
// Calculate payload size based on total length
let total_size = src.len();
let message_size = total_size - min_size; // Size of the payload part
// Extract payload based on message type
let message_start = type_end;
let message_end = message_start + message_size;
let payload_slice = &src[message_start..message_end]; // Bounds already checked by min_size and total_size calculation
let message = match message_type {
MessageType::Busy => {
if message_size != 0 {
return Err(LpError::InvalidPayloadSize {
expected: 0,
actual: message_size,
});
}
LpMessage::Busy
}
MessageType::Handshake => {
// No size validation needed here for Handshake, it's variable
LpMessage::Handshake(payload_slice.to_vec())
}
MessageType::EncryptedData => {
// No size validation needed here for EncryptedData, it's variable
LpMessage::EncryptedData(payload_slice.to_vec())
}
MessageType::ClientHello => {
// ClientHello has structured data
// Deserialize ClientHelloData from payload
let data: ClientHelloData = bincode::deserialize(payload_slice)
.map_err(|e| LpError::DeserializationError(e.to_string()))?;
LpMessage::ClientHello(data)
}
};
// Extract trailer
let trailer_start = message_end;
let trailer_end = trailer_start + TRAILER_LEN;
// Check if trailer_end exceeds src length (shouldn't happen if min_size check passed and calculation is correct, but good for safety)
if trailer_end > total_size {
// This indicates an internal logic error or buffer manipulation issue
return Err(LpError::InsufficientBufferSize); // Or a more specific internal error
}
let trailer_slice = &src[trailer_start..trailer_end];
let mut trailer = [0u8; TRAILER_LEN];
trailer.copy_from_slice(trailer_slice);
// Create and return the packet
Ok(LpPacket {
header,
message,
trailer,
})
}
/// Serializes an LpPacket into the provided BytesMut buffer.
pub fn serialize_lp_packet(item: &LpPacket, dst: &mut BytesMut) -> Result<(), LpError> {
// Reserve approximate size - consider making this more accurate if needed
dst.reserve(LpHeader::SIZE + 2 + item.message.len() + TRAILER_LEN);
item.encode(dst); // Use the existing encode method on LpPacket
Ok(())
}
// Add a new error variant for invalid message types (Moved from previous impl LpError block)
impl LpError {
pub fn invalid_message_type(message_type: u16) -> Self {
LpError::InvalidMessageType(message_type)
}
}
#[cfg(test)]
mod tests {
// Import standalone functions
use super::{parse_lp_packet, serialize_lp_packet};
// Keep necessary imports
use crate::message::{LpMessage, MessageType};
use crate::packet::{LpHeader, LpPacket, TRAILER_LEN};
use crate::LpError;
use bytes::BytesMut;
// === Updated Encode/Decode Tests ===
#[test]
fn test_serialize_parse_busy() {
let mut dst = BytesMut::new();
// Create a Busy packet
let packet = LpPacket {
header: LpHeader {
protocol_version: 1,
session_id: 42,
counter: 123,
},
message: LpMessage::Busy,
trailer: [0; TRAILER_LEN],
};
// Serialize the packet
serialize_lp_packet(&packet, &mut dst).unwrap();
// Parse the packet
let decoded = parse_lp_packet(&dst).unwrap();
// Verify the packet fields
assert_eq!(decoded.header.protocol_version, 1);
assert_eq!(decoded.header.session_id, 42);
assert_eq!(decoded.header.counter, 123);
assert!(matches!(decoded.message, LpMessage::Busy));
assert_eq!(decoded.trailer, [0; TRAILER_LEN]);
}
#[test]
fn test_serialize_parse_handshake() {
let mut dst = BytesMut::new();
// Create a Handshake message packet
let payload = vec![42u8; 80]; // Example payload size
let packet = LpPacket {
header: LpHeader {
protocol_version: 1,
session_id: 42,
counter: 123,
},
message: LpMessage::Handshake(payload.clone()),
trailer: [0; TRAILER_LEN],
};
// Serialize the packet
serialize_lp_packet(&packet, &mut dst).unwrap();
// Parse the packet
let decoded = parse_lp_packet(&dst).unwrap();
// Verify the packet fields
assert_eq!(decoded.header.protocol_version, 1);
assert_eq!(decoded.header.session_id, 42);
assert_eq!(decoded.header.counter, 123);
// Verify message type and data
match decoded.message {
LpMessage::Handshake(decoded_payload) => {
assert_eq!(decoded_payload, payload);
}
_ => panic!("Expected Handshake message"),
}
assert_eq!(decoded.trailer, [0; TRAILER_LEN]);
}
#[test]
fn test_serialize_parse_encrypted_data() {
let mut dst = BytesMut::new();
// Create an EncryptedData message packet
let payload = vec![43u8; 124]; // Example payload size
let packet = LpPacket {
header: LpHeader {
protocol_version: 1,
session_id: 42,
counter: 123,
},
message: LpMessage::EncryptedData(payload.clone()),
trailer: [0; TRAILER_LEN],
};
// Serialize the packet
serialize_lp_packet(&packet, &mut dst).unwrap();
// Parse the packet
let decoded = parse_lp_packet(&dst).unwrap();
// Verify the packet fields
assert_eq!(decoded.header.protocol_version, 1);
assert_eq!(decoded.header.session_id, 42);
assert_eq!(decoded.header.counter, 123);
// Verify message type and data
match decoded.message {
LpMessage::EncryptedData(decoded_payload) => {
assert_eq!(decoded_payload, payload);
}
_ => panic!("Expected EncryptedData message"),
}
assert_eq!(decoded.trailer, [0; TRAILER_LEN]);
}
// === Updated Incomplete Data Tests ===
#[test]
fn test_parse_incomplete_header() {
// Create a buffer with incomplete header
let mut buf = BytesMut::new();
buf.extend_from_slice(&[1, 0, 0, 0]); // Only 4 bytes, not enough for LpHeader::SIZE
// Attempt to parse - expect error
let result = parse_lp_packet(&buf);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
LpError::InsufficientBufferSize
));
}
#[test]
fn test_parse_incomplete_message_type() {
// Create a buffer with complete header but incomplete message type
let mut buf = BytesMut::new();
buf.extend_from_slice(&[1, 0, 0, 0]); // Version + reserved
buf.extend_from_slice(&42u32.to_le_bytes()); // Sender index
buf.extend_from_slice(&123u64.to_le_bytes()); // Counter
buf.extend_from_slice(&[0]); // Only 1 byte of message type (need 2)
// Buffer length = 16 + 1 = 17. Min size = 16 + 2 + 16 = 34.
let result = parse_lp_packet(&buf);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
LpError::InsufficientBufferSize
));
}
#[test]
fn test_parse_incomplete_message_data() {
// Create a buffer simulating Handshake but missing trailer and maybe partial payload
let mut buf = BytesMut::new();
buf.extend_from_slice(&[1, 0, 0, 0]); // Version + reserved
buf.extend_from_slice(&42u32.to_le_bytes()); // Sender index
buf.extend_from_slice(&123u64.to_le_bytes()); // Counter
buf.extend_from_slice(&MessageType::Handshake.to_u16().to_le_bytes()); // Handshake type
buf.extend_from_slice(&[42; 40]); // 40 bytes of payload data
// Buffer length = 16 + 2 + 40 = 58. Min size = 16 + 2 + 16 = 34.
// Payload size calculated as 58 - 34 = 24.
// Trailer expected at index 16 + 2 + 24 = 42.
// Trailer read attempts src[42..58].
// This *should* parse successfully based on the logic, but the trailer is garbage.
// Let's rethink: parse_lp_packet assumes the *entire slice* is the packet.
// If the slice doesn't end exactly where the trailer should, it's an error.
// In this case, total length is 58. LpHeader(16) + Type(2) + Trailer(16) = 34. Payload = 58-34=24.
// Trailer starts at 16+2+24 = 42. Ends at 42+16=58. It fits exactly.
// This test *still* doesn't test incompleteness correctly for the datagram parser.
// Let's test a buffer that's *too short* even for header+type+trailer+min_payload
let mut buf_too_short = BytesMut::new();
buf_too_short.extend_from_slice(&[1, 0, 0, 0]); // Version + reserved
buf_too_short.extend_from_slice(&42u32.to_le_bytes()); // Sender index
buf_too_short.extend_from_slice(&123u64.to_le_bytes()); // Counter
buf_too_short.extend_from_slice(&MessageType::Handshake.to_u16().to_le_bytes()); // Handshake type
// No payload, no trailer. Length = 16+2=18. Min size = 34.
let result_too_short = parse_lp_packet(&buf_too_short);
assert!(result_too_short.is_err());
assert!(matches!(
result_too_short.unwrap_err(),
LpError::InsufficientBufferSize
));
// Test a buffer missing PART of the trailer
let mut buf_partial_trailer = BytesMut::new();
buf_partial_trailer.extend_from_slice(&[1, 0, 0, 0]); // Version + reserved
buf_partial_trailer.extend_from_slice(&42u32.to_le_bytes()); // Sender index
buf_partial_trailer.extend_from_slice(&123u64.to_le_bytes()); // Counter
buf_partial_trailer.extend_from_slice(&MessageType::Handshake.to_u16().to_le_bytes()); // Handshake type
let payload = vec![42u8; 20]; // Assume 20 byte payload
buf_partial_trailer.extend_from_slice(&payload);
buf_partial_trailer.extend_from_slice(&[0; TRAILER_LEN - 1]); // Missing last byte of trailer
// Total length = 16 + 2 + 20 + 15 = 53. Min size = 34. This passes.
// Payload size = 53 - 34 = 19. <--- THIS IS WRONG. The parser assumes the length dictates payload.
// Let's fix the parser logic slightly.
// The point is, parse_lp_packet expects a COMPLETE datagram. Providing less bytes
// than LpHeader + Type + Trailer should fail. Providing *more* is also an issue unless
// the length calculation works out perfectly. The most direct test is just < min_size.
// Renaming test to reflect this.
}
#[test]
fn test_parse_buffer_smaller_than_minimum() {
// Test a buffer that's smaller than the smallest possible packet (LpHeader+Type+Trailer)
let mut buf_too_short = BytesMut::new();
buf_too_short.extend_from_slice(&[1, 0, 0, 0]); // Version + reserved
buf_too_short.extend_from_slice(&42u32.to_le_bytes()); // Sender index
buf_too_short.extend_from_slice(&123u64.to_le_bytes()); // Counter
buf_too_short.extend_from_slice(&MessageType::Busy.to_u16().to_le_bytes()); // Type
buf_too_short.extend_from_slice(&[0; TRAILER_LEN - 1]); // Missing last byte of trailer
// Length = 16 + 2 + 15 = 33. Min Size = 34.
let result_too_short = parse_lp_packet(&buf_too_short);
assert!(
result_too_short.is_err(),
"Expected error for buffer size 33, min 34"
);
assert!(matches!(
result_too_short.unwrap_err(),
LpError::InsufficientBufferSize
));
}
#[test]
fn test_parse_invalid_message_type() {
// Create a buffer with invalid message type
let mut buf = BytesMut::new();
buf.extend_from_slice(&[1, 0, 0, 0]); // Version + reserved
buf.extend_from_slice(&42u32.to_le_bytes()); // Sender index
buf.extend_from_slice(&123u64.to_le_bytes()); // Counter
buf.extend_from_slice(&255u16.to_le_bytes()); // Invalid message type
// Need payload and trailer to meet min_size requirement
let payload_size = 10; // Arbitrary
buf.extend_from_slice(&vec![0u8; payload_size]); // Some data
buf.extend_from_slice(&[0; TRAILER_LEN]); // Trailer
// Attempt to parse
let result = parse_lp_packet(&buf);
assert!(result.is_err());
match result {
Err(LpError::InvalidMessageType(255)) => {} // Expected error
Err(e) => panic!("Expected InvalidMessageType error, got {:?}", e),
Ok(_) => panic!("Expected error, but got Ok"),
}
}
#[test]
fn test_parse_incorrect_payload_size_for_busy() {
// Create a Busy packet but *with* a payload (which is invalid)
let mut buf = BytesMut::new();
buf.extend_from_slice(&[1, 0, 0, 0]); // Version + reserved
buf.extend_from_slice(&42u32.to_le_bytes()); // Sender index
buf.extend_from_slice(&123u64.to_le_bytes()); // Counter
buf.extend_from_slice(&MessageType::Busy.to_u16().to_le_bytes()); // Busy type
buf.extend_from_slice(&[42; 1]); // <<< Invalid 1-byte payload for Busy
buf.extend_from_slice(&[0; TRAILER_LEN]); // Trailer
// Total size = 16 + 2 + 1 + 16 = 35. Min size = 34.
// Calculated payload size = 35 - 34 = 1.
let result = parse_lp_packet(&buf);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
LpError::InvalidPayloadSize {
expected: 0,
actual: 1
}
));
}
// Test multiple packets simulation isn't relevant for datagram parsing
// #[test]
// fn test_multiple_packets_in_buffer() { ... }
// === ClientHello Serialization Tests ===
#[test]
fn test_serialize_parse_client_hello() {
use crate::message::ClientHelloData;
let mut dst = BytesMut::new();
// Create ClientHelloData
let client_key = [42u8; 32];
let protocol_version = 1u8;
let salt = [99u8; 32];
let hello_data = ClientHelloData {
client_lp_public_key: client_key,
protocol_version,
salt,
};
// Create a ClientHello message packet
let packet = LpPacket {
header: LpHeader {
protocol_version: 1,
session_id: 42,
counter: 123,
},
message: LpMessage::ClientHello(hello_data.clone()),
trailer: [0; TRAILER_LEN],
};
// Serialize the packet
serialize_lp_packet(&packet, &mut dst).unwrap();
// Parse the packet
let decoded = parse_lp_packet(&dst).unwrap();
// Verify the packet fields
assert_eq!(decoded.header.protocol_version, 1);
assert_eq!(decoded.header.session_id, 42);
assert_eq!(decoded.header.counter, 123);
// Verify message type and data
match decoded.message {
LpMessage::ClientHello(decoded_data) => {
assert_eq!(decoded_data.client_lp_public_key, client_key);
assert_eq!(decoded_data.protocol_version, protocol_version);
assert_eq!(decoded_data.salt, salt);
}
_ => panic!("Expected ClientHello message"),
}
assert_eq!(decoded.trailer, [0; TRAILER_LEN]);
}
#[test]
fn test_serialize_parse_client_hello_with_fresh_salt() {
use crate::message::ClientHelloData;
let mut dst = BytesMut::new();
// Create ClientHelloData with fresh salt
let client_key = [7u8; 32];
let hello_data = ClientHelloData::new_with_fresh_salt(client_key, 1);
// Create a ClientHello message packet
let packet = LpPacket {
header: LpHeader {
protocol_version: 1,
session_id: 100,
counter: 200,
},
message: LpMessage::ClientHello(hello_data.clone()),
trailer: [55; TRAILER_LEN],
};
// Serialize the packet
serialize_lp_packet(&packet, &mut dst).unwrap();
// Parse the packet
let decoded = parse_lp_packet(&dst).unwrap();
// Verify message type and data
match decoded.message {
LpMessage::ClientHello(decoded_data) => {
assert_eq!(decoded_data.client_lp_public_key, client_key);
assert_eq!(decoded_data.protocol_version, 1);
assert_eq!(decoded_data.salt, hello_data.salt);
// Verify timestamp can be extracted
let timestamp = decoded_data.extract_timestamp();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
// Timestamp should be within 2 seconds of now
assert!((timestamp as i64 - now as i64).abs() <= 2);
}
_ => panic!("Expected ClientHello message"),
}
}
#[test]
fn test_parse_client_hello_malformed_bincode() {
// Create a buffer with ClientHello message type but invalid bincode data
let mut buf = BytesMut::new();
buf.extend_from_slice(&[1, 0, 0, 0]); // Version + reserved
buf.extend_from_slice(&42u32.to_le_bytes()); // Sender index
buf.extend_from_slice(&123u64.to_le_bytes()); // Counter
buf.extend_from_slice(&MessageType::ClientHello.to_u16().to_le_bytes()); // ClientHello type
// Add malformed bincode data (random bytes that won't deserialize to ClientHelloData)
buf.extend_from_slice(&[0xFF; 50]); // Invalid bincode data
buf.extend_from_slice(&[0; TRAILER_LEN]); // Trailer
// Attempt to parse
let result = parse_lp_packet(&buf);
assert!(result.is_err());
match result {
Err(LpError::DeserializationError(_)) => {} // Expected error
Err(e) => panic!("Expected DeserializationError, got {:?}", e),
Ok(_) => panic!("Expected error, but got Ok"),
}
}
#[test]
fn test_parse_client_hello_incomplete_bincode() {
// Create a buffer with ClientHello but truncated bincode data
let mut buf = BytesMut::new();
buf.extend_from_slice(&[1, 0, 0, 0]); // Version + reserved
buf.extend_from_slice(&42u32.to_le_bytes()); // Sender index
buf.extend_from_slice(&123u64.to_le_bytes()); // Counter
buf.extend_from_slice(&MessageType::ClientHello.to_u16().to_le_bytes()); // ClientHello type
// Add incomplete bincode data (only partial ClientHelloData)
buf.extend_from_slice(&[0; 20]); // Too few bytes for full ClientHelloData
buf.extend_from_slice(&[0; TRAILER_LEN]); // Trailer
// Attempt to parse
let result = parse_lp_packet(&buf);
assert!(result.is_err());
match result {
Err(LpError::DeserializationError(_)) => {} // Expected error
Err(e) => panic!("Expected DeserializationError, got {:?}", e),
Ok(_) => panic!("Expected error, but got Ok"),
}
}
#[test]
fn test_client_hello_different_protocol_versions() {
use crate::message::ClientHelloData;
for version in [0u8, 1, 2, 255] {
let mut dst = BytesMut::new();
let hello_data = ClientHelloData {
client_lp_public_key: [version; 32],
protocol_version: version,
salt: [version.wrapping_add(1); 32],
};
let packet = LpPacket {
header: LpHeader {
protocol_version: 1,
session_id: version as u32,
counter: version as u64,
},
message: LpMessage::ClientHello(hello_data.clone()),
trailer: [version; TRAILER_LEN],
};
serialize_lp_packet(&packet, &mut dst).unwrap();
let decoded = parse_lp_packet(&dst).unwrap();
match decoded.message {
LpMessage::ClientHello(decoded_data) => {
assert_eq!(decoded_data.protocol_version, version);
assert_eq!(decoded_data.client_lp_public_key, [version; 32]);
}
_ => panic!("Expected ClientHello message for version {}", version),
}
}
}
}
+73
View File
@@ -0,0 +1,73 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::{noise_protocol::NoiseError, replay::ReplayError};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum LpError {
#[error("IO Error: {0}")]
IoError(#[from] std::io::Error),
#[error("Snow Error: {0}")]
SnowKeyError(#[from] snow::Error),
#[error("Snow Pattern Error: {0}")]
SnowPatternError(String),
#[error("Noise Protocol Error: {0}")]
NoiseError(#[from] NoiseError),
#[error("Replay detected: {0}")]
Replay(#[from] ReplayError),
#[error("Invalid packet format: {0}")]
InvalidPacketFormat(String),
#[error("Invalid message type: {0}")]
InvalidMessageType(u16),
#[error("Payload too large: {0}")]
PayloadTooLarge(usize),
#[error("Insufficient buffer size provided")]
InsufficientBufferSize,
#[error("Attempted operation on closed session")]
SessionClosed,
#[error("Internal error: {0}")]
Internal(String),
#[error("Invalid state transition: tried input {input:?} in state {state:?}")]
InvalidStateTransition { state: String, input: String },
#[error("Invalid payload size: expected {expected}, got {actual}")]
InvalidPayloadSize { expected: usize, actual: usize },
#[error("Deserialization error: {0}")]
DeserializationError(String),
#[error(transparent)]
InvalidBase58String(#[from] bs58::decode::Error),
/// Session ID from incoming packet does not match any known session.
#[error("Received packet with unknown session ID: {0}")]
UnknownSessionId(u32),
/// Invalid state transition attempt in the state machine.
#[error("Invalid input '{input}' for current state '{state}'")]
InvalidStateTransitionAttempt { state: String, input: String },
/// Session is closed.
#[error("Session is closed")]
LpSessionClosed,
/// Session is processing an input event.
#[error("Session is processing an input event")]
LpSessionProcessing,
/// State machine not found.
#[error("State machine not found for lp_id: {lp_id}")]
StateMachineNotFound { lp_id: u32 },
}
+165
View File
@@ -0,0 +1,165 @@
use std::fmt::{self, Display, Formatter};
use std::ops::Deref;
use std::str::FromStr;
use nym_sphinx::{PrivateKey as SphinxPrivateKey, PublicKey as SphinxPublicKey};
use serde::Serialize;
use utoipa::ToSchema;
use crate::LpError;
pub struct PrivateKey(SphinxPrivateKey);
impl Deref for PrivateKey {
type Target = SphinxPrivateKey;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl Default for PrivateKey {
fn default() -> Self {
Self::new()
}
}
impl PrivateKey {
pub fn new() -> Self {
let private_key = SphinxPrivateKey::random();
Self(private_key)
}
pub fn to_base58_string(&self) -> String {
bs58::encode(self.0.to_bytes()).into_string()
}
pub fn from_base58_string(s: &str) -> Result<Self, LpError> {
let bytes: [u8; 32] = bs58::decode(s).into_vec()?.try_into().unwrap();
Ok(PrivateKey(SphinxPrivateKey::from(bytes)))
}
pub fn public_key(&self) -> PublicKey {
let public_key = SphinxPublicKey::from(&self.0);
PublicKey(public_key)
}
}
pub struct PublicKey(SphinxPublicKey);
impl Deref for PublicKey {
type Target = SphinxPublicKey;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl PublicKey {
pub fn to_base58_string(&self) -> String {
bs58::encode(self.0.as_bytes()).into_string()
}
pub fn from_base58_string(s: &str) -> Result<Self, LpError> {
let bytes: [u8; 32] = bs58::decode(s).into_vec()?.try_into().unwrap();
Ok(PublicKey(SphinxPublicKey::from(bytes)))
}
pub fn from_bytes(bytes: &[u8; 32]) -> Result<Self, LpError> {
Ok(PublicKey(SphinxPublicKey::from(*bytes)))
}
pub fn as_bytes(&self) -> &[u8; 32] {
self.0.as_bytes()
}
}
impl Default for PublicKey {
fn default() -> Self {
let private_key = PrivateKey::default();
private_key.public_key()
}
}
pub struct Keypair {
private_key: PrivateKey,
public_key: PublicKey,
}
impl Default for Keypair {
fn default() -> Self {
Self::new()
}
}
impl Keypair {
pub fn new() -> Self {
let private_key = PrivateKey::default();
let public_key = private_key.public_key();
Self {
private_key,
public_key,
}
}
pub fn private_key(&self) -> &PrivateKey {
&self.private_key
}
pub fn public_key(&self) -> &PublicKey {
&self.public_key
}
}
impl From<KeypairReadable> for Keypair {
fn from(keypair: KeypairReadable) -> Self {
Self {
private_key: PrivateKey::from_base58_string(&keypair.private).unwrap(),
public_key: PublicKey::from_base58_string(&keypair.public).unwrap(),
}
}
}
impl From<&Keypair> for KeypairReadable {
fn from(keypair: &Keypair) -> Self {
Self {
private: keypair.private_key.to_base58_string(),
public: keypair.public_key.to_base58_string(),
}
}
}
impl FromStr for PrivateKey {
type Err = LpError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
PrivateKey::from_base58_string(s)
}
}
impl Display for PrivateKey {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_base58_string())
}
}
impl Display for PublicKey {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_base58_string())
}
}
#[derive(Serialize, serde::Deserialize, Clone, ToSchema, Debug)]
pub struct KeypairReadable {
private: String,
public: String,
}
impl KeypairReadable {
pub fn private_key(&self) -> Result<PrivateKey, LpError> {
PrivateKey::from_base58_string(&self.private)
}
pub fn public_key(&self) -> Result<PublicKey, LpError> {
PublicKey::from_base58_string(&self.public)
}
}
+329
View File
@@ -0,0 +1,329 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
pub mod codec;
pub mod error;
pub mod keypair;
pub mod message;
pub mod noise_protocol;
pub mod packet;
pub mod psk;
pub mod replay;
pub mod session;
mod session_integration;
pub mod session_manager;
use std::hash::{DefaultHasher, Hasher as _};
pub use error::LpError;
use keypair::PublicKey;
pub use message::{ClientHelloData, LpMessage};
pub use packet::LpPacket;
pub use psk::derive_psk;
pub use replay::{ReceivingKeyCounterValidator, ReplayError};
pub use session::LpSession;
pub use session_manager::SessionManager;
// Add the new state machine module
pub mod state_machine;
pub use state_machine::LpStateMachine;
pub const NOISE_PATTERN: &str = "Noise_XKpsk3_25519_ChaChaPoly_SHA256";
pub const NOISE_PSK_INDEX: u8 = 3;
#[cfg(test)]
pub fn sessions_for_tests() -> (LpSession, LpSession) {
use crate::{keypair::Keypair, make_lp_id};
let keypair_1 = Keypair::default();
let keypair_2 = Keypair::default();
let id = make_lp_id(keypair_1.public_key(), keypair_2.public_key());
// Use consistent salt for deterministic tests
let salt = [1u8; 32];
// Initiator derives PSK from their perspective
let initiator_psk = derive_psk(keypair_1.private_key(), keypair_2.public_key(), &salt);
let initiator_session = LpSession::new(
id,
true,
&keypair_1.private_key().to_bytes(),
&keypair_2.public_key().to_bytes(),
&initiator_psk,
)
.expect("Test session creation failed");
// Responder derives same PSK from their perspective
let responder_psk = derive_psk(keypair_2.private_key(), keypair_1.public_key(), &salt);
let responder_session = LpSession::new(
id,
false,
&keypair_2.private_key().to_bytes(),
&keypair_1.public_key().to_bytes(),
&responder_psk,
)
.expect("Test session creation failed");
(initiator_session, responder_session)
}
/// Generates a deterministic u32 session ID for the Lewes Protocol
/// based on two public keys. The order of the keys does not matter.
///
/// Uses a different internal delimiter than `make_conv_id` to avoid
/// potential collisions if the same key pairs were used in both contexts.
fn make_id(key1_bytes: &[u8], key2_bytes: &[u8], sep: u8) -> u32 {
let mut hasher = DefaultHasher::new();
// Ensure consistent order for hashing to make the ID order-independent.
// This guarantees make_lp_id(a, b) == make_lp_id(b, a).
if key1_bytes < key2_bytes {
hasher.write(key1_bytes);
// Use a delimiter specific to Lewes Protocol ID generation
// (0xCC chosen arbitrarily, could be any value different from 0xFF)
hasher.write_u8(sep);
hasher.write(key2_bytes);
} else {
hasher.write(key2_bytes);
hasher.write_u8(sep);
hasher.write(key1_bytes);
}
// Truncate the u64 hash result to u32
(hasher.finish() & 0xFFFF_FFFF) as u32
}
pub fn make_lp_id(key1_bytes: &PublicKey, key2_bytes: &PublicKey) -> u32 {
make_id(key1_bytes.as_bytes(), key2_bytes.as_bytes(), 0xCC)
}
pub fn make_conv_id(src: &[u8], dst: &[u8]) -> u32 {
make_id(src, dst, 0xFF)
}
#[cfg(test)]
mod tests {
use crate::keypair::Keypair;
use crate::message::LpMessage;
use crate::packet::{LpHeader, LpPacket, TRAILER_LEN};
use crate::session_manager::SessionManager;
use crate::{make_lp_id, sessions_for_tests, LpError};
use bytes::BytesMut;
// Import the new standalone functions
use crate::codec::{parse_lp_packet, serialize_lp_packet};
#[test]
fn test_replay_protection_integration() {
// Create session
let session = sessions_for_tests().0;
// === Packet 1 (Counter 0 - Should succeed) ===
let packet1 = LpPacket {
header: LpHeader {
protocol_version: 1,
session_id: 42, // Matches session's sending_index assumption for this test
counter: 0,
},
message: LpMessage::Busy,
trailer: [0u8; TRAILER_LEN],
};
// Serialize packet
let mut buf1 = BytesMut::new();
serialize_lp_packet(&packet1, &mut buf1).unwrap();
// Parse packet
let parsed_packet1 = parse_lp_packet(&buf1).unwrap();
// Perform replay check (should pass)
session
.receiving_counter_quick_check(parsed_packet1.header.counter)
.expect("Initial packet failed replay check");
// Mark received (simulating successful processing)
session
.receiving_counter_mark(parsed_packet1.header.counter)
.expect("Failed to mark initial packet received");
// === Packet 2 (Counter 0 - Replay, should fail check) ===
let packet2 = LpPacket {
header: LpHeader {
protocol_version: 1,
session_id: 42,
counter: 0, // Same counter as before (replay)
},
message: LpMessage::Busy,
trailer: [0u8; TRAILER_LEN],
};
// Serialize packet
let mut buf2 = BytesMut::new();
serialize_lp_packet(&packet2, &mut buf2).unwrap();
// Parse packet
let parsed_packet2 = parse_lp_packet(&buf2).unwrap();
// Perform replay check (should fail)
let replay_result = session.receiving_counter_quick_check(parsed_packet2.header.counter);
assert!(replay_result.is_err());
match replay_result.unwrap_err() {
LpError::Replay(e) => {
assert!(matches!(e, crate::replay::ReplayError::DuplicateCounter));
}
e => panic!("Expected replay error, got {:?}", e),
}
// Do not mark received as it failed validation
// === Packet 3 (Counter 1 - Should succeed) ===
let packet3 = LpPacket {
header: LpHeader {
protocol_version: 1,
session_id: 42,
counter: 1, // Incremented counter
},
message: LpMessage::Busy,
trailer: [0u8; TRAILER_LEN],
};
// Serialize packet
let mut buf3 = BytesMut::new();
serialize_lp_packet(&packet3, &mut buf3).unwrap();
// Parse packet
let parsed_packet3 = parse_lp_packet(&buf3).unwrap();
// Perform replay check (should pass)
session
.receiving_counter_quick_check(parsed_packet3.header.counter)
.expect("Packet 3 failed replay check");
// Mark received
session
.receiving_counter_mark(parsed_packet3.header.counter)
.expect("Failed to mark packet 3 received");
// Verify validator state directly on the session
let state = session.current_packet_cnt();
assert_eq!(state.0, 2); // Next expected counter (correct - was 1, now expects 2)
assert_eq!(state.1, 2); // Total marked received (correct - packets 1 and 3)
}
#[test]
fn test_session_manager_integration() {
// Create session manager
let local_manager = SessionManager::new();
let remote_manager = SessionManager::new();
let local_keypair = Keypair::default();
let remote_keypair = Keypair::default();
let lp_id = make_lp_id(local_keypair.public_key(), remote_keypair.public_key());
// Create a session via manager
let _ = local_manager
.create_session_state_machine(
&local_keypair,
remote_keypair.public_key(),
true,
&[2u8; 32],
)
.unwrap();
let _ = remote_manager
.create_session_state_machine(
&remote_keypair,
local_keypair.public_key(),
false,
&[2u8; 32],
)
.unwrap();
// === Packet 1 (Counter 0 - Should succeed) ===
let packet1 = LpPacket {
header: LpHeader {
protocol_version: 1,
session_id: lp_id,
counter: 0,
},
message: LpMessage::Busy,
trailer: [0u8; TRAILER_LEN],
};
// Serialize
let mut buf1 = BytesMut::new();
serialize_lp_packet(&packet1, &mut buf1).unwrap();
// Parse
let parsed_packet1 = parse_lp_packet(&buf1).unwrap();
// Process via SessionManager method (which should handle checks + marking)
// NOTE: We might need a method on SessionManager/LpSession like `process_incoming_packet`
// that encapsulates parse -> check -> process_noise -> mark.
// For now, we simulate the steps using the retrieved session.
// Perform replay check
local_manager
.receiving_counter_quick_check(lp_id, parsed_packet1.header.counter)
.expect("Packet 1 check failed");
// Mark received
local_manager
.receiving_counter_mark(lp_id, parsed_packet1.header.counter)
.expect("Packet 1 mark failed");
// === Packet 2 (Counter 1 - Should succeed on same session) ===
let packet2 = LpPacket {
header: LpHeader {
protocol_version: 1,
session_id: lp_id,
counter: 1,
},
message: LpMessage::Busy,
trailer: [0u8; TRAILER_LEN],
};
// Serialize
let mut buf2 = BytesMut::new();
serialize_lp_packet(&packet2, &mut buf2).unwrap();
// Parse
let parsed_packet2 = parse_lp_packet(&buf2).unwrap();
// Perform replay check
local_manager
.receiving_counter_quick_check(lp_id, parsed_packet2.header.counter)
.expect("Packet 2 check failed");
// Mark received
local_manager
.receiving_counter_mark(lp_id, parsed_packet2.header.counter)
.expect("Packet 2 mark failed");
// === Packet 3 (Counter 0 - Replay, should fail check) ===
let packet3 = LpPacket {
header: LpHeader {
protocol_version: 1,
session_id: lp_id,
counter: 0, // Replay of first packet
},
message: LpMessage::Busy,
trailer: [0u8; TRAILER_LEN],
};
// Serialize
let mut buf3 = BytesMut::new();
serialize_lp_packet(&packet3, &mut buf3).unwrap();
// Parse
let parsed_packet3 = parse_lp_packet(&buf3).unwrap();
// Perform replay check (should fail)
let replay_result =
local_manager.receiving_counter_quick_check(lp_id, parsed_packet3.header.counter);
assert!(replay_result.is_err());
match replay_result.unwrap_err() {
LpError::Replay(e) => {
assert!(matches!(e, crate::replay::ReplayError::DuplicateCounter));
}
e => panic!("Expected replay error for packet 3, got {:?}", e),
}
// Do not mark received
}
}
+246
View File
@@ -0,0 +1,246 @@
use std::fmt::{self, Display};
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use bytes::{BufMut, BytesMut};
use serde::{Deserialize, Serialize};
/// Data structure for the ClientHello message
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientHelloData {
/// Client's LP x25519 public key (32 bytes)
pub client_lp_public_key: [u8; 32],
/// Protocol version for future compatibility
pub protocol_version: u8,
/// Salt for PSK derivation (32 bytes: 8-byte timestamp + 24-byte nonce)
pub salt: [u8; 32],
}
impl ClientHelloData {
/// Generates a new ClientHelloData with fresh salt.
///
/// Salt format: 8 bytes timestamp (u64 LE) + 24 bytes random nonce
///
/// # Arguments
/// * `client_lp_public_key` - Client's x25519 public key
/// * `protocol_version` - Protocol version number
pub fn new_with_fresh_salt(client_lp_public_key: [u8; 32], protocol_version: u8) -> Self {
use std::time::{SystemTime, UNIX_EPOCH};
// Generate salt: timestamp + nonce
let mut salt = [0u8; 32];
// First 8 bytes: current timestamp as u64 little-endian
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("System time before UNIX epoch")
.as_secs();
salt[..8].copy_from_slice(&timestamp.to_le_bytes());
// Last 24 bytes: random nonce
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut salt[8..]);
Self {
client_lp_public_key,
protocol_version,
salt,
}
}
/// Extracts the timestamp from the salt.
///
/// # Returns
/// Unix timestamp in seconds
pub fn extract_timestamp(&self) -> u64 {
let mut timestamp_bytes = [0u8; 8];
timestamp_bytes.copy_from_slice(&self.salt[..8]);
u64::from_le_bytes(timestamp_bytes)
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
#[repr(u16)]
pub enum MessageType {
Busy = 0x0000,
Handshake = 0x0001,
EncryptedData = 0x0002,
ClientHello = 0x0003,
}
impl MessageType {
pub(crate) fn from_u16(value: u16) -> Option<Self> {
match value {
0x0000 => Some(MessageType::Busy),
0x0001 => Some(MessageType::Handshake),
0x0002 => Some(MessageType::EncryptedData),
0x0003 => Some(MessageType::ClientHello),
_ => None,
}
}
pub fn to_u16(&self) -> u16 {
match self {
MessageType::Busy => 0x0000,
MessageType::Handshake => 0x0001,
MessageType::EncryptedData => 0x0002,
MessageType::ClientHello => 0x0003,
}
}
}
#[derive(Debug, Clone)]
pub enum LpMessage {
Busy,
Handshake(Vec<u8>),
EncryptedData(Vec<u8>),
ClientHello(ClientHelloData),
}
impl Display for LpMessage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
LpMessage::Busy => write!(f, "Busy"),
LpMessage::Handshake(_) => write!(f, "Handshake"),
LpMessage::EncryptedData(_) => write!(f, "EncryptedData"),
LpMessage::ClientHello(_) => write!(f, "ClientHello"),
}
}
}
impl LpMessage {
pub fn payload(&self) -> &[u8] {
match self {
LpMessage::Busy => &[],
LpMessage::Handshake(payload) => payload,
LpMessage::EncryptedData(payload) => payload,
LpMessage::ClientHello(_) => &[], // Structured data, serialized in encode_content
}
}
pub fn is_empty(&self) -> bool {
match self {
LpMessage::Busy => true,
LpMessage::Handshake(payload) => payload.is_empty(),
LpMessage::EncryptedData(payload) => payload.is_empty(),
LpMessage::ClientHello(_) => false, // Always has data
}
}
pub fn len(&self) -> usize {
match self {
LpMessage::Busy => 0,
LpMessage::Handshake(payload) => payload.len(),
LpMessage::EncryptedData(payload) => payload.len(),
LpMessage::ClientHello(_) => 65, // 32 bytes key + 1 byte version + 32 bytes salt
}
}
pub fn typ(&self) -> MessageType {
match self {
LpMessage::Busy => MessageType::Busy,
LpMessage::Handshake(_) => MessageType::Handshake,
LpMessage::EncryptedData(_) => MessageType::EncryptedData,
LpMessage::ClientHello(_) => MessageType::ClientHello,
}
}
pub fn encode_content(&self, dst: &mut BytesMut) {
match self {
LpMessage::Busy => { /* No content */ }
LpMessage::Handshake(payload) => {
dst.put_slice(payload);
}
LpMessage::EncryptedData(payload) => {
dst.put_slice(payload);
}
LpMessage::ClientHello(data) => {
// Serialize ClientHelloData using bincode
let serialized =
bincode::serialize(data).expect("Failed to serialize ClientHelloData");
dst.put_slice(&serialized);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::packet::{LpHeader, TRAILER_LEN};
use crate::LpPacket;
#[test]
fn encoding() {
let message = LpMessage::EncryptedData(vec![11u8; 124]);
let resp_header = LpHeader {
protocol_version: 1,
session_id: 0,
counter: 0,
};
let packet = LpPacket {
header: resp_header,
message,
trailer: [80; TRAILER_LEN],
};
// Just print packet for debug, will be captured in test output
println!("{packet:?}");
// Verify message type
assert!(matches!(packet.message.typ(), MessageType::EncryptedData));
// Verify correct data in message
match &packet.message {
LpMessage::EncryptedData(data) => {
assert_eq!(*data, vec![11u8; 124]);
}
_ => panic!("Wrong message type"),
}
}
#[test]
fn test_client_hello_salt_generation() {
let client_key = [1u8; 32];
let hello1 = ClientHelloData::new_with_fresh_salt(client_key, 1);
let hello2 = ClientHelloData::new_with_fresh_salt(client_key, 1);
// Different salts should be generated
assert_ne!(hello1.salt, hello2.salt);
// But timestamps should be very close (within 1 second)
let ts1 = hello1.extract_timestamp();
let ts2 = hello2.extract_timestamp();
assert!((ts1 as i64 - ts2 as i64).abs() <= 1);
}
#[test]
fn test_client_hello_timestamp_extraction() {
let client_key = [2u8; 32];
let hello = ClientHelloData::new_with_fresh_salt(client_key, 1);
let timestamp = hello.extract_timestamp();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
// Timestamp should be within 1 second of now
assert!((timestamp as i64 - now as i64).abs() <= 1);
}
#[test]
fn test_client_hello_salt_format() {
let client_key = [3u8; 32];
let hello = ClientHelloData::new_with_fresh_salt(client_key, 1);
// First 8 bytes should be non-zero timestamp
let timestamp_bytes = &hello.salt[..8];
assert_ne!(timestamp_bytes, &[0u8; 8]);
// Salt should be 32 bytes total
assert_eq!(hello.salt.len(), 32);
}
}
+298
View File
@@ -0,0 +1,298 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Sans-IO Noise protocol state machine, adapted from noise-psq.
use snow::{params::NoiseParams, TransportState};
use thiserror::Error;
// --- Error Definition ---
/// Errors related to the Noise protocol state machine.
#[derive(Error, Debug)]
pub enum NoiseError {
#[error("encountered a Noise decryption error")]
DecryptionError,
#[error("encountered a Noise Protocol error - {0}")]
ProtocolError(snow::Error),
#[error("operation is invalid in the current protocol state")]
IncorrectStateError,
#[error("Other Noise-related error: {0}")]
Other(String),
}
impl From<snow::Error> for NoiseError {
fn from(err: snow::Error) -> Self {
match err {
snow::Error::Decrypt => NoiseError::DecryptionError,
err => NoiseError::ProtocolError(err),
}
}
}
// --- Protocol State and Structs ---
/// Represents the possible states of the Noise protocol machine.
#[derive(Debug)]
pub enum NoiseProtocolState {
/// The protocol is currently performing the handshake.
/// Contains the Snow handshake state.
Handshaking(Box<snow::HandshakeState>),
/// The handshake is complete, and the protocol is in transport mode.
/// Contains the Snow transport state.
Transport(TransportState),
/// The protocol has encountered an unrecoverable error.
/// Stores the error description.
Failed(String),
}
/// The core sans-io Noise protocol state machine.
#[derive(Debug)]
pub struct NoiseProtocol {
state: NoiseProtocolState,
// We might need buffers for incoming/outgoing data later if we add internal buffering
// read_buffer: Vec<u8>,
// write_buffer: Vec<u8>,
}
/// Represents the outcome of processing received bytes via `read_message`.
#[derive(Debug, PartialEq)]
pub enum ReadResult {
/// A handshake or transport message was successfully processed, but yielded no application data
/// and did not complete the handshake.
NoOp,
/// A complete application data message was decrypted.
DecryptedData(Vec<u8>),
/// The handshake successfully completed during this read operation.
HandshakeComplete,
// NOTE: NeedMoreBytes variant removed as read_message expects full frames.
}
// --- Implementation ---
impl NoiseProtocol {
/// Creates a new `NoiseProtocol` instance in the Handshaking state.
///
/// Takes an initialized `snow::HandshakeState` (e.g., from `snow::Builder`).
pub fn new(initial_state: snow::HandshakeState) -> Self {
NoiseProtocol {
state: NoiseProtocolState::Handshaking(Box::new(initial_state)),
}
}
/// Processes a single, complete incoming Noise message frame.
///
/// Assumes the caller handles buffering and framing to provide one full message.
/// Returns the result of processing the message.
pub fn read_message(&mut self, input: &[u8]) -> Result<ReadResult, NoiseError> {
// Allocate a buffer large enough for the maximum possible Noise message size.
// TODO: Consider reusing a buffer for efficiency.
let mut buffer = vec![0u8; 65535]; // Max Noise message size
match &mut self.state {
NoiseProtocolState::Handshaking(handshake_state) => {
match handshake_state.read_message(input, &mut buffer) {
Ok(_) => {
if handshake_state.is_handshake_finished() {
// Transition to Transport state.
let current_state = std::mem::replace(
&mut self.state,
// Temporary placeholder needed for mem::replace
NoiseProtocolState::Failed(
NoiseError::IncorrectStateError.to_string(),
),
);
if let NoiseProtocolState::Handshaking(state_to_convert) = current_state
{
match state_to_convert.into_transport_mode() {
Ok(transport_state) => {
self.state = NoiseProtocolState::Transport(transport_state);
Ok(ReadResult::HandshakeComplete)
}
Err(e) => {
let err = NoiseError::from(e);
self.state = NoiseProtocolState::Failed(err.to_string());
Err(err)
}
}
} else {
// Should be unreachable
let err = NoiseError::IncorrectStateError;
self.state = NoiseProtocolState::Failed(err.to_string());
Err(err)
}
} else {
// Handshake continues
Ok(ReadResult::NoOp)
}
}
Err(e) => {
let err = NoiseError::from(e);
self.state = NoiseProtocolState::Failed(err.to_string());
Err(err)
}
}
}
NoiseProtocolState::Transport(transport_state) => {
match transport_state.read_message(input, &mut buffer) {
Ok(len) => Ok(ReadResult::DecryptedData(buffer[..len].to_vec())),
Err(e) => {
let err = NoiseError::from(e);
self.state = NoiseProtocolState::Failed(err.to_string());
Err(err)
}
}
}
NoiseProtocolState::Failed(_) => Err(NoiseError::IncorrectStateError),
}
}
/// Checks if there are pending handshake messages to send.
///
/// If in Handshaking state and it's our turn, generates the message.
/// Transitions state to Transport if the handshake completes after this message.
/// Returns `None` if not in Handshaking state or not our turn.
pub fn get_bytes_to_send(&mut self) -> Option<Result<Vec<u8>, NoiseError>> {
match &mut self.state {
NoiseProtocolState::Handshaking(handshake_state) => {
if handshake_state.is_my_turn() {
let mut buffer = vec![0u8; 65535];
match handshake_state.write_message(&[], &mut buffer) {
// Empty payload for handshake msg
Ok(len) => {
if handshake_state.is_handshake_finished() {
// Transition to Transport state.
let current_state = std::mem::replace(
&mut self.state,
NoiseProtocolState::Failed(
NoiseError::IncorrectStateError.to_string(),
),
);
if let NoiseProtocolState::Handshaking(state_to_convert) =
current_state
{
match state_to_convert.into_transport_mode() {
Ok(transport_state) => {
self.state =
NoiseProtocolState::Transport(transport_state);
Some(Ok(buffer[..len].to_vec())) // Return final handshake msg
}
Err(e) => {
let err = NoiseError::from(e);
self.state =
NoiseProtocolState::Failed(err.to_string());
Some(Err(err))
}
}
} else {
// Should be unreachable
let err = NoiseError::IncorrectStateError;
self.state = NoiseProtocolState::Failed(err.to_string());
Some(Err(err))
}
} else {
// Handshake continues
Some(Ok(buffer[..len].to_vec()))
}
}
Err(e) => {
let err = NoiseError::from(e);
self.state = NoiseProtocolState::Failed(err.to_string());
Some(Err(err))
}
}
} else {
// Not our turn
None
}
}
NoiseProtocolState::Transport(_) | NoiseProtocolState::Failed(_) => {
// No handshake messages to send in these states
None
}
}
}
/// Encrypts an application data payload for sending during the Transport phase.
///
/// Returns the ciphertext (payload + 16-byte tag).
/// Errors if not in Transport state or encryption fails.
pub fn write_message(&mut self, payload: &[u8]) -> Result<Vec<u8>, NoiseError> {
match &mut self.state {
NoiseProtocolState::Transport(transport_state) => {
let mut buffer = vec![0u8; payload.len() + 16]; // Payload + tag
match transport_state.write_message(payload, &mut buffer) {
Ok(len) => Ok(buffer[..len].to_vec()),
Err(e) => {
let err = NoiseError::from(e);
self.state = NoiseProtocolState::Failed(err.to_string());
Err(err)
}
}
}
NoiseProtocolState::Handshaking(_) | NoiseProtocolState::Failed(_) => {
Err(NoiseError::IncorrectStateError)
}
}
}
/// Returns true if the protocol is in the transport phase (handshake complete).
pub fn is_transport(&self) -> bool {
matches!(self.state, NoiseProtocolState::Transport(_))
}
/// Returns true if the protocol has failed.
pub fn is_failed(&self) -> bool {
matches!(self.state, NoiseProtocolState::Failed(_))
}
/// Check if the handshake has finished and the protocol is in transport mode.
pub fn is_handshake_finished(&self) -> bool {
matches!(self.state, NoiseProtocolState::Transport(_))
}
}
pub fn create_noise_state(
local_private_key: &[u8],
remote_public_key: &[u8],
psk: &[u8],
) -> Result<NoiseProtocol, NoiseError> {
let pattern_name = "Noise_XKpsk3_25519_ChaChaPoly_SHA256";
let psk_index = 3;
let noise_params: NoiseParams = pattern_name.parse().unwrap();
let builder = snow::Builder::new(noise_params.clone());
// Using dummy remote key as it's not needed for state creation itself
// In a real scenario, the key would depend on initiator/responder role
let handshake_state = builder
.local_private_key(local_private_key)
.remote_public_key(remote_public_key) // Use own public as dummy remote
.psk(psk_index, psk)
.build_initiator()?;
Ok(NoiseProtocol::new(handshake_state))
}
pub fn create_noise_state_responder(
local_private_key: &[u8],
remote_public_key: &[u8],
psk: &[u8],
) -> Result<NoiseProtocol, NoiseError> {
let pattern_name = "Noise_XKpsk3_25519_ChaChaPoly_SHA256";
let psk_index = 3;
let noise_params: NoiseParams = pattern_name.parse().unwrap();
let builder = snow::Builder::new(noise_params.clone());
// Using dummy remote key as it's not needed for state creation itself
// In a real scenario, the key would depend on initiator/responder role
let handshake_state = builder
.local_private_key(local_private_key)
.remote_public_key(remote_public_key) // Use own public as dummy remote
.psk(psk_index, psk)
.build_responder()?;
Ok(NoiseProtocol::new(handshake_state))
}
+195
View File
@@ -0,0 +1,195 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::message::LpMessage;
use crate::replay::ReceivingKeyCounterValidator;
use crate::LpError;
use bytes::{BufMut, BytesMut};
use nym_lp_common::format_debug_bytes;
use parking_lot::Mutex;
use std::fmt::Write;
use std::fmt::{Debug, Formatter};
use std::sync::Arc;
#[allow(dead_code)]
pub(crate) const UDP_HEADER_LEN: usize = 8;
#[allow(dead_code)]
pub(crate) const IP_HEADER_LEN: usize = 40; // v4 - 20, v6 - 40
#[allow(dead_code)]
pub(crate) const MTU: usize = 1500;
#[allow(dead_code)]
pub(crate) const UDP_OVERHEAD: usize = UDP_HEADER_LEN + IP_HEADER_LEN;
#[allow(dead_code)]
pub const TRAILER_LEN: usize = 16;
#[allow(dead_code)]
pub(crate) const UDP_PAYLOAD_SIZE: usize = MTU - UDP_OVERHEAD - TRAILER_LEN;
#[derive(Clone)]
pub struct LpPacket {
pub(crate) header: LpHeader,
pub(crate) message: LpMessage,
pub(crate) trailer: [u8; TRAILER_LEN],
}
impl Debug for LpPacket {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", format_debug_bytes(&self.debug_bytes())?)
}
}
impl LpPacket {
pub fn new(header: LpHeader, message: LpMessage) -> Self {
Self {
header,
message,
trailer: [0; TRAILER_LEN],
}
}
/// Compute a hash of the message payload
///
/// This can be used for message integrity verification or deduplication
pub fn hash_payload(&self) -> [u8; 32] {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
let mut buffer = BytesMut::new();
// Include message type and content in the hash
buffer.put_slice(&(self.message.typ() as u16).to_le_bytes());
self.message.encode_content(&mut buffer);
hasher.update(&buffer);
hasher.finalize().into()
}
pub fn hash_payload_hex(&self) -> String {
let hash = self.hash_payload();
hash.iter()
.fold(String::with_capacity(hash.len() * 2), |mut acc, byte| {
let _ = write!(acc, "{:02x}", byte);
acc
})
}
pub fn message(&self) -> &LpMessage {
&self.message
}
pub fn header(&self) -> &LpHeader {
&self.header
}
pub(crate) fn debug_bytes(&self) -> Vec<u8> {
let mut bytes = BytesMut::new();
self.encode(&mut bytes);
bytes.freeze().to_vec()
}
pub(crate) fn encode(&self, dst: &mut BytesMut) {
self.header.encode(dst);
dst.put_slice(&(self.message.typ() as u16).to_le_bytes());
self.message.encode_content(dst);
dst.put_slice(&self.trailer)
}
/// 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.
pub fn validate_counter(
&self,
validator: &Arc<Mutex<ReceivingKeyCounterValidator>>,
) -> Result<(), LpError> {
let guard = validator.lock();
guard.will_accept_branchless(self.header.counter)?;
Ok(())
}
/// Mark packet as received in the replay protection validator
///
/// This should be called after a packet has been successfully processed.
pub fn mark_received(
&self,
validator: &Arc<Mutex<ReceivingKeyCounterValidator>>,
) -> Result<(), LpError> {
let mut guard = validator.lock();
guard.mark_did_receive_branchless(self.header.counter)?;
Ok(())
}
}
// VERSION [1B] || RESERVED [3B] || SENDER_INDEX [4B] || COUNTER [8B]
#[derive(Debug, Clone)]
pub struct LpHeader {
pub protocol_version: u8,
pub session_id: u32,
pub counter: u64,
}
impl LpHeader {
pub const SIZE: usize = 16;
}
impl LpHeader {
pub fn new(session_id: u32, counter: u64) -> Self {
Self {
protocol_version: 1,
session_id,
counter,
}
}
pub fn encode(&self, dst: &mut BytesMut) {
// protocol version
dst.put_u8(self.protocol_version);
// reserved
dst.put_slice(&[0, 0, 0]);
// sender index
dst.put_slice(&self.session_id.to_le_bytes());
// counter
dst.put_slice(&self.counter.to_le_bytes());
}
pub fn parse(src: &[u8]) -> Result<Self, LpError> {
if src.len() < Self::SIZE {
return Err(LpError::InsufficientBufferSize);
}
let protocol_version = src[0];
// Skip reserved bytes [1..4]
let mut session_id_bytes = [0u8; 4];
session_id_bytes.copy_from_slice(&src[4..8]);
let session_id = u32::from_le_bytes(session_id_bytes);
let mut counter_bytes = [0u8; 8];
counter_bytes.copy_from_slice(&src[8..16]);
let counter = u64::from_le_bytes(counter_bytes);
Ok(LpHeader {
protocol_version,
session_id,
counter,
})
}
/// Get the counter value from the header
pub fn counter(&self) -> u64 {
self.counter
}
/// Get the sender index from the header
pub fn session_id(&self) -> u32 {
self.session_id
}
}
// subsequent data: MessageType || Data
+136
View File
@@ -0,0 +1,136 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! PSK (Pre-Shared Key) derivation for LP sessions using Blake3 KDF.
//!
//! This module implements identity-bound PSK derivation where both client and gateway
//! derive the same PSK from their LP keypairs using ECDH + Blake3 KDF.
use crate::keypair::{PrivateKey, PublicKey};
/// Context string for Blake3 KDF domain separation.
const PSK_CONTEXT: &str = "nym-lp-psk-v1";
/// Derives a PSK using Blake3 KDF from local private key, remote public key, and salt.
///
/// # Formula
/// ```text
/// shared_secret = ECDH(local_private, remote_public)
/// psk = Blake3_derive_key(context="nym-lp-psk-v1", input=shared_secret || salt)
/// ```
///
/// # Properties
/// - **Identity-bound**: PSK is tied to the LP keypairs of both parties
/// - **Session-specific**: Different salts produce different PSKs
/// - **Symmetric**: Both sides derive the same PSK from their respective keys
///
/// # Arguments
/// * `local_private` - This side's LP private key
/// * `remote_public` - Peer's LP public key
/// * `salt` - 32-byte salt (timestamp + nonce from ClientHello)
///
/// # Returns
/// 32-byte PSK suitable for Noise protocol
///
/// # Example
/// ```ignore
/// // Client side
/// let client_private = client_keypair.private_key();
/// let gateway_public = gateway_keypair.public_key();
/// let salt = ClientHelloData::new_with_fresh_salt(...).salt;
/// let psk = derive_psk(&client_private, &gateway_public, &salt);
///
/// // Gateway side (derives same PSK)
/// let gateway_private = gateway_keypair.private_key();
/// let client_public = /* from ClientHello */;
/// let psk = derive_psk(&gateway_private, &client_public, &salt);
/// ```
pub fn derive_psk(
local_private: &PrivateKey,
remote_public: &PublicKey,
salt: &[u8; 32],
) -> [u8; 32] {
// Perform ECDH to get shared secret
let shared_secret = local_private.diffie_hellman(remote_public);
// Derive PSK using Blake3 KDF with domain separation
nym_crypto::kdf::derive_key_blake3(PSK_CONTEXT, shared_secret.as_bytes(), salt)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::keypair::Keypair;
#[test]
fn test_psk_derivation_is_deterministic() {
let keypair_1 = Keypair::default();
let keypair_2 = Keypair::default();
let salt = [1u8; 32];
// Derive PSK twice with same inputs
let psk1 = derive_psk(keypair_1.private_key(), keypair_2.public_key(), &salt);
let psk2 = derive_psk(keypair_1.private_key(), keypair_2.public_key(), &salt);
assert_eq!(psk1, psk2, "Same inputs should produce same PSK");
}
#[test]
fn test_psk_derivation_is_symmetric() {
let keypair_1 = Keypair::default();
let keypair_2 = Keypair::default();
let salt = [2u8; 32];
// Client derives PSK
let client_psk = derive_psk(keypair_1.private_key(), keypair_2.public_key(), &salt);
// Gateway derives PSK from their perspective
let gateway_psk = derive_psk(keypair_2.private_key(), keypair_1.public_key(), &salt);
assert_eq!(
client_psk, gateway_psk,
"Both sides should derive identical PSK"
);
}
#[test]
fn test_different_salts_produce_different_psks() {
let keypair_1 = Keypair::default();
let keypair_2 = Keypair::default();
let salt1 = [1u8; 32];
let salt2 = [2u8; 32];
let psk1 = derive_psk(keypair_1.private_key(), keypair_2.public_key(), &salt1);
let psk2 = derive_psk(keypair_1.private_key(), keypair_2.public_key(), &salt2);
assert_ne!(psk1, psk2, "Different salts should produce different PSKs");
}
#[test]
fn test_different_keys_produce_different_psks() {
let keypair_1 = Keypair::default();
let keypair_2 = Keypair::default();
let keypair_3 = Keypair::default();
let salt = [3u8; 32];
let psk1 = derive_psk(keypair_1.private_key(), keypair_2.public_key(), &salt);
let psk2 = derive_psk(keypair_1.private_key(), keypair_3.public_key(), &salt);
assert_ne!(
psk1, psk2,
"Different remote keys should produce different PSKs"
);
}
#[test]
fn test_psk_output_length() {
let keypair_1 = Keypair::default();
let keypair_2 = Keypair::default();
let salt = [4u8; 32];
let psk = derive_psk(keypair_1.private_key(), keypair_2.public_key(), &salt);
assert_eq!(psk.len(), 32, "PSK should be exactly 32 bytes");
}
}
+68
View File
@@ -0,0 +1,68 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Error types for replay protection.
use thiserror::Error;
/// Errors that can occur during replay protection validation.
#[derive(Debug, Error)]
pub enum ReplayError {
/// The counter value is invalid (e.g., too far in the future)
#[error("Invalid counter value")]
InvalidCounter,
/// The packet has already been received (replay attack)
#[error("Duplicate counter value")]
DuplicateCounter,
/// The packet is outside the replay window
#[error("Packet outside replay window")]
OutOfWindow,
}
/// Result type for replay protection operations
pub type ReplayResult<T> = Result<T, ReplayError>;
#[cfg(test)]
mod tests {
use super::*;
use crate::error::LpError;
#[test]
fn test_replay_error_variants() {
let invalid = ReplayError::InvalidCounter;
let duplicate = ReplayError::DuplicateCounter;
let out_of_window = ReplayError::OutOfWindow;
assert_eq!(invalid.to_string(), "Invalid counter value");
assert_eq!(duplicate.to_string(), "Duplicate counter value");
assert_eq!(out_of_window.to_string(), "Packet outside replay window");
}
#[test]
fn test_replay_error_conversion() {
let replay_error = ReplayError::InvalidCounter;
let lp_error: LpError = replay_error.into();
match lp_error {
LpError::Replay(e) => {
assert!(matches!(e, ReplayError::InvalidCounter));
}
_ => panic!("Expected Replay variant"),
}
}
#[test]
fn test_replay_result() {
let ok_result: ReplayResult<()> = Ok(());
let err_result: ReplayResult<()> = Err(ReplayError::InvalidCounter);
assert!(ok_result.is_ok());
assert!(err_result.is_err());
assert!(matches!(
err_result.unwrap_err(),
ReplayError::InvalidCounter
));
}
}
+15
View File
@@ -0,0 +1,15 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Replay protection module for the Lewes Protocol.
//!
//! This module implements BoringTun-style replay protection to prevent
//! replay attacks and ensure packet ordering. It uses a bitmap-based
//! approach to track received packets and validate their sequence.
pub mod error;
pub mod simd;
pub mod validator;
pub use error::ReplayError;
pub use validator::ReceivingKeyCounterValidator;
+278
View File
@@ -0,0 +1,278 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! ARM NEON implementation of bitmap operations.
use super::BitmapOps;
#[cfg(target_feature = "neon")]
use std::arch::aarch64::{vceqq_u64, vdupq_n_u64, vgetq_lane_u64, vld1q_u64, vst1q_u64};
/// ARM NEON bitmap operations implementation
pub struct ArmBitmapOps;
impl BitmapOps for ArmBitmapOps {
#[inline(always)]
fn clear_words(bitmap: &mut [u64], start_idx: usize, num_words: usize) {
debug_assert!(start_idx + num_words <= bitmap.len());
#[cfg(target_feature = "neon")]
unsafe {
// Process 2 words at a time with NEON
// Safety:
// - vdupq_n_u64 is safe to call with any u64 value
let zero_vec = vdupq_n_u64(0);
let mut idx = start_idx;
let end_idx = start_idx + num_words;
// Process aligned blocks of 2 words
while idx + 2 <= end_idx {
// Safety:
// - bitmap[idx..] is valid for reads/writes of at least 2 u64 words (16 bytes)
// - We've validated with the debug_assert that start_idx + num_words <= bitmap.len()
// - We check that idx + 2 <= end_idx to ensure we have 2 complete words
vst1q_u64(bitmap[idx..].as_mut_ptr(), zero_vec);
idx += 2;
}
// Handle remaining words (0 or 1)
while idx < end_idx {
bitmap[idx] = 0;
idx += 1;
}
}
#[cfg(not(target_feature = "neon"))]
{
// Fallback to scalar implementation
for i in start_idx..(start_idx + num_words) {
bitmap[i] = 0;
}
}
}
#[inline(always)]
fn is_range_zero(bitmap: &[u64], start_idx: usize, num_words: usize) -> bool {
debug_assert!(start_idx + num_words <= bitmap.len());
#[cfg(target_feature = "neon")]
unsafe {
// Process 2 words at a time with NEON
// Safety:
// - vdupq_n_u64 is safe to call with any u64 value
let zero_vec = vdupq_n_u64(0);
let mut idx = start_idx;
let end_idx = start_idx + num_words;
// Process aligned blocks of 2 words
while idx + 2 <= end_idx {
// Safety:
// - bitmap[idx..] is valid for reads of at least 2 u64 words (16 bytes)
// - We've validated with the debug_assert that start_idx + num_words <= bitmap.len()
// - We check that idx + 2 <= end_idx to ensure we have 2 complete words
let data_vec = vld1q_u64(bitmap[idx..].as_ptr());
// Safety:
// - vceqq_u64 is safe when given valid vector values from vld1q_u64 and vdupq_n_u64
// - vgetq_lane_u64 is safe with valid indices (0 and 1) for a 2-lane vector
let cmp_result = vceqq_u64(data_vec, zero_vec);
let mask1 = vgetq_lane_u64(cmp_result, 0);
let mask2 = vgetq_lane_u64(cmp_result, 1);
if (mask1 & mask2) != u64::MAX {
return false;
}
idx += 2;
}
// Handle remaining words (0 or 1)
while idx < end_idx {
if bitmap[idx] != 0 {
return false;
}
idx += 1;
}
true
}
#[cfg(not(target_feature = "neon"))]
{
// Fallback to scalar implementation
bitmap[start_idx..(start_idx + num_words)]
.iter()
.all(|&w| w == 0)
}
}
#[inline(always)]
fn set_bit(bitmap: &mut [u64], bit_idx: u64) {
let word_idx = (bit_idx / 64) as usize;
let bit_pos = bit_idx % 64;
bitmap[word_idx] |= 1u64 << bit_pos;
}
#[inline(always)]
fn clear_bit(bitmap: &mut [u64], bit_idx: u64) {
let word_idx = (bit_idx / 64) as usize;
let bit_pos = bit_idx % 64;
bitmap[word_idx] &= !(1u64 << bit_pos);
}
#[inline(always)]
fn check_bit(bitmap: &[u64], bit_idx: u64) -> bool {
let word_idx = (bit_idx / 64) as usize;
let bit_pos = bit_idx % 64;
(bitmap[word_idx] & (1u64 << bit_pos)) != 0
}
}
/// We also implement optimized versions for specific operations that could
/// benefit from NEON but don't fit the general trait pattern
///
/// Atomic operations for the bitmap
pub mod atomic {
#[cfg(target_feature = "neon")]
use std::arch::aarch64::{vdupq_n_u64, vld1q_u64, vorrq_u64, vst1q_u64};
/// Check and set bit, returning the previous state
/// This function is not actually atomic! It's just a non-atomic optimization
/// For actual atomic operations, the caller must provide proper synchronization
#[inline(always)]
pub fn check_and_set_bit(bitmap: &mut [u64], bit_idx: u64) -> bool {
let word_idx = (bit_idx / 64) as usize;
let bit_pos = bit_idx % 64;
let mask = 1u64 << bit_pos;
// Get old value
let old_word = bitmap[word_idx];
// Set bit regardless of current state
bitmap[word_idx] |= mask;
// Return true if bit was already set (duplicate)
(old_word & mask) != 0
}
/// Set a range of bits efficiently using NEON
///
/// # Safety
///
/// This function is unsafe because it:
/// - Uses SIMD intrinsics that require the NEON CPU feature to be available
/// - Accesses bitmap memory through raw pointers
/// - Does not perform bounds checking beyond what's required for SIMD operations
///
/// Caller must ensure:
/// - The NEON feature is available on the current CPU
/// - `bitmap` has sufficient size to hold indices up to `end_bit/64`
/// - `start_bit` and `end_bit` are valid bit indices within the bitmap
/// - No other thread is concurrently modifying the same memory
#[inline(always)]
#[cfg(target_feature = "neon")]
pub unsafe fn set_bits_range(bitmap: &mut [u64], start_bit: u64, end_bit: u64) {
// Process whole words where possible
let start_word = (start_bit / 64) as usize;
let end_word = (end_bit / 64) as usize;
if start_word == end_word {
// Special case: all bits in the same word
let start_mask = u64::MAX << (start_bit % 64);
let end_mask = u64::MAX >> (63 - (end_bit % 64));
bitmap[start_word] |= start_mask & end_mask;
return;
}
// Handle partial words at the beginning and end
if start_bit % 64 != 0 {
let start_mask = u64::MAX << (start_bit % 64);
bitmap[start_word] |= start_mask;
}
if (end_bit + 1) % 64 != 0 {
let end_mask = u64::MAX >> (63 - (end_bit % 64));
bitmap[end_word] |= end_mask;
}
// Handle complete words in the middle using NEON
let first_full_word = if start_bit % 64 == 0 {
start_word
} else {
start_word + 1
};
let last_full_word = if (end_bit + 1) % 64 == 0 {
end_word
} else {
end_word - 1
};
if first_full_word <= last_full_word {
// Use NEON to set words faster
// Safety: vdupq_n_u64 is safe to call with any u64 value
let ones_vec = vdupq_n_u64(u64::MAX);
let mut idx = first_full_word;
while idx + 2 <= last_full_word + 1 {
// Safety:
// - bitmap[idx..] is valid for reads/writes of at least 2 u64 words (16 bytes)
// - We check that idx + 2 <= last_full_word + 1 to ensure we have 2 complete words
let current_vec = vld1q_u64(bitmap[idx..].as_ptr());
// Safety: vorrq_u64 is safe when given valid vector values
let result_vec = vorrq_u64(current_vec, ones_vec);
vst1q_u64(bitmap[idx..].as_mut_ptr(), result_vec);
idx += 2;
}
// Handle remaining words
while idx <= last_full_word {
bitmap[idx] = u64::MAX;
idx += 1;
}
}
}
/// Set a range of bits efficiently (scalar fallback)
#[inline(always)]
#[cfg(not(target_feature = "neon"))]
pub fn set_bits_range(bitmap: &mut [u64], start_bit: u64, end_bit: u64) {
// Process whole words where possible
let start_word = (start_bit / 64) as usize;
let end_word = (end_bit / 64) as usize;
if start_word == end_word {
// Special case: all bits in the same word
let start_mask = u64::MAX << (start_bit % 64);
let end_mask = u64::MAX >> (63 - (end_bit % 64));
bitmap[start_word] |= start_mask & end_mask;
return;
}
// Handle partial words at the beginning and end
if start_bit % 64 != 0 {
let start_mask = u64::MAX << (start_bit % 64);
bitmap[start_word] |= start_mask;
}
if (end_bit + 1) % 64 != 0 {
let end_mask = u64::MAX >> (63 - (end_bit % 64));
bitmap[end_word] |= end_mask;
}
// Handle complete words in the middle
let first_full_word = if start_bit % 64 == 0 {
start_word
} else {
start_word + 1
};
let last_full_word = if (end_bit + 1) % 64 == 0 {
end_word
} else {
end_word - 1
};
for word_idx in first_full_word..=last_full_word {
bitmap[word_idx] = u64::MAX;
}
}
}
+71
View File
@@ -0,0 +1,71 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! SIMD optimizations for the replay protection bitmap operations.
//!
//! This module provides architecture-specific SIMD implementations with a common interface.
// Re-export the appropriate implementation
#[cfg(target_arch = "x86_64")]
mod x86;
#[cfg(target_arch = "x86_64")]
pub use self::x86::*;
#[cfg(target_arch = "aarch64")]
mod arm;
#[cfg(target_arch = "aarch64")]
pub use self::arm::*;
// Fallback scalar implementation for all other architectures
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
mod scalar;
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
pub use self::scalar::*;
/// Trait defining SIMD operations for bitmap manipulation
pub trait BitmapOps {
/// Clear a range of words in the bitmap
fn clear_words(bitmap: &mut [u64], start_idx: usize, num_words: usize);
/// Check if a range of words in the bitmap is all zeros
fn is_range_zero(bitmap: &[u64], start_idx: usize, num_words: usize) -> bool;
/// Set a specific bit in the bitmap
fn set_bit(bitmap: &mut [u64], bit_idx: u64);
/// Clear a specific bit in the bitmap
fn clear_bit(bitmap: &mut [u64], bit_idx: u64);
/// Check if a specific bit is set in the bitmap
fn check_bit(bitmap: &[u64], bit_idx: u64) -> bool;
}
/// Get the optimal number of words to process in a SIMD operation
/// for the current architecture
#[inline(always)]
pub fn optimal_simd_width() -> usize {
// This value is specialized for each architecture in their respective modules
OPTIMAL_SIMD_WIDTH
}
/// Constant indicating the optimal SIMD processing width in number of u64 words
/// for the current architecture
#[cfg(target_arch = "x86_64")]
#[cfg(target_feature = "avx2")]
pub const OPTIMAL_SIMD_WIDTH: usize = 4; // 256 bits = 4 u64 words
#[cfg(target_arch = "x86_64")]
#[cfg(all(not(target_feature = "avx2"), target_feature = "sse2"))]
pub const OPTIMAL_SIMD_WIDTH: usize = 2; // 128 bits = 2 u64 words
#[cfg(target_arch = "aarch64")]
#[cfg(target_feature = "neon")]
pub const OPTIMAL_SIMD_WIDTH: usize = 2; // 128 bits = 2 u64 words
// Fallback for non-SIMD platforms or when features aren't available
#[cfg(not(any(
all(target_arch = "x86_64", target_feature = "avx2"),
all(target_arch = "x86_64", target_feature = "sse2"),
all(target_arch = "aarch64", target_feature = "neon")
)))]
pub const OPTIMAL_SIMD_WIDTH: usize = 1; // Scalar fallback
+114
View File
@@ -0,0 +1,114 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Scalar (non-SIMD) implementation of bitmap operations.
//! Used as a fallback when SIMD instructions are unavailable.
use super::BitmapOps;
/// Scalar (non-SIMD) bitmap operations implementation
pub struct ScalarBitmapOps;
impl BitmapOps for ScalarBitmapOps {
#[inline(always)]
fn clear_words(bitmap: &mut [u64], start_idx: usize, num_words: usize) {
for i in start_idx..(start_idx + num_words) {
bitmap[i] = 0;
}
}
#[inline(always)]
fn is_range_zero(bitmap: &[u64], start_idx: usize, num_words: usize) -> bool {
for i in start_idx..(start_idx + num_words) {
if bitmap[i] != 0 {
return false;
}
}
true
}
#[inline(always)]
fn set_bit(bitmap: &mut [u64], bit_idx: u64) {
let word_idx = (bit_idx / 64) as usize;
let bit_pos = (bit_idx % 64) as u64;
bitmap[word_idx] |= 1u64 << bit_pos;
}
#[inline(always)]
fn clear_bit(bitmap: &mut [u64], bit_idx: u64) {
let word_idx = (bit_idx / 64) as usize;
let bit_pos = (bit_idx % 64) as u64;
bitmap[word_idx] &= !(1u64 << bit_pos);
}
#[inline(always)]
fn check_bit(bitmap: &[u64], bit_idx: u64) -> bool {
let word_idx = (bit_idx / 64) as usize;
let bit_pos = (bit_idx % 64) as u64;
(bitmap[word_idx] & (1u64 << bit_pos)) != 0
}
}
/// Scalar implementations of other bitmap utilities
pub mod atomic {
/// Check and set bit, returning the previous state
/// This function is not actually atomic! It's just a normal operation
#[inline(always)]
pub fn check_and_set_bit(bitmap: &mut [u64], bit_idx: u64) -> bool {
let word_idx = (bit_idx / 64) as usize;
let bit_pos = (bit_idx % 64) as u64;
let mask = 1u64 << bit_pos;
// Get old value
let old_word = bitmap[word_idx];
// Set bit regardless of current state
bitmap[word_idx] |= mask;
// Return true if bit was already set (duplicate)
(old_word & mask) != 0
}
/// Set a range of bits efficiently
#[inline(always)]
pub fn set_bits_range(bitmap: &mut [u64], start_bit: u64, end_bit: u64) {
// Process whole words where possible
let start_word = (start_bit / 64) as usize;
let end_word = (end_bit / 64) as usize;
if start_word == end_word {
// Special case: all bits in the same word
let start_mask = u64::MAX << (start_bit % 64);
let end_mask = u64::MAX >> (63 - (end_bit % 64));
bitmap[start_word] |= start_mask & end_mask;
return;
}
// Handle partial words at the beginning and end
if start_bit % 64 != 0 {
let start_mask = u64::MAX << (start_bit % 64);
bitmap[start_word] |= start_mask;
}
if (end_bit + 1) % 64 != 0 {
let end_mask = u64::MAX >> (63 - (end_bit % 64));
bitmap[end_word] |= end_mask;
}
// Handle complete words in the middle
let first_full_word = if start_bit % 64 == 0 {
start_word
} else {
start_word + 1
};
let last_full_word = if (end_bit + 1) % 64 == 0 {
end_word
} else {
end_word - 1
};
for word_idx in first_full_word..=last_full_word {
bitmap[word_idx] = u64::MAX;
}
}
}
+489
View File
@@ -0,0 +1,489 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! x86/x86_64 SIMD implementation of bitmap operations.
//! Provides optimized implementations using SSE2 and AVX2 intrinsics.
use super::BitmapOps;
// Track execution counts for debugging
static mut AVX2_CLEAR_COUNT: usize = 0;
static mut SSE2_CLEAR_COUNT: usize = 0;
static mut SCALAR_CLEAR_COUNT: usize = 0;
// Import the appropriate SIMD intrinsics
#[cfg(target_feature = "avx2")]
use std::arch::x86_64::{
__m256i, _mm256_cmpeq_epi64, _mm256_load_si256, _mm256_loadu_si256, _mm256_movemask_epi8,
_mm256_or_si256, _mm256_set1_epi64x, _mm256_setzero_si256, _mm256_store_si256,
_mm256_storeu_si256, _mm256_testz_si256,
};
#[cfg(target_feature = "sse2")]
use std::arch::x86_64::{
__m128i, _mm_cmpeq_epi64, _mm_load_si128, _mm_loadu_si128, _mm_movemask_epi8, _mm_or_si128,
_mm_set1_epi64x, _mm_setzero_si128, _mm_store_si128, _mm_storeu_si128, _mm_testz_si128,
};
/// x86/x86_64 SIMD bitmap operations implementation
pub struct X86BitmapOps;
impl BitmapOps for X86BitmapOps {
#[inline(always)]
fn clear_words(bitmap: &mut [u64], start_idx: usize, num_words: usize) {
debug_assert!(start_idx + num_words <= bitmap.len());
// First try AVX2 (256-bit, 4 words at a time)
#[cfg(target_feature = "avx2")]
unsafe {
// Track execution count
AVX2_CLEAR_COUNT += 1;
// Process 4 words at a time with AVX2
let zero_vec = _mm256_setzero_si256();
let mut idx = start_idx;
let end_idx = start_idx + num_words;
// Process aligned blocks of 4 words
while idx + 4 <= end_idx {
// Safety:
// - bitmap[idx..] is valid for reads/writes of at least 4 u64 words (32 bytes)
// - We've validated with the debug_assert that start_idx + num_words <= bitmap.len()
// - We check that idx + 4 <= end_idx to ensure we have 4 complete words
// - The unaligned _storeu_ variant is used to handle any alignment
_mm256_storeu_si256(bitmap[idx..].as_mut_ptr() as *mut __m256i, zero_vec);
idx += 4;
}
// Handle remaining words with SSE2 or scalar ops
if idx < end_idx {
if idx + 2 <= end_idx {
// Use SSE2 for 2 words
// Safety: Same as above, but for 2 words (16 bytes) instead of 4
let sse_zero = _mm_setzero_si128();
_mm_storeu_si128(bitmap[idx..].as_mut_ptr() as *mut __m128i, sse_zero);
idx += 2;
}
// Handle any remaining words
while idx < end_idx {
bitmap[idx] = 0;
idx += 1;
}
}
return;
}
// If AVX2 is unavailable, try SSE2 (128-bit, 2 words at a time)
#[cfg(all(target_feature = "sse2", not(target_feature = "avx2")))]
unsafe {
// Track execution count
SSE2_CLEAR_COUNT += 1;
// Process 2 words at a time with SSE2
let zero_vec = _mm_setzero_si128();
let mut idx = start_idx;
let end_idx = start_idx + num_words;
// Process aligned blocks of 2 words
while idx + 2 <= end_idx {
// Safety:
// - bitmap[idx..] is valid for reads/writes of at least 2 u64 words (16 bytes)
// - We've validated with the debug_assert that start_idx + num_words <= bitmap.len()
// - We check that idx + 2 <= end_idx to ensure we have 2 complete words
// - The unaligned _storeu_ variant is used to handle any alignment
_mm_storeu_si128(bitmap[idx..].as_mut_ptr() as *mut __m128i, zero_vec);
idx += 2;
}
// Handle remaining word (if any)
if idx < end_idx {
bitmap[idx] = 0;
}
return;
}
// Fallback to scalar implementation if no SIMD features available
unsafe {
// Safety: Just increments a static counter, with no possibility of data races
// as long as this function isn't called concurrently
SCALAR_CLEAR_COUNT += 1;
}
// Scalar fallback
for i in start_idx..(start_idx + num_words) {
bitmap[i] = 0;
}
}
#[inline(always)]
fn is_range_zero(bitmap: &[u64], start_idx: usize, num_words: usize) -> bool {
debug_assert!(start_idx + num_words <= bitmap.len());
// First try AVX2 (256-bit, 4 words at a time)
#[cfg(target_feature = "avx2")]
unsafe {
let mut idx = start_idx;
let end_idx = start_idx + num_words;
// Process aligned blocks of 4 words
while idx + 4 <= end_idx {
// Safety:
// - bitmap[idx..] is valid for reads of at least 4 u64 words (32 bytes)
// - We've validated with the debug_assert that start_idx + num_words <= bitmap.len()
// - We check that idx + 4 <= end_idx to ensure we have 4 complete words
// - The unaligned _loadu_ variant is used to handle any alignment
let data_vec = _mm256_loadu_si256(bitmap[idx..].as_ptr() as *const __m256i);
// Check if any bits are non-zero
// Safety: _mm256_testz_si256 is safe when given valid __m256i values,
// which data_vec is guaranteed to be
if !_mm256_testz_si256(data_vec, data_vec) {
return false;
}
idx += 4;
}
// Handle remaining words with SSE2 or scalar ops
if idx < end_idx {
if idx + 2 <= end_idx {
// Use SSE2 for 2 words
// Safety:
// - bitmap[idx..] is valid for reads of at least 2 u64 words (16 bytes)
// - We check that idx + 2 <= end_idx to ensure we have 2 complete words
let data_vec = _mm_loadu_si128(bitmap[idx..].as_ptr() as *const __m128i);
// Safety: _mm_testz_si128 is safe when given valid __m128i values
if !_mm_testz_si128(data_vec, data_vec) {
return false;
}
idx += 2;
}
// Handle any remaining words
while idx < end_idx {
if bitmap[idx] != 0 {
return false;
}
idx += 1;
}
}
return true;
}
// If AVX2 is unavailable, try SSE2 (128-bit, 2 words at a time)
#[cfg(all(target_feature = "sse2", not(target_feature = "avx2")))]
unsafe {
let mut idx = start_idx;
let end_idx = start_idx + num_words;
// Process aligned blocks of 2 words
while idx + 2 <= end_idx {
// Safety:
// - bitmap[idx..] is valid for reads of at least 2 u64 words (16 bytes)
// - We've validated with the debug_assert that start_idx + num_words <= bitmap.len()
// - We check that idx + 2 <= end_idx to ensure we have 2 complete words
// - The unaligned _loadu_ variant is used to handle any alignment
let data_vec = _mm_loadu_si128(bitmap[idx..].as_ptr() as *const __m128i);
// Check if any bits are non-zero (SSE4.1 would have _mm_testz_si128,
// but for SSE2 compatibility we need to use a different approach)
#[cfg(target_feature = "sse4.1")]
{
// Safety: _mm_testz_si128 is safe when given valid __m128i values
if !_mm_testz_si128(data_vec, data_vec) {
return false;
}
}
#[cfg(not(target_feature = "sse4.1"))]
{
// Compare with zero vector using SSE2 only
// Safety: All operations are valid with the data_vec value
let zero_vec = _mm_setzero_si128();
let cmp = _mm_cmpeq_epi64(data_vec, zero_vec);
// The movemask gives us a bit for each byte, set if the high bit of the byte is set
// For all-zero comparison, all 16 bits should be set (0xFFFF)
let mask = _mm_movemask_epi8(cmp);
if mask != 0xFFFF {
return false;
}
}
idx += 2;
}
// Handle remaining word (if any)
if idx < end_idx && bitmap[idx] != 0 {
return false;
}
return true;
}
// Scalar fallback
bitmap[start_idx..(start_idx + num_words)]
.iter()
.all(|&word| word == 0)
}
#[inline(always)]
fn set_bit(bitmap: &mut [u64], bit_idx: u64) {
let word_idx = (bit_idx / 64) as usize;
let bit_pos = (bit_idx % 64) as u64;
bitmap[word_idx] |= 1u64 << bit_pos;
}
#[inline(always)]
fn clear_bit(bitmap: &mut [u64], bit_idx: u64) {
let word_idx = (bit_idx / 64) as usize;
let bit_pos = (bit_idx % 64) as u64;
bitmap[word_idx] &= !(1u64 << bit_pos);
}
#[inline(always)]
fn check_bit(bitmap: &[u64], bit_idx: u64) -> bool {
let word_idx = (bit_idx / 64) as usize;
let bit_pos = (bit_idx % 64) as u64;
(bitmap[word_idx] & (1u64 << bit_pos)) != 0
}
}
/// Additional x86 optimized operations not covered by the trait
pub mod atomic {
use super::*;
/// Check and set bit, returning the previous state
/// This function is not actually atomic! It's just a non-atomic optimization
#[inline(always)]
pub fn check_and_set_bit(bitmap: &mut [u64], bit_idx: u64) -> bool {
let word_idx = (bit_idx / 64) as usize;
let bit_pos = (bit_idx % 64) as u64;
let mask = 1u64 << bit_pos;
// Get old value
let old_word = bitmap[word_idx];
// Set bit regardless of current state
bitmap[word_idx] |= mask;
// Return true if bit was already set (duplicate)
(old_word & mask) != 0
}
/// Set multiple bits at once using SIMD when possible
///
/// # Safety
///
/// This function is unsafe because it:
/// - Uses SIMD intrinsics that require the AVX2 CPU feature to be available
/// - Accesses bitmap memory through raw pointers
/// - Does not perform bounds checking beyond what's required for SIMD operations
///
/// Caller must ensure:
/// - The AVX2 feature is available on the current CPU
/// - `bitmap` has sufficient size to hold indices up to `end_bit/64`
/// - `start_bit` and `end_bit` are valid bit indices within the bitmap
/// - No other thread is concurrently modifying the same memory
#[inline(always)]
#[cfg(target_feature = "avx2")]
pub unsafe fn set_bits_range(bitmap: &mut [u64], start_bit: u64, end_bit: u64) {
// Process whole words where possible
let start_word = (start_bit / 64) as usize;
let end_word = (end_bit / 64) as usize;
// Special case: all bits in the same word
if start_word == end_word {
let start_mask = u64::MAX << (start_bit % 64);
let end_mask = u64::MAX >> (63 - (end_bit % 64));
bitmap[start_word] |= start_mask & end_mask;
return;
}
// Handle partial words at the beginning and end
if start_bit % 64 != 0 {
let start_mask = u64::MAX << (start_bit % 64);
bitmap[start_word] |= start_mask;
}
if (end_bit + 1) % 64 != 0 {
let end_mask = u64::MAX >> (63 - (end_bit % 64));
bitmap[end_word] |= end_mask;
}
// Handle complete words in the middle using AVX2
let first_full_word = if start_bit % 64 == 0 {
start_word
} else {
start_word + 1
};
let last_full_word = if (end_bit + 1) % 64 == 0 {
end_word
} else {
end_word - 1
};
if first_full_word <= last_full_word {
// Use AVX2 to set multiple words at once
// Safety: _mm256_set1_epi64x is safe to call with any i64 value
let ones = _mm256_set1_epi64x(-1); // All bits set to 1
let mut i = first_full_word;
while i + 4 <= last_full_word + 1 {
// Safety:
// - bitmap[i..] is valid for reads/writes of at least 4 u64 words (32 bytes)
// - We check that i + 4 <= last_full_word + 1 to ensure we have 4 complete words
// - The unaligned _loadu/_storeu variants are used to handle any alignment
let current = _mm256_loadu_si256(bitmap[i..].as_ptr() as *const __m256i);
let result = _mm256_or_si256(current, ones);
_mm256_storeu_si256(bitmap[i..].as_mut_ptr() as *mut __m256i, result);
i += 4;
}
// Use SSE2 for remaining pairs of words
if i + 2 <= last_full_word + 1 {
// Safety:
// - bitmap[i..] is valid for reads/writes of at least 2 u64 words (16 bytes)
// - We check that i + 2 <= last_full_word + 1 to ensure we have 2 complete words
// - The unaligned _loadu/_storeu variants are used to handle any alignment
let sse_ones = _mm_set1_epi64x(-1);
let current = _mm_loadu_si128(bitmap[i..].as_ptr() as *const __m128i);
let result = _mm_or_si128(current, sse_ones);
_mm_storeu_si128(bitmap[i..].as_mut_ptr() as *mut __m128i, result);
i += 2;
}
// Handle any remaining words
while i <= last_full_word {
bitmap[i] = u64::MAX;
i += 1;
}
}
}
/// Set multiple bits at once using SSE2 (when AVX2 not available)
///
/// # Safety
///
/// This function is unsafe because it:
/// - Uses SIMD intrinsics that require the SSE2 CPU feature to be available
/// - Accesses bitmap memory through raw pointers
/// - Does not perform bounds checking beyond what's required for SIMD operations
///
/// Caller must ensure:
/// - The SSE2 feature is available on the current CPU
/// - `bitmap` has sufficient size to hold indices up to `end_bit/64`
/// - `start_bit` and `end_bit` are valid bit indices within the bitmap
/// - No other thread is concurrently modifying the same memory
#[inline(always)]
#[cfg(all(target_feature = "sse2", not(target_feature = "avx2")))]
pub unsafe fn set_bits_range(bitmap: &mut [u64], start_bit: u64, end_bit: u64) {
// Process whole words where possible
let start_word = (start_bit / 64) as usize;
let end_word = (end_bit / 64) as usize;
// Special case: all bits in the same word
if start_word == end_word {
let start_mask = u64::MAX << (start_bit % 64);
let end_mask = u64::MAX >> (63 - (end_bit % 64));
bitmap[start_word] |= start_mask & end_mask;
return;
}
// Handle partial words at the beginning and end
if start_bit % 64 != 0 {
let start_mask = u64::MAX << (start_bit % 64);
bitmap[start_word] |= start_mask;
}
if (end_bit + 1) % 64 != 0 {
let end_mask = u64::MAX >> (63 - (end_bit % 64));
bitmap[end_word] |= end_mask;
}
// Handle complete words in the middle using SSE2
let first_full_word = if start_bit % 64 == 0 {
start_word
} else {
start_word + 1
};
let last_full_word = if (end_bit + 1) % 64 == 0 {
end_word
} else {
end_word - 1
};
if first_full_word <= last_full_word {
// Use SSE2 to set multiple words at once
// Safety: _mm_set1_epi64x is safe to call with any i64 value
let ones = _mm_set1_epi64x(-1); // All bits set to 1
let mut i = first_full_word;
while i + 2 <= last_full_word + 1 {
// Safety:
// - bitmap[i..] is valid for reads/writes of at least 2 u64 words (16 bytes)
// - We check that i + 2 <= last_full_word + 1 to ensure we have 2 complete words
// - The unaligned _loadu/_storeu variants are used to handle any alignment
let current = _mm_loadu_si128(bitmap[i..].as_ptr() as *const __m128i);
let result = _mm_or_si128(current, ones);
_mm_storeu_si128(bitmap[i..].as_mut_ptr() as *mut __m128i, result);
i += 2;
}
// Handle any remaining words
while i <= last_full_word {
bitmap[i] = u64::MAX;
i += 1;
}
}
}
/// Set multiple bits at once using scalar operations (fallback)
#[inline(always)]
#[cfg(not(any(target_feature = "avx2", target_feature = "sse2")))]
pub fn set_bits_range(bitmap: &mut [u64], start_bit: u64, end_bit: u64) {
// Process whole words where possible
let start_word = (start_bit / 64) as usize;
let end_word = (end_bit / 64) as usize;
// Special case: all bits in the same word
if start_word == end_word {
let start_mask = u64::MAX << (start_bit % 64);
let end_mask = u64::MAX >> (63 - (end_bit % 64));
bitmap[start_word] |= start_mask & end_mask;
return;
}
// Handle partial words at the beginning and end
if start_bit % 64 != 0 {
let start_mask = u64::MAX << (start_bit % 64);
bitmap[start_word] |= start_mask;
}
if (end_bit + 1) % 64 != 0 {
let end_mask = u64::MAX >> (63 - (end_bit % 64));
bitmap[end_word] |= end_mask;
}
// Handle complete words in the middle
let first_full_word = if start_bit % 64 == 0 {
start_word
} else {
start_word + 1
};
let last_full_word = if (end_bit + 1) % 64 == 0 {
end_word
} else {
end_word - 1
};
for i in first_full_word..=last_full_word {
bitmap[i] = u64::MAX;
}
}
}
+876
View File
@@ -0,0 +1,876 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Replay protection validator implementation.
//!
//! This module implements the core replay protection logic using a bitmap-based
//! approach to track received packets and validate their sequence.
use crate::replay::error::{ReplayError, ReplayResult};
use crate::replay::simd::{self, BitmapOps};
// Determine the appropriate SIMD implementation at compile time
#[cfg(target_arch = "aarch64")]
#[cfg(target_feature = "neon")]
use crate::replay::simd::ArmBitmapOps as SimdImpl;
#[cfg(target_arch = "x86_64")]
#[cfg(target_feature = "avx2")]
use crate::replay::simd::X86BitmapOps as SimdImpl;
#[cfg(target_arch = "x86_64")]
#[cfg(all(not(target_feature = "avx2"), target_feature = "sse2"))]
use crate::replay::simd::X86BitmapOps as SimdImpl;
#[cfg(not(any(
all(target_arch = "x86_64", target_feature = "avx2"),
all(target_arch = "x86_64", target_feature = "sse2"),
all(target_arch = "aarch64", target_feature = "neon")
)))]
use crate::replay::simd::ScalarBitmapOps as SimdImpl;
/// Size of a word in the bitmap (64 bits)
const WORD_SIZE: usize = 64;
/// Number of words in the bitmap (allows reordering of 64*16 = 1024 packets)
const N_WORDS: usize = 16;
/// Total number of bits in the bitmap
const N_BITS: usize = WORD_SIZE * N_WORDS;
/// Validator for receiving key counters to prevent replay attacks.
///
/// This structure maintains a bitmap of received packets and validates
/// incoming packet counters to ensure they are not replayed.
#[derive(Debug, Clone, Default)]
pub struct ReceivingKeyCounterValidator {
/// Next expected counter value
next: u64,
/// Total number of received packets
receive_cnt: u64,
/// Bitmap for tracking received packets
bitmap: [u64; N_WORDS],
}
impl ReceivingKeyCounterValidator {
/// Creates a new validator with the given initial counter value.
pub fn new(initial_counter: u64) -> Self {
Self {
next: initial_counter,
receive_cnt: 0,
bitmap: [0; N_WORDS],
}
}
/// Sets a bit in the bitmap to mark a counter as received.
#[inline(always)]
fn set_bit(&mut self, idx: u64) {
SimdImpl::set_bit(&mut self.bitmap, idx % (N_BITS as u64));
}
/// Clears a bit in the bitmap.
#[inline(always)]
fn clear_bit(&mut self, idx: u64) {
SimdImpl::clear_bit(&mut self.bitmap, idx % (N_BITS as u64));
}
/// Clears the word that contains the given index.
#[inline(always)]
#[allow(dead_code)]
fn clear_word(&mut self, idx: u64) {
let bit_idx = idx % (N_BITS as u64);
let word = (bit_idx / (WORD_SIZE as u64)) as usize;
SimdImpl::clear_words(&mut self.bitmap, word, 1);
}
/// Returns true if the bit is set, false otherwise.
#[inline(always)]
fn check_bit_branchless(&self, idx: u64) -> bool {
SimdImpl::check_bit(&self.bitmap, idx % (N_BITS as u64))
}
/// Performs a quick check to determine if a counter will be accepted.
///
/// This is a fast check that can be done before more expensive operations.
///
/// Returns:
/// - `Ok(())` if the counter is acceptable
/// - `Err(ReplayError::InvalidCounter)` if the counter is invalid (too far back)
/// - `Err(ReplayError::DuplicateCounter)` if the counter has already been received
#[inline(always)]
pub fn will_accept_branchless(&self, counter: u64) -> ReplayResult<()> {
// Calculate conditions
let is_growing = counter >= self.next;
// Handle potential overflow when adding N_BITS to counter
let too_far_back = if counter > u64::MAX - (N_BITS as u64) {
// If adding N_BITS would overflow, it can't be too far back
false
} else {
counter + (N_BITS as u64) < self.next
};
let duplicate = self.check_bit_branchless(counter);
// Using Option to avoid early returns
let result = if is_growing {
Some(Ok(()))
} else if too_far_back {
Some(Err(ReplayError::OutOfWindow))
} else if duplicate {
Some(Err(ReplayError::DuplicateCounter))
} else {
Some(Ok(()))
};
// Unwrap the option (always Some)
result.unwrap()
}
/// Special case function for clearing the entire bitmap
/// Used for the fast path when we know the bitmap must be entirely cleared
#[inline(always)]
fn clear_window_fast(&mut self) {
SimdImpl::clear_words(&mut self.bitmap, 0, N_WORDS);
}
/// Checks if the bitmap is completely empty (all zeros)
/// This is used for fast path optimization
#[inline(always)]
fn is_bitmap_empty(&self) -> bool {
SimdImpl::is_range_zero(&self.bitmap, 0, N_WORDS)
}
/// Marks a counter as received and updates internal state.
///
/// This method should be called after a packet has been validated
/// and processed successfully.
///
/// Returns:
/// - `Ok(())` if the counter was successfully marked
/// - `Err(ReplayError::InvalidCounter)` if the counter is invalid (too far back)
/// - `Err(ReplayError::DuplicateCounter)` if the counter has already been received
#[inline(always)]
pub fn mark_did_receive_branchless(&mut self, counter: u64) -> ReplayResult<()> {
// Calculate conditions once - using saturating operations to prevent overflow
// For the too_far_back check, we need to avoid overflowing when adding N_BITS to counter
let too_far_back = if counter > u64::MAX - (N_BITS as u64) {
// If adding N_BITS would overflow, it can't be too far back
false
} else {
counter + (N_BITS as u64) < self.next
};
let is_sequential = counter == self.next;
let is_out_of_order = counter < self.next;
// Early return for out-of-window condition
if too_far_back {
return Err(ReplayError::OutOfWindow);
}
// Check for duplicate (only matters for out-of-order packets)
let duplicate = is_out_of_order && self.check_bit_branchless(counter);
if duplicate {
return Err(ReplayError::DuplicateCounter);
}
// Fast path for far ahead counters with empty bitmap
let far_ahead = counter.saturating_sub(self.next) >= (N_BITS as u64);
if far_ahead && self.is_bitmap_empty() {
// No need to clear anything, just set the new bit
self.set_bit(counter);
self.next = counter.saturating_add(1);
self.receive_cnt += 1;
return Ok(());
}
// Handle bitmap clearing for ahead counters that aren't sequential
if !is_sequential && !is_out_of_order {
self.clear_window(counter);
}
// Set the bit and update counters
self.set_bit(counter);
// Update next counter safely - avoid overflow
self.next = if is_sequential {
counter.saturating_add(1)
} else {
self.next.max(counter.saturating_add(1))
};
self.receive_cnt += 1;
Ok(())
}
/// Returns the current packet count statistics.
///
/// Returns a tuple of `(next, receive_cnt)` where:
/// - `next` is the next expected counter value
/// - `receive_cnt` is the total number of received packets
pub fn current_packet_cnt(&self) -> (u64, u64) {
(self.next, self.receive_cnt)
}
#[inline(always)]
#[allow(dead_code)]
fn check_and_set_bit_branchless(&mut self, idx: u64) -> bool {
let bit_idx = idx % (N_BITS as u64);
simd::atomic::check_and_set_bit(&mut self.bitmap, bit_idx)
}
#[inline(always)]
#[allow(dead_code)]
fn increment_counter_branchless(&mut self, condition: bool) {
// Add either 1 or 0 based on condition
self.receive_cnt += condition as u64;
}
#[inline(always)]
pub fn mark_sequential_branchless(&mut self, counter: u64) -> ReplayResult<()> {
// Check if sequential
let is_sequential = counter == self.next;
// Set the bit
self.set_bit(counter);
// Conditionally update next counter using saturating add to prevent overflow
self.next = self.next.saturating_add(is_sequential as u64);
// Always increment receive count if we got here
self.receive_cnt += 1;
Ok(())
}
// Helper function for window clearing with SIMD optimization
#[inline(always)]
fn clear_window(&mut self, counter: u64) {
// Handle potential overflow safely
// If counter is very large (close to u64::MAX), we need special handling
let counter_distance = counter.saturating_sub(self.next);
let far_ahead = counter_distance >= (N_BITS as u64);
// Fast path: Complete window clearing for far ahead counters
if far_ahead {
// Check if window is already clear for fast path optimization
if !self.is_bitmap_empty() {
// Use SIMD to clear the entire bitmap at once
self.clear_window_fast();
}
return;
}
// Prepare for partial window clearing
let mut i = self.next;
// Get SIMD processing width (platform optimized)
let simd_width = simd::optimal_simd_width();
// Pre-alignment clearing
if i % (WORD_SIZE as u64) != 0 {
let current_word = (i % (N_BITS as u64) / (WORD_SIZE as u64)) as usize;
// Check if we need to clear this word
if self.bitmap[current_word] != 0 {
// Safely handle potential overflow by checking before each increment
while i % (WORD_SIZE as u64) != 0 && i < counter {
self.clear_bit(i);
// Prevent overflow on increment
if i == u64::MAX {
break;
}
i += 1;
}
} else {
// Fast forward to the next word boundary
let words_to_skip = (WORD_SIZE as u64) - (i % (WORD_SIZE as u64));
if words_to_skip > u64::MAX - i {
// Would overflow, just set to MAX
i = u64::MAX;
} else {
i += words_to_skip;
}
}
}
// Word-aligned clearing with SIMD where possible
while i <= counter.saturating_sub(WORD_SIZE as u64) {
let current_word = (i % (N_BITS as u64) / (WORD_SIZE as u64)) as usize;
// Check if we have enough consecutive words to use SIMD
if current_word + simd_width <= N_WORDS
&& i % (simd_width as u64 * WORD_SIZE as u64) == 0
{
// Use SIMD to clear multiple words at once if any need clearing
let needs_clearing =
!SimdImpl::is_range_zero(&self.bitmap, current_word, simd_width);
if needs_clearing {
SimdImpl::clear_words(&mut self.bitmap, current_word, simd_width);
}
// Skip the words we just processed
let words_to_skip = simd_width as u64 * WORD_SIZE as u64;
if words_to_skip > u64::MAX - i {
i = u64::MAX;
break;
}
i += words_to_skip;
} else {
// Process single word
if self.bitmap[current_word] != 0 {
self.bitmap[current_word] = 0;
}
// Check for potential overflow before incrementing
if i > u64::MAX - (WORD_SIZE as u64) {
i = u64::MAX;
break;
}
i += WORD_SIZE as u64;
}
}
// Post-alignment clearing (bit by bit for remaining bits)
if i < counter {
let final_word = (i % (N_BITS as u64) / (WORD_SIZE as u64)) as usize;
let is_final_word_empty = self.bitmap[final_word] == 0;
// Skip clearing if word is already empty
if !is_final_word_empty {
while i < counter {
self.clear_bit(i);
// Prevent overflow on increment
if i == u64::MAX {
break;
}
i += 1;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_replay_counter_basic() {
let mut validator = ReceivingKeyCounterValidator::default();
// Check initial state
assert_eq!(validator.next, 0);
assert_eq!(validator.receive_cnt, 0);
// Test sequential counters
assert!(validator.mark_did_receive_branchless(0).is_ok());
assert!(validator.mark_did_receive_branchless(0).is_err());
assert!(validator.mark_did_receive_branchless(1).is_ok());
assert!(validator.mark_did_receive_branchless(1).is_err());
}
#[test]
fn test_replay_counter_out_of_order() {
let mut validator = ReceivingKeyCounterValidator::default();
// Process some sequential packets
assert!(validator.mark_did_receive_branchless(0).is_ok());
assert!(validator.mark_did_receive_branchless(1).is_ok());
assert!(validator.mark_did_receive_branchless(2).is_ok());
// Out-of-order packet that hasn't been seen yet
assert!(validator.mark_did_receive_branchless(1).is_err()); // Already seen
assert!(validator.mark_did_receive_branchless(10).is_ok()); // New packet, ahead of next
// Next should now be 11
assert_eq!(validator.next, 11);
// Can still accept packets in the valid window
assert!(validator.will_accept_branchless(9).is_ok());
assert!(validator.will_accept_branchless(8).is_ok());
// But duplicates are rejected
assert!(validator.will_accept_branchless(10).is_err());
}
#[test]
fn test_replay_counter_full() {
let mut validator = ReceivingKeyCounterValidator::default();
// Process a bunch of sequential packets
for i in 0..64 {
assert!(validator.mark_did_receive_branchless(i).is_ok());
assert!(validator.mark_did_receive_branchless(i).is_err());
}
// Test out of order within window
assert!(validator.mark_did_receive_branchless(15).is_err()); // Already seen
assert!(validator.mark_did_receive_branchless(63).is_err()); // Already seen
// Test for packets within bitmap range
for i in 64..(N_BITS as u64) + 128 {
assert!(validator.mark_did_receive_branchless(i).is_ok());
assert!(validator.mark_did_receive_branchless(i).is_err());
}
}
#[test]
fn test_replay_counter_window_sliding() {
let mut validator = ReceivingKeyCounterValidator::default();
// Jump far ahead to force window sliding
let far_ahead = (N_BITS as u64) * 3;
assert!(validator.mark_did_receive_branchless(far_ahead).is_ok());
// Everything too far back should be rejected
for i in 0..=(N_BITS as u64) * 2 {
assert!(matches!(
validator.will_accept_branchless(i),
Err(ReplayError::OutOfWindow)
));
assert!(validator.mark_did_receive_branchless(i).is_err());
}
// Values in window but less than far_ahead should be accepted
for i in (N_BITS as u64) * 2 + 1..far_ahead {
assert!(validator.will_accept_branchless(i).is_ok());
}
// The far_ahead value itself should be rejected now (duplicate)
assert!(matches!(
validator.will_accept_branchless(far_ahead),
Err(ReplayError::DuplicateCounter)
));
// Test receiving packets in reverse order within window
for i in ((N_BITS as u64) * 2 + 1..far_ahead).rev() {
assert!(validator.mark_did_receive_branchless(i).is_ok());
assert!(validator.mark_did_receive_branchless(i).is_err());
}
}
#[test]
fn test_out_of_order_tracking() {
let mut validator = ReceivingKeyCounterValidator::default();
// Jump ahead
assert!(validator.mark_did_receive_branchless(1000).is_ok());
// Test some more additions
assert!(validator.mark_did_receive_branchless(1000 + 70).is_ok());
assert!(validator.mark_did_receive_branchless(1000 + 71).is_ok());
assert!(validator.mark_did_receive_branchless(1000 + 72).is_ok());
assert!(validator
.mark_did_receive_branchless(1000 + 72 + 125)
.is_ok());
assert!(validator.mark_did_receive_branchless(1000 + 63).is_ok());
// Check duplicates
assert!(validator.mark_did_receive_branchless(1000 + 70).is_err());
assert!(validator.mark_did_receive_branchless(1000 + 71).is_err());
assert!(validator.mark_did_receive_branchless(1000 + 72).is_err());
}
#[test]
fn test_counter_stats() {
let mut validator = ReceivingKeyCounterValidator::default();
// Initial state
let (next, count) = validator.current_packet_cnt();
assert_eq!(next, 0);
assert_eq!(count, 0);
// After receiving some packets
assert!(validator.mark_did_receive_branchless(0).is_ok());
assert!(validator.mark_did_receive_branchless(1).is_ok());
assert!(validator.mark_did_receive_branchless(2).is_ok());
let (next, count) = validator.current_packet_cnt();
assert_eq!(next, 3);
assert_eq!(count, 3);
// After an out of order packet
assert!(validator.mark_did_receive_branchless(10).is_ok());
let (next, count) = validator.current_packet_cnt();
assert_eq!(next, 11);
assert_eq!(count, 4);
// After a packet from the past (within window)
assert!(validator.mark_did_receive_branchless(5).is_ok());
let (next, count) = validator.current_packet_cnt();
assert_eq!(next, 11); // Next doesn't change
assert_eq!(count, 5); // Count increases
}
#[test]
fn test_window_boundary_edge_cases() {
let mut validator = ReceivingKeyCounterValidator::default();
// First process a sequence of packets
for i in 0..100 {
assert!(validator.mark_did_receive_branchless(i).is_ok());
}
// The window should now span from 100 to 100+N_BITS
// Test packet near the upper edge of the window
let upper_edge = 100 + (N_BITS as u64) - 1;
assert!(validator.will_accept_branchless(upper_edge).is_ok());
assert!(validator.mark_did_receive_branchless(upper_edge).is_ok());
// Test packet just outside the upper edge (should be accepted)
let just_outside_upper = 100 + (N_BITS as u64);
assert!(validator.will_accept_branchless(just_outside_upper).is_ok());
// Test packet near the lower edge of the window
let lower_edge = 100 + 1; // +1 because we've already processed 100
assert!(validator.will_accept_branchless(lower_edge).is_ok());
// Test packet just outside the lower edge (should be rejected)
if upper_edge >= (N_BITS as u64) * 2 {
// Only test this if we're far enough along to have a lower bound
let just_outside_lower = 100 - (N_BITS as u64);
assert!(matches!(
validator.will_accept_branchless(just_outside_lower),
Err(ReplayError::OutOfWindow)
));
}
}
#[test]
fn test_multiple_window_shifts() {
let mut validator = ReceivingKeyCounterValidator::default();
// First jump - process packet far ahead
let first_jump = 2000;
assert!(validator.mark_did_receive_branchless(first_jump).is_ok());
// Verify next counter is updated
let (next, _) = validator.current_packet_cnt();
assert_eq!(next, first_jump + 1);
// Second large jump, even further ahead
let second_jump = first_jump + 5000;
assert!(validator.mark_did_receive_branchless(second_jump).is_ok());
// Verify next counter is updated again
let (next, _) = validator.current_packet_cnt();
assert_eq!(next, second_jump + 1);
// Test packets within the new window
let mid_window = second_jump - 500;
assert!(validator.will_accept_branchless(mid_window).is_ok());
// Test packets outside the new window
let outside_window = first_jump + 100;
assert!(matches!(
validator.will_accept_branchless(outside_window),
Err(ReplayError::OutOfWindow)
));
}
#[test]
fn test_interleaved_packets_at_boundaries() {
let mut validator = ReceivingKeyCounterValidator::default();
// Jump ahead to establish a large window
let jump = 2000;
assert!(validator.mark_did_receive_branchless(jump).is_ok());
// Process a sequence at the upper boundary
for i in 0..10 {
let upper_packet = jump + 100 + i;
assert!(validator.mark_did_receive_branchless(upper_packet).is_ok());
}
// Process a sequence at the lower boundary
for i in 0..10 {
let lower_packet = jump - (N_BITS as u64) + 100 + i;
// These might fail if they're outside the window, that's ok
let _ = validator.mark_did_receive_branchless(lower_packet);
}
// Process alternating packets at both ends
for i in 0..5 {
let upper = jump + 200 + i;
let lower = jump - (N_BITS as u64) + 200 + i;
assert!(validator.will_accept_branchless(upper).is_ok());
let lower_result = validator.will_accept_branchless(lower);
// Lower might be accepted or rejected, depending on exactly where the window is
if lower_result.is_ok() {
assert!(validator.mark_did_receive_branchless(lower).is_ok());
}
assert!(validator.mark_did_receive_branchless(upper).is_ok());
}
}
#[test]
fn test_exact_window_size_with_full_bitmap() {
let mut validator = ReceivingKeyCounterValidator::default();
// Fill the entire bitmap with non-sequential packets
// This tests both window size and bitmap capacity
// Generate a random but reproducible pattern
let mut positions = Vec::new();
for i in 0..N_BITS {
positions.push((i * 7) % N_BITS);
}
// Mark packets in this pattern
for pos in &positions {
assert!(validator.mark_did_receive_branchless(*pos as u64).is_ok());
}
// Try to mark them again (should all fail as duplicates)
for pos in &positions {
assert!(matches!(
validator.mark_did_receive_branchless(*pos as u64),
Err(ReplayError::DuplicateCounter)
));
}
// Force window to slide
let far_ahead = (N_BITS as u64) * 2;
assert!(validator.mark_did_receive_branchless(far_ahead).is_ok());
// Old packets should now be outside the window
for pos in &positions {
if *pos as u64 + (N_BITS as u64) < far_ahead {
assert!(matches!(
validator.will_accept_branchless(*pos as u64),
Err(ReplayError::OutOfWindow)
));
}
}
}
use std::sync::{Arc, Barrier};
use std::thread;
#[test]
fn test_concurrent_access() {
let validator = Arc::new(std::sync::Mutex::new(
ReceivingKeyCounterValidator::default(),
));
let num_threads = 8;
let operations_per_thread = 1000;
let barrier = Arc::new(Barrier::new(num_threads));
// Create thread handles
let mut handles = vec![];
for thread_id in 0..num_threads {
let validator_clone = Arc::clone(&validator);
let barrier_clone = Arc::clone(&barrier);
let handle = thread::spawn(move || {
// Wait for all threads to be ready
barrier_clone.wait();
let mut successes = 0;
let mut duplicates = 0;
let mut out_of_window = 0;
for i in 0..operations_per_thread {
// Generate a somewhat random but reproducible counter value
// Different threads will sometimes try to insert the same value
let counter = (i * 7 + thread_id * 13) as u64;
let mut guard = validator_clone.lock().unwrap();
match guard.mark_did_receive_branchless(counter) {
Ok(()) => successes += 1,
Err(ReplayError::DuplicateCounter) => duplicates += 1,
Err(ReplayError::OutOfWindow) => out_of_window += 1,
_ => {}
}
}
(successes, duplicates, out_of_window)
});
handles.push(handle);
}
// Collect results
let mut total_successes = 0;
let mut total_duplicates = 0;
let mut total_out_of_window = 0;
for handle in handles {
let (successes, duplicates, out_of_window) = handle.join().unwrap();
total_successes += successes;
total_duplicates += duplicates;
total_out_of_window += out_of_window;
}
// Verify that all operations were accounted for
assert_eq!(
total_successes + total_duplicates + total_out_of_window,
num_threads * operations_per_thread
);
// Verify that some operations were successful and some were duplicates
assert!(total_successes > 0);
assert!(total_duplicates > 0);
// Check final state of the validator
let final_state = validator.lock().unwrap();
let (_next, receive_cnt) = final_state.current_packet_cnt();
// Verify that the received count matches our successful operations
assert_eq!(receive_cnt, total_successes as u64);
}
#[test]
fn test_memory_usage() {
use std::mem::{size_of, size_of_val};
// Test small validator
let validator_default = ReceivingKeyCounterValidator::default();
let size_default = size_of_val(&validator_default);
// Expected size calculation
let expected_size = size_of::<u64>() * 2 + // next + receive_cnt
size_of::<u64>() * N_WORDS; // bitmap
assert_eq!(size_default, expected_size);
println!("Default validator size: {} bytes", size_default);
// Memory efficiency calculation (bits tracked per byte of memory)
let bits_per_byte = N_BITS as f64 / size_default as f64;
println!(
"Memory efficiency: {:.2} bits tracked per byte of memory",
bits_per_byte
);
// Verify minimum memory needed for different window sizes
for window_size in [64, 128, 256, 512, 1024, 2048] {
let words_needed = (window_size + WORD_SIZE - 1) / WORD_SIZE; // Ceiling division
let memory_needed = size_of::<u64>() * 2 + size_of::<u64>() * words_needed;
println!(
"Window size {}: {} bytes minimum",
window_size, memory_needed
);
}
}
#[test]
#[cfg(any(
target_feature = "sse2",
target_feature = "avx2",
target_feature = "neon"
))]
fn test_simd_operations() {
// This test verifies that SIMD-optimized operations would produce
// the same results as the scalar implementation
// Create a validator with a known state
let mut validator = ReceivingKeyCounterValidator::default();
// Fill bitmap with a pattern
for i in 0..64 {
validator.set_bit(i);
}
// Create a copy for comparison
let _original_bitmap = validator.bitmap;
// Simulate SIMD clear (4 words at a time)
#[cfg(target_feature = "avx2")]
{
use std::arch::x86_64::{_mm256_setzero_si256, _mm256_storeu_si256};
// Clear words 0-3 using AVX2
unsafe {
let zero_vec = _mm256_setzero_si256();
_mm256_storeu_si256(validator.bitmap.as_mut_ptr() as *mut _, zero_vec);
}
// Verify first 4 words are cleared
assert_eq!(validator.bitmap[0], 0);
assert_eq!(validator.bitmap[1], 0);
assert_eq!(validator.bitmap[2], 0);
assert_eq!(validator.bitmap[3], 0);
// Verify other words are unchanged
for i in 4..N_WORDS {
assert_eq!(validator.bitmap[i], original_bitmap[i]);
}
}
#[cfg(target_feature = "sse2")]
{
use std::arch::x86_64::{_mm_setzero_si128, _mm_storeu_si128};
// Reset validator
validator.bitmap = original_bitmap;
// Clear words 0-1 using SSE2
unsafe {
let zero_vec = _mm_setzero_si128();
_mm_storeu_si128(validator.bitmap.as_mut_ptr() as *mut _, zero_vec);
}
// Verify first 2 words are cleared
assert_eq!(validator.bitmap[0], 0);
assert_eq!(validator.bitmap[1], 0);
// Verify other words are unchanged
for i in 2..N_WORDS {
assert_eq!(validator.bitmap[i], original_bitmap[i]);
}
}
// No SIMD available, make this test a no-op
#[cfg(not(any(
target_feature = "sse2",
target_feature = "avx2",
target_feature = "neon"
)))]
{
println!("No SIMD features available, skipping SIMD test");
}
}
#[test]
fn test_clear_window_overflow() {
let mut validator = ReceivingKeyCounterValidator::default();
// Set a very large next value, close to u64::MAX
validator.next = u64::MAX - 1000;
// Try to clear window with an even higher counter
// This should exercise the potentially problematic code
let counter = u64::MAX - 500;
// Call clear_window directly (this is what we suspect has issues)
validator.clear_window(counter);
// If we got here without a panic, at least it's not crashing
// Let's verify the bitmap state is reasonable
let any_non_zero = validator.bitmap.iter().any(|&word| word != 0);
assert!(!any_non_zero, "Bitmap should be cleared");
// Try the full function which uses clear_window internally
assert!(validator.mark_did_receive_branchless(counter).is_ok());
// Verify it was marked
assert!(matches!(
validator.will_accept_branchless(counter),
Err(ReplayError::DuplicateCounter)
));
}
}
+658
View File
@@ -0,0 +1,658 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Session management for the Lewes Protocol.
//!
//! This module implements session management functionality, including replay protection
//! and Noise protocol state handling.
use crate::noise_protocol::{NoiseError, NoiseProtocol, ReadResult};
use crate::packet::LpHeader;
use crate::replay::ReceivingKeyCounterValidator;
use crate::{LpError, LpMessage, LpPacket};
use parking_lot::Mutex;
use snow::Builder;
use std::sync::atomic::{AtomicU64, Ordering};
/// A session in the Lewes Protocol, handling connection state with Noise.
///
/// Sessions manage connection state, including LP replay protection and Noise cryptography.
/// Each session has a unique receiving index and sending index for connection identification.
#[derive(Debug)]
pub struct LpSession {
id: u32,
/// Flag indicating if this session acts as the Noise protocol initiator.
is_initiator: bool,
/// Noise protocol state machine
noise_state: Mutex<NoiseProtocol>,
/// Counter for outgoing packets
sending_counter: AtomicU64,
/// Validator for incoming packet counters to prevent replay attacks
receiving_counter: Mutex<ReceivingKeyCounterValidator>,
}
impl LpSession {
pub fn id(&self) -> u32 {
self.id
}
pub fn noise_state(&self) -> &Mutex<NoiseProtocol> {
&self.noise_state
}
/// Returns true if this session was created as the initiator.
pub fn is_initiator(&self) -> bool {
self.is_initiator
}
/// Creates a new session and initializes the Noise protocol state.
///
/// # Arguments
///
/// * `receiving_index` - Index used for receiving packets (becomes session ID).
/// * `sending_index` - Index used for sending packets to the peer.
/// * `is_initiator` - True if this side initiates the Noise handshake.
/// * `local_static_key` - This side's static private key (e.g., X25519).
/// * `remote_static_key` - The peer's static public key (required for initiator in some patterns like XK).
/// * `psk` - The pre-shared key established out-of-band.
/// * `pattern_name` - The Noise protocol pattern string (e.g., "Noise_XKpsk3_25519_ChaChaPoly_SHA256").
/// * `psk_index` - The index/position where the PSK is mixed in according to the pattern.
pub fn new(
id: u32,
is_initiator: bool,
local_private_key: &[u8],
remote_public_key: &[u8],
psk: &[u8],
) -> Result<Self, LpError> {
// XKpsk3 pattern requires remote static key known upfront (XK)
// and PSK mixed at position 3. This provides forward secrecy with PSK authentication.
let pattern_name = "Noise_XKpsk3_25519_ChaChaPoly_SHA256";
let psk_index = 3;
let params = pattern_name.parse()?;
let builder = Builder::new(params);
let builder = builder.local_private_key(local_private_key);
let builder = builder.remote_public_key(remote_public_key);
let builder = builder.psk(psk_index, psk);
let initial_state = if is_initiator {
builder.build_initiator().map_err(LpError::SnowKeyError)?
} else {
builder.build_responder().map_err(LpError::SnowKeyError)?
};
let noise_protocol = NoiseProtocol::new(initial_state);
Ok(Self {
id,
is_initiator,
noise_state: Mutex::new(noise_protocol),
sending_counter: AtomicU64::new(0),
receiving_counter: Mutex::new(ReceivingKeyCounterValidator::default()),
})
}
pub fn next_packet(&self, message: LpMessage) -> Result<LpPacket, LpError> {
let counter = self.next_counter();
let header = LpHeader::new(self.id(), counter);
let packet = LpPacket::new(header, message);
Ok(packet)
}
/// Generates the next counter value for outgoing packets.
pub fn next_counter(&self) -> u64 {
self.sending_counter.fetch_add(1, Ordering::Relaxed)
}
/// Performs a quick validation check for an incoming packet counter.
///
/// This should be called before performing any expensive operations like
/// decryption/Noise processing to efficiently filter out potential replay attacks.
///
/// # Arguments
///
/// * `counter` - The counter value to check
///
/// # Returns
///
/// * `Ok(())` if the counter is likely valid
/// * `Err(LpError::Replay)` if the counter is invalid or a potential replay
pub fn receiving_counter_quick_check(&self, counter: u64) -> Result<(), LpError> {
// Branchless implementation uses SIMD when available for constant-time
// operations, preventing timing attacks. Check before crypto to save CPU cycles.
let counter_validator = self.receiving_counter.lock();
counter_validator
.will_accept_branchless(counter)
.map_err(LpError::Replay)
}
/// Marks a counter as received after successful packet processing.
///
/// This should be called after a packet has been successfully decoded and processed
/// (including Noise decryption/handshake step) to update the replay protection state.
///
/// # Arguments
///
/// * `counter` - The counter value to mark as received
///
/// # Returns
///
/// * `Ok(())` if the counter was successfully marked
/// * `Err(LpError::Replay)` if the counter cannot be marked (duplicate, too old, etc.)
pub fn receiving_counter_mark(&self, counter: u64) -> Result<(), LpError> {
let mut counter_validator = self.receiving_counter.lock();
counter_validator
.mark_did_receive_branchless(counter)
.map_err(LpError::Replay)
}
/// Returns current packet statistics for monitoring.
///
/// # Returns
///
/// A tuple containing:
/// * The next expected counter value for incoming packets
/// * The total number of received packets
pub fn current_packet_cnt(&self) -> (u64, u64) {
let counter_validator = self.receiving_counter.lock();
counter_validator.current_packet_cnt()
}
/// Prepares the next handshake message to be sent, if any.
///
/// This should be called by the driver/IO layer to check if the Noise protocol
/// state machine requires a message to be sent to the peer.
///
/// # Returns
///
/// * `Ok(None)` if no message needs to be sent currently (e.g., waiting for peer, or handshake complete).
/// * `Err(NoiseError)` if there's an error within the Noise protocol state.
pub fn prepare_handshake_message(&self) -> Option<Result<LpMessage, LpError>> {
let mut noise_state = self.noise_state.lock();
if let Some(message) = noise_state.get_bytes_to_send() {
match message {
Ok(message) => Some(Ok(LpMessage::Handshake(message))),
Err(e) => Some(Err(LpError::NoiseError(e))),
}
} else {
None
}
}
/// Processes a received handshake message from the peer.
///
/// This should be called by the driver/IO layer after receiving a potential
/// handshake message payload from an LP packet.
///
/// # Arguments
///
/// * `noise_payload` - The raw bytes received from the peer, purported to be a Noise handshake message.
///
/// # Returns
///
/// * `Ok(ReadResult)` detailing the outcome (e.g., handshake complete, no-op).
/// * `Err(NoiseError)` if the message is invalid or causes a Noise protocol error.
pub fn process_handshake_message(&self, message: &LpMessage) -> Result<ReadResult, NoiseError> {
let mut noise_state = self.noise_state.lock();
match message {
LpMessage::Handshake(payload) => {
// The sans-io NoiseProtocol::read_message expects only the payload.
noise_state.read_message(payload)
}
_ => Err(NoiseError::IncorrectStateError),
}
}
/// Checks if the Noise handshake phase is complete.
pub fn is_handshake_complete(&self) -> bool {
self.noise_state.lock().is_handshake_finished()
}
/// Encrypts application data payload using the established Noise transport session.
///
/// This should only be called after the handshake is complete (`is_handshake_complete` returns true).
///
/// # Arguments
///
/// * `payload` - The application data to encrypt.
///
/// # Returns
///
/// * `Ok(Vec<u8>)` containing the encrypted Noise message ciphertext.
/// * `Err(NoiseError)` if the session is not in transport mode or encryption fails.
pub fn encrypt_data(&self, payload: &[u8]) -> Result<LpMessage, NoiseError> {
let mut noise_state = self.noise_state.lock();
// Explicitly check if handshake is finished before trying to write
if !noise_state.is_handshake_finished() {
return Err(NoiseError::IncorrectStateError);
}
let payload = noise_state.write_message(payload)?;
Ok(LpMessage::EncryptedData(payload))
}
/// Decrypts an incoming Noise message containing application data.
///
/// This should only be called after the handshake is complete (`is_handshake_complete` returns true)
/// and when an `LPMessage::EncryptedData` is received.
///
/// # Arguments
///
/// * `noise_ciphertext` - The encrypted Noise message received from the peer.
///
/// # Returns
///
/// * `Ok(Vec<u8>)` containing the decrypted application data payload.
/// * `Err(NoiseError)` if the session is not in transport mode, decryption fails, or the message is not data.
pub fn decrypt_data(&self, noise_ciphertext: &LpMessage) -> Result<Vec<u8>, NoiseError> {
let mut noise_state = self.noise_state.lock();
// Explicitly check if handshake is finished before trying to read
if !noise_state.is_handshake_finished() {
return Err(NoiseError::IncorrectStateError);
}
let payload = noise_ciphertext.payload();
match noise_state.read_message(payload)? {
ReadResult::DecryptedData(data) => Ok(data),
_ => Err(NoiseError::IncorrectStateError),
}
}
}
#[cfg(test)]
mod tests {
use snow::{params::NoiseParams, Keypair};
use super::*;
use crate::{replay::ReplayError, sessions_for_tests, NOISE_PATTERN};
// Helper function to generate keypairs for tests
fn generate_keypair() -> Keypair {
let params: NoiseParams = NOISE_PATTERN.parse().unwrap();
snow::Builder::new(params).generate_keypair().unwrap()
}
// Helper function to create a session with real keys for handshake tests
fn create_handshake_test_session(
is_initiator: bool,
local_keys: &Keypair,
remote_pub_key: &[u8],
psk: &[u8],
) -> LpSession {
// Use a dummy ID for testing, the important part is is_initiator
let test_id = if is_initiator { 1 } else { 2 };
LpSession::new(
test_id,
is_initiator,
&local_keys.private,
remote_pub_key,
psk,
)
.expect("Test session creation failed")
}
#[test]
fn test_session_creation() {
let session = sessions_for_tests().0;
// Initial counter should be zero
let counter = session.next_counter();
assert_eq!(counter, 0);
// Counter should increment
let counter = session.next_counter();
assert_eq!(counter, 1);
}
#[test]
fn test_replay_protection_sequential() {
let session = sessions_for_tests().1;
// Sequential counters should be accepted
assert!(session.receiving_counter_quick_check(0).is_ok());
assert!(session.receiving_counter_mark(0).is_ok());
assert!(session.receiving_counter_quick_check(1).is_ok());
assert!(session.receiving_counter_mark(1).is_ok());
// Duplicates should be rejected
assert!(session.receiving_counter_quick_check(0).is_err());
let err = session.receiving_counter_mark(0).unwrap_err();
match err {
LpError::Replay(replay_error) => {
assert!(matches!(replay_error, ReplayError::DuplicateCounter));
}
_ => panic!("Expected replay error"),
}
}
#[test]
fn test_replay_protection_out_of_order() {
let session = sessions_for_tests().1;
// Receive packets in order
assert!(session.receiving_counter_mark(0).is_ok());
assert!(session.receiving_counter_mark(1).is_ok());
assert!(session.receiving_counter_mark(2).is_ok());
// Skip ahead
assert!(session.receiving_counter_mark(10).is_ok());
// Can still receive out-of-order packets within window
assert!(session.receiving_counter_quick_check(5).is_ok());
assert!(session.receiving_counter_mark(5).is_ok());
// But duplicates are still rejected
assert!(session.receiving_counter_quick_check(5).is_err());
assert!(session.receiving_counter_mark(5).is_err());
}
#[test]
fn test_packet_stats() {
let session = sessions_for_tests().1;
// Initial stats
let (next, received) = session.current_packet_cnt();
assert_eq!(next, 0);
assert_eq!(received, 0);
// After receiving packets
assert!(session.receiving_counter_mark(0).is_ok());
assert!(session.receiving_counter_mark(1).is_ok());
let (next, received) = session.current_packet_cnt();
assert_eq!(next, 2);
assert_eq!(received, 2);
}
#[test]
fn test_prepare_handshake_message_initial_state() {
let initiator_keys = generate_keypair();
let responder_keys = generate_keypair();
let psk = [3u8; 32];
let initiator_session =
create_handshake_test_session(true, &initiator_keys, &responder_keys.public, &psk);
let responder_session = create_handshake_test_session(
false,
&responder_keys,
&initiator_keys.public, // Responder also needs initiator's key for XK
&psk,
);
// Initiator should have a message to send immediately (-> e)
let initiator_msg_result = initiator_session.prepare_handshake_message();
assert!(initiator_msg_result.is_some());
let initiator_msg = initiator_msg_result
.unwrap()
.expect("Initiator msg prep failed");
assert!(!initiator_msg.is_empty());
// Responder should have nothing to send initially (waits for <- e)
let responder_msg_result = responder_session.prepare_handshake_message();
assert!(responder_msg_result.is_none());
}
#[test]
fn test_process_handshake_message_first_step() {
let initiator_keys = generate_keypair();
let responder_keys = generate_keypair();
let psk = [4u8; 32];
let initiator_session =
create_handshake_test_session(true, &initiator_keys, &responder_keys.public, &psk);
let responder_session =
create_handshake_test_session(false, &responder_keys, &initiator_keys.public, &psk);
// 1. Initiator prepares the first message (-> e)
let initiator_msg_result = initiator_session.prepare_handshake_message();
let initiator_msg = initiator_msg_result
.unwrap()
.expect("Initiator msg prep failed");
// 2. Responder processes the message (<- e)
let process_result = responder_session.process_handshake_message(&initiator_msg);
// Check the result of processing
match process_result {
Ok(ReadResult::NoOp) => {
// Expected for XK first message, responder doesn't decrypt data yet
}
Ok(other) => panic!("Unexpected process result: {:?}", other),
Err(e) => panic!("Responder processing failed: {:?}", e),
}
// 3. After processing, responder should now have a message to send (-> e, es)
let responder_response_result = responder_session.prepare_handshake_message();
assert!(responder_response_result.is_some());
let responder_response = responder_response_result
.unwrap()
.expect("Responder response prep failed");
assert!(!responder_response.is_empty());
}
#[test]
fn test_handshake_driver_simulation() {
let initiator_keys = generate_keypair();
let responder_keys = generate_keypair();
let psk = [5u8; 32];
let initiator_session =
create_handshake_test_session(true, &initiator_keys, &responder_keys.public, &psk);
let responder_session =
create_handshake_test_session(false, &responder_keys, &initiator_keys.public, &psk);
let mut responder_to_initiator_msg = None;
let mut rounds = 0;
const MAX_ROUNDS: usize = 10; // Safety break for the loop
// Start by priming the initiator message
let mut initiator_to_responder_msg =
initiator_session.prepare_handshake_message().unwrap().ok();
assert!(
initiator_to_responder_msg.is_some(),
"Initiator did not produce initial message"
);
while rounds < MAX_ROUNDS {
rounds += 1;
// === Initiator -> Responder ===
if let Some(msg) = initiator_to_responder_msg.take() {
// Process message
match responder_session.process_handshake_message(&msg) {
Ok(_) => {}
Err(e) => panic!("Responder processing failed: {:?}", e),
}
// Check if responder needs to send a reply
responder_to_initiator_msg = responder_session
.prepare_handshake_message()
.transpose()
.unwrap();
}
// Check completion after potentially processing responder's message below
if initiator_session.is_handshake_complete()
&& responder_session.is_handshake_complete()
{
break;
}
// === Responder -> Initiator ===
if let Some(msg) = responder_to_initiator_msg.take() {
// Process message
match initiator_session.process_handshake_message(&msg) {
Ok(_) => {}
Err(e) => panic!("Initiator processing failed: {:?}", e),
}
// Check if initiator needs to send a reply (should be last message in XK)
initiator_to_responder_msg = initiator_session
.prepare_handshake_message()
.transpose()
.unwrap();
}
// Check completion again after potentially processing initiator's message above
if initiator_session.is_handshake_complete()
&& responder_session.is_handshake_complete()
{
break;
}
}
assert!(
rounds < MAX_ROUNDS,
"Handshake did not complete within max rounds"
);
assert!(
initiator_session.is_handshake_complete(),
"Initiator handshake did not complete"
);
assert!(
responder_session.is_handshake_complete(),
"Responder handshake did not complete"
);
println!("Handshake completed in {} rounds.", rounds);
}
#[test]
fn test_encrypt_decrypt_after_handshake() {
// --- Setup Handshake ---
let initiator_keys = generate_keypair();
let responder_keys = generate_keypair();
let psk = [6u8; 32];
let initiator_session =
create_handshake_test_session(true, &initiator_keys, &responder_keys.public, &psk);
let responder_session =
create_handshake_test_session(false, &responder_keys, &initiator_keys.public, &psk);
// Drive handshake to completion (simplified loop from previous test)
let mut i_msg = initiator_session
.prepare_handshake_message()
.unwrap()
.unwrap();
responder_session.process_handshake_message(&i_msg).unwrap();
let r_msg = responder_session
.prepare_handshake_message()
.unwrap()
.unwrap();
initiator_session.process_handshake_message(&r_msg).unwrap();
i_msg = initiator_session
.prepare_handshake_message()
.unwrap()
.unwrap();
responder_session.process_handshake_message(&i_msg).unwrap();
assert!(initiator_session.is_handshake_complete());
assert!(responder_session.is_handshake_complete());
// --- Test Encryption/Decryption ---
let plaintext = b"Hello, Lewes Protocol!";
// Initiator encrypts
let ciphertext = initiator_session
.encrypt_data(plaintext)
.expect("Initiator encryption failed");
assert_ne!(ciphertext.payload(), plaintext); // Ensure it's actually encrypted
// Responder decrypts
let decrypted = responder_session
.decrypt_data(&ciphertext)
.expect("Responder decryption failed");
assert_eq!(decrypted, plaintext);
// --- Test other direction ---
let plaintext2 = b"Response from responder.";
// Responder encrypts
let ciphertext2 = responder_session
.encrypt_data(plaintext2)
.expect("Responder encryption failed");
assert_ne!(ciphertext2.payload(), plaintext2);
// Initiator decrypts
let decrypted2 = initiator_session
.decrypt_data(&ciphertext2)
.expect("Initiator decryption failed");
assert_eq!(decrypted2, plaintext2);
}
#[test]
fn test_encrypt_decrypt_before_handshake() {
let initiator_keys = generate_keypair();
let responder_keys = generate_keypair();
let psk = [7u8; 32];
let initiator_session =
create_handshake_test_session(true, &initiator_keys, &responder_keys.public, &psk);
assert!(!initiator_session.is_handshake_complete());
// Attempt to encrypt before handshake
let plaintext = b"This should fail";
let result = initiator_session.encrypt_data(plaintext);
assert!(result.is_err());
match result.unwrap_err() {
NoiseError::IncorrectStateError => {} // Expected error
e => panic!("Expected IncorrectStateError, got {:?}", e),
}
// Attempt to decrypt before handshake (using dummy ciphertext)
let dummy_ciphertext = vec![0u8; 32];
let result_decrypt =
initiator_session.decrypt_data(&LpMessage::EncryptedData(dummy_ciphertext));
assert!(result_decrypt.is_err());
match result_decrypt.unwrap_err() {
NoiseError::IncorrectStateError => {} // Expected error
e => panic!("Expected IncorrectStateError, got {:?}", e),
}
}
/*
// These tests remain commented as they rely on the old mock crypto functions
#[test]
fn test_mock_crypto() {
let session = create_test_session(true);
let data = [1, 2, 3, 4, 5];
let mut encrypted = [0; 5];
let mut decrypted = [0; 5];
// Mock encrypt should copy the data
// let encrypted_len = session.encrypt_packet(&data, &mut encrypted).unwrap(); // Removed method
// assert_eq!(encrypted_len, 5);
// assert_eq!(encrypted, data);
// Mock decrypt should copy the data
// let decrypted_len = session.decrypt_packet(&encrypted, &mut decrypted).unwrap(); // Removed method
// assert_eq!(decrypted_len, 5);
// assert_eq!(decrypted, data);
}
#[test]
fn test_mock_crypto_buffer_too_small() {
let session = create_test_session(true);
let data = [1, 2, 3, 4, 5];
let mut too_small = [0; 3];
// Should fail with buffer too small
// let result = session.encrypt_packet(&data, &mut too_small); // Removed method
// assert!(result.is_err());
// match result.unwrap_err() {
// LpError::InsufficientBufferSize => {} // Error type might change
// _ => panic!("Expected InsufficientBufferSize error"),
// }
}
*/
}
File diff suppressed because it is too large Load Diff
+296
View File
@@ -0,0 +1,296 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Session management for the Lewes Protocol.
//!
//! This module implements session lifecycle management functionality, handling
//! creation, retrieval, and storage of sessions.
use dashmap::DashMap;
use crate::keypair::{Keypair, PublicKey};
use crate::noise_protocol::ReadResult;
use crate::state_machine::{LpAction, LpInput, LpState, LpStateBare};
use crate::{LpError, LpMessage, LpSession, LpStateMachine};
/// Manages the lifecycle of Lewes Protocol sessions.
///
/// The SessionManager is responsible for creating, storing, and retrieving sessions,
/// ensuring proper thread-safety for concurrent access.
pub struct SessionManager {
/// Manages state machines directly, keyed by lp_id
state_machines: DashMap<u32, LpStateMachine>,
}
impl Default for SessionManager {
fn default() -> Self {
Self::new()
}
}
impl SessionManager {
/// Creates a new session manager with empty session storage.
pub fn new() -> Self {
Self {
state_machines: DashMap::new(),
}
}
pub fn process_input(&self, lp_id: u32, input: LpInput) -> Result<Option<LpAction>, LpError> {
self.with_state_machine_mut(lp_id, |sm| sm.process_input(input).transpose())?
}
pub fn add(&self, session: LpSession) -> Result<(), LpError> {
let sm = LpStateMachine {
state: LpState::ReadyToHandshake { session },
};
self.state_machines.insert(sm.id()?, sm);
Ok(())
}
pub fn handshaking(&self, lp_id: u32) -> Result<bool, LpError> {
Ok(self.get_state(lp_id)? == LpStateBare::Handshaking)
}
pub fn should_initiate_handshake(&self, lp_id: u32) -> Result<bool, LpError> {
Ok(self.ready_to_handshake(lp_id)? || self.closed(lp_id)?)
}
pub fn ready_to_handshake(&self, lp_id: u32) -> Result<bool, LpError> {
Ok(self.get_state(lp_id)? == LpStateBare::ReadyToHandshake)
}
pub fn closed(&self, lp_id: u32) -> Result<bool, LpError> {
Ok(self.get_state(lp_id)? == LpStateBare::Closed)
}
pub fn transport(&self, lp_id: u32) -> Result<bool, LpError> {
Ok(self.get_state(lp_id)? == LpStateBare::Transport)
}
#[cfg(test)]
fn get_state_machine_id(&self, lp_id: u32) -> Result<u32, LpError> {
self.with_state_machine(lp_id, |sm| sm.id())?
}
pub fn get_state(&self, lp_id: u32) -> Result<LpStateBare, LpError> {
self.with_state_machine(lp_id, |sm| Ok(sm.bare_state()))?
}
pub fn receiving_counter_quick_check(&self, lp_id: u32, counter: u64) -> Result<(), LpError> {
self.with_state_machine(lp_id, |sm| {
sm.session()?.receiving_counter_quick_check(counter)
})?
}
pub fn receiving_counter_mark(&self, lp_id: u32, counter: u64) -> Result<(), LpError> {
self.with_state_machine(lp_id, |sm| sm.session()?.receiving_counter_mark(counter))?
}
pub fn start_handshake(&self, lp_id: u32) -> Option<Result<LpMessage, LpError>> {
self.prepare_handshake_message(lp_id)
}
pub fn prepare_handshake_message(&self, lp_id: u32) -> Option<Result<LpMessage, LpError>> {
self.with_state_machine(lp_id, |sm| sm.session().ok()?.prepare_handshake_message())
.ok()?
}
pub fn is_handshake_complete(&self, lp_id: u32) -> Result<bool, LpError> {
self.with_state_machine(lp_id, |sm| Ok(sm.session()?.is_handshake_complete()))?
}
pub fn next_counter(&self, lp_id: u32) -> Result<u64, LpError> {
self.with_state_machine(lp_id, |sm| Ok(sm.session()?.next_counter()))?
}
pub fn decrypt_data(&self, lp_id: u32, message: &LpMessage) -> Result<Vec<u8>, LpError> {
self.with_state_machine(lp_id, |sm| {
sm.session()?
.decrypt_data(message)
.map_err(LpError::NoiseError)
})?
}
pub fn encrypt_data(&self, lp_id: u32, message: &[u8]) -> Result<LpMessage, LpError> {
self.with_state_machine(lp_id, |sm| {
sm.session()?
.encrypt_data(message)
.map_err(LpError::NoiseError)
})?
}
pub fn current_packet_cnt(&self, lp_id: u32) -> Result<(u64, u64), LpError> {
self.with_state_machine(lp_id, |sm| Ok(sm.session()?.current_packet_cnt()))?
}
pub fn process_handshake_message(
&self,
lp_id: u32,
message: &LpMessage,
) -> Result<ReadResult, LpError> {
self.with_state_machine(lp_id, |sm| {
Ok(sm.session()?.process_handshake_message(message)?)
})?
}
pub fn session_count(&self) -> usize {
self.state_machines.len()
}
pub fn state_machine_exists(&self, lp_id: u32) -> bool {
self.state_machines.contains_key(&lp_id)
}
pub fn with_state_machine<F, R>(&self, lp_id: u32, f: F) -> Result<R, LpError>
where
F: FnOnce(&LpStateMachine) -> R,
{
if let Some(sm) = self.state_machines.get(&lp_id) {
Ok(f(&sm))
} else {
Err(LpError::StateMachineNotFound { lp_id })
}
// self.state_machines.get(&lp_id).map(|sm_ref| f(&*sm_ref)) // Lock held only during closure execution
}
// For mutable access (like running process_input)
pub fn with_state_machine_mut<F, R>(&self, lp_id: u32, f: F) -> Result<R, LpError>
where
F: FnOnce(&mut LpStateMachine) -> R, // Closure takes mutable ref
{
if let Some(mut sm) = self.state_machines.get_mut(&lp_id) {
Ok(f(&mut sm))
} else {
Err(LpError::StateMachineNotFound { lp_id })
}
}
pub fn create_session_state_machine(
&self,
local_keypair: &Keypair,
remote_public_key: &PublicKey,
is_initiator: bool,
psk: &[u8],
) -> Result<u32, LpError> {
let sm = LpStateMachine::new(is_initiator, local_keypair, remote_public_key, psk)?;
let sm_id = sm.id()?;
self.state_machines.insert(sm_id, sm);
Ok(sm_id)
}
/// Method to remove a state machine
pub fn remove_state_machine(&self, lp_id: u32) -> bool {
let removed = self.state_machines.remove(&lp_id);
removed.is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_session_manager_get() {
let manager = SessionManager::new();
let sm_1_id = manager
.create_session_state_machine(
&Keypair::default(),
&PublicKey::default(),
true,
&[2u8; 32],
)
.unwrap();
let retrieved = manager.state_machine_exists(sm_1_id);
assert!(retrieved);
let not_found = manager.state_machine_exists(99);
assert!(!not_found);
}
#[test]
fn test_session_manager_remove() {
let manager = SessionManager::new();
let sm_1_id = manager
.create_session_state_machine(
&Keypair::default(),
&PublicKey::default(),
true,
&[2u8; 32],
)
.unwrap();
let removed = manager.remove_state_machine(sm_1_id);
assert!(removed);
assert_eq!(manager.session_count(), 0);
let removed_again = manager.remove_state_machine(sm_1_id);
assert!(!removed_again);
}
#[test]
fn test_multiple_sessions() {
let manager = SessionManager::new();
let sm_1 = manager
.create_session_state_machine(
&Keypair::default(),
&PublicKey::default(),
true,
&[2u8; 32],
)
.unwrap();
let sm_2 = manager
.create_session_state_machine(
&Keypair::default(),
&PublicKey::default(),
true,
&[2u8; 32],
)
.unwrap();
let sm_3 = manager
.create_session_state_machine(
&Keypair::default(),
&PublicKey::default(),
true,
&[2u8; 32],
)
.unwrap();
assert_eq!(manager.session_count(), 3);
let retrieved1 = manager.get_state_machine_id(sm_1).unwrap();
let retrieved2 = manager.get_state_machine_id(sm_2).unwrap();
let retrieved3 = manager.get_state_machine_id(sm_3).unwrap();
assert_eq!(retrieved1, sm_1);
assert_eq!(retrieved2, sm_2);
assert_eq!(retrieved3, sm_3);
}
#[test]
fn test_session_manager_create_session() {
let manager = SessionManager::new();
let sm = manager.create_session_state_machine(
&Keypair::default(),
&PublicKey::default(),
true,
&[2u8; 32],
);
assert!(sm.is_ok());
let sm = sm.unwrap();
assert_eq!(manager.session_count(), 1);
let retrieved = manager.get_state_machine_id(sm);
assert!(retrieved.is_ok());
assert_eq!(retrieved.unwrap(), sm);
}
}
+649
View File
@@ -0,0 +1,649 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Lewes Protocol State Machine for managing connection lifecycle.
use crate::{
keypair::{Keypair, PublicKey},
make_lp_id,
noise_protocol::NoiseError,
packet::LpPacket,
session::LpSession,
LpError,
};
use bytes::BytesMut;
use std::mem;
/// Represents the possible states of the Lewes Protocol connection.
#[derive(Debug, Default)]
pub enum LpState {
/// Initial state: Ready to start the handshake.
/// State machine is created with keys, lp_id is derived, session is ready.
ReadyToHandshake { session: LpSession },
/// Actively performing the Noise handshake.
/// (We might be able to merge this with ReadyToHandshake if the first step always happens)
Handshaking { session: LpSession }, // Kept for now, logic might merge later
/// Handshake complete, ready for data transport.
Transport { session: LpSession },
/// An error occurred, or the connection was intentionally closed.
Closed { reason: String },
/// Processing an input event.
#[default]
Processing,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LpStateBare {
ReadyToHandshake,
Handshaking,
Transport,
Closed,
Processing,
}
impl From<&LpState> for LpStateBare {
fn from(state: &LpState) -> Self {
match state {
LpState::ReadyToHandshake { .. } => LpStateBare::ReadyToHandshake,
LpState::Handshaking { .. } => LpStateBare::Handshaking,
LpState::Transport { .. } => LpStateBare::Transport,
LpState::Closed { .. } => LpStateBare::Closed,
LpState::Processing => LpStateBare::Processing,
}
}
}
/// Represents inputs that drive the state machine transitions.
#[derive(Debug)]
pub enum LpInput {
/// Explicitly trigger the start of the handshake (optional, could be implicit on creation)
StartHandshake,
/// Received an LP Packet from the network.
ReceivePacket(LpPacket),
/// Application wants to send data (only valid in Transport state).
SendData(Vec<u8>), // Using Bytes for efficiency
/// Close the connection.
Close,
}
/// Represents actions the state machine requests the environment to perform.
#[derive(Debug)]
pub enum LpAction {
/// Send an LP Packet over the network.
SendPacket(LpPacket),
/// Deliver decrypted application data received from the peer.
DeliverData(BytesMut),
/// Inform the environment that the handshake is complete.
HandshakeComplete,
/// Inform the environment that the connection is closed.
ConnectionClosed,
}
/// The Lewes Protocol State Machine.
pub struct LpStateMachine {
pub state: LpState,
}
impl LpStateMachine {
pub fn bare_state(&self) -> LpStateBare {
LpStateBare::from(&self.state)
}
pub fn session(&self) -> Result<&LpSession, LpError> {
match &self.state {
LpState::ReadyToHandshake { session }
| LpState::Handshaking { session }
| LpState::Transport { session } => Ok(session),
LpState::Closed { .. } => Err(LpError::LpSessionClosed),
LpState::Processing => Err(LpError::LpSessionProcessing),
}
}
/// Consume the state machine and return the session with ownership.
/// This is useful when the handshake is complete and you want to transfer
/// ownership of the session to the caller.
pub fn into_session(self) -> Result<LpSession, LpError> {
match self.state {
LpState::ReadyToHandshake { session }
| LpState::Handshaking { session }
| LpState::Transport { session } => Ok(session),
LpState::Closed { .. } => Err(LpError::LpSessionClosed),
LpState::Processing => Err(LpError::LpSessionProcessing),
}
}
pub fn id(&self) -> Result<u32, LpError> {
Ok(self.session()?.id())
}
/// Creates a new state machine, calculates the lp_id, creates the session,
/// and sets the initial state to ReadyToHandshake.
///
/// Requires the local *full* keypair to get the public key for lp_id calculation.
pub fn new(
is_initiator: bool,
local_keypair: &Keypair, // Use Keypair
remote_public_key: &PublicKey,
psk: &[u8],
// session_manager: Arc<SessionManager> // Optional
) -> Result<Self, LpError> {
// Calculate the shared lp_id// Calculate the shared lp_id
let lp_id = make_lp_id(local_keypair.public_key(), remote_public_key);
let local_private_key = local_keypair.private_key().to_bytes();
let remote_public_key = remote_public_key.as_bytes();
// Create the session immediately
let session = LpSession::new(
lp_id,
is_initiator,
&local_private_key,
remote_public_key,
psk,
)?;
// TODO: Register the session with the SessionManager if applicable
// if let Some(manager) = session_manager {
// manager.insert_session(lp_id, session.clone())?; // Assuming insert_session exists
// }
Ok(LpStateMachine {
state: LpState::ReadyToHandshake { session },
// Store necessary info if needed for recreation, otherwise remove
// is_initiator,
// local_private_key: local_private_key.to_vec(),
// remote_public_key: remote_public_key.to_vec(),
// psk: psk.to_vec(),
})
}
/// Processes an input event and returns a list of actions to perform.
pub fn process_input(&mut self, input: LpInput) -> Option<Result<LpAction, LpError>> {
// 1. Replace current state with a placeholder, taking ownership of the real current state.
let current_state = mem::take(&mut self.state);
let mut result_action: Option<Result<LpAction, LpError>> = None;
// 2. Match on the owned current_state. Each arm calculates and returns the NEXT state.
let next_state = match (current_state, input) {
// --- ReadyToHandshake State ---
(LpState::ReadyToHandshake { session }, LpInput::StartHandshake) => {
if session.is_initiator() {
// Initiator sends the first message
match self.start_handshake(&session) {
Some(Ok(action)) => {
result_action = Some(Ok(action));
LpState::Handshaking { session } // Transition state
}
Some(Err(e)) => {
// Error occurred, move to Closed state
let reason = e.to_string();
result_action = Some(Err(e));
LpState::Closed { reason }
}
None => {
// Should not happen, treat as internal error
let err = LpError::Internal(
"start_handshake returned None unexpectedly".to_string(),
);
let reason = err.to_string();
result_action = Some(Err(err));
LpState::Closed { reason }
}
}
} else {
// Responder waits for the first message, transition to Handshaking to wait.
LpState::Handshaking { session }
// No action needed yet, result_action remains None.
}
}
// --- Handshaking State ---
(LpState::Handshaking { session }, LpInput::ReceivePacket(packet)) => {
// Check if packet lp_id matches our session
if packet.header.session_id() != session.id() {
result_action = Some(Err(LpError::UnknownSessionId(packet.header.session_id())));
// Don't change state, return the original state variant
LpState::Handshaking { session }
} else {
// --- Inline handle_handshake_packet logic ---
// 1. Check replay protection *before* processing
if let Err(e) = session.receiving_counter_quick_check(packet.header.counter) {
let _reason = e.to_string();
result_action = Some(Err(e));
LpState::Handshaking { session }
// LpState::Closed { reason }
} else {
// 2. Process the handshake message
match session.process_handshake_message(&packet.message) {
Ok(_) => {
// 3. Mark counter as received *after* successful processing
if let Err(e) = session.receiving_counter_mark(packet.header.counter) {
let _reason = e.to_string();
result_action = Some(Err(e));
// LpState::Closed { reason }
LpState::Handshaking { session }
} else {
// 4. Check if handshake is now complete
if session.is_handshake_complete() {
result_action = Some(Ok(LpAction::HandshakeComplete));
LpState::Transport { session } // Transition to Transport
} else {
// 5. Check if we need to send the next handshake message
match session.prepare_handshake_message() {
Some(Ok(message)) => {
match session.next_packet(message) {
Ok(response_packet) => {
result_action = Some(Ok(LpAction::SendPacket(response_packet)));
// Check AGAIN if handshake became complete *after preparing*
if session.is_handshake_complete() {
LpState::Transport { session } // Transition to Transport
} else {
LpState::Handshaking { session } // Remain Handshaking
}
}
Err(e) => {
let reason = e.to_string();
result_action = Some(Err(e));
LpState::Closed { reason }
}
}
}
Some(Err(e)) => {
let reason = e.to_string();
result_action = Some(Err(e));
LpState::Closed { reason }
}
None => {
// Handshake stalled unexpectedly
let err = LpError::NoiseError(NoiseError::Other(
"Handshake stalled unexpectedly".to_string(),
));
let reason = err.to_string();
result_action = Some(Err(err));
LpState::Closed { reason }
}
}
}
}
}
Err(e) => { // Error from process_handshake_message
let reason = e.to_string();
result_action = Some(Err(e.into()));
LpState::Closed { reason }
}
}
}
// --- End inline handle_handshake_packet logic ---
}
}
// Reject SendData during handshake
(LpState::Handshaking { session }, LpInput::SendData(_)) => { // Keep session if returning to this state
result_action = Some(Err(LpError::InvalidStateTransition {
state: "Handshaking".to_string(),
input: "SendData".to_string(),
}));
// Invalid input, remain in Handshaking state
LpState::Handshaking { session }
}
// Reject StartHandshake if already handshaking
(LpState::Handshaking { session }, LpInput::StartHandshake) => { // Keep session
result_action = Some(Err(LpError::InvalidStateTransition {
state: "Handshaking".to_string(),
input: "StartHandshake".to_string(),
}));
// Invalid input, remain in Handshaking state
LpState::Handshaking { session }
}
// --- Transport State ---
(LpState::Transport { session }, LpInput::ReceivePacket(packet)) => { // Needs mut session for marking counter
// Check if packet lp_id matches our session
if packet.header.session_id() != session.id() {
result_action = Some(Err(LpError::UnknownSessionId(packet.header.session_id())));
// Remain in transport state
LpState::Transport { session }
} else {
// --- Inline handle_data_packet logic ---
// 1. Check replay protection
if let Err(e) = session.receiving_counter_quick_check(packet.header.counter) {
let _reason = e.to_string();
result_action = Some(Err(e));
LpState::Transport { session }
} else {
// 2. Decrypt data
match session.decrypt_data(&packet.message) {
Ok(plaintext) => {
// 3. Mark counter as received
if let Err(e) = session.receiving_counter_mark(packet.header.counter) {
let _reason = e.to_string();
result_action = Some(Err(e));
LpState::Transport{ session }
} else {
// 4. Deliver data
result_action = Some(Ok(LpAction::DeliverData(BytesMut::from(plaintext.as_slice()))));
// Remain in transport state
LpState::Transport { session }
}
}
Err(e) => { // Error decrypting data
let reason = e.to_string();
result_action = Some(Err(e.into()));
LpState::Closed { reason }
}
}
}
// --- End inline handle_data_packet logic ---
}
}
(LpState::Transport { session }, LpInput::SendData(data)) => {
// Encrypt and send application data
match self.prepare_data_packet(&session, &data) {
Ok(packet) => result_action = Some(Ok(LpAction::SendPacket(packet))),
Err(e) => {
// If prepare fails, should we close? Let's report error and stay Transport for now.
// Alternative: transition to Closed state.
result_action = Some(Err(e.into()));
}
}
// Remain in transport state
LpState::Transport { session }
}
// Reject StartHandshake if already in transport
(LpState::Transport { session }, LpInput::StartHandshake) => { // Keep session
result_action = Some(Err(LpError::InvalidStateTransition {
state: "Transport".to_string(),
input: "StartHandshake".to_string(),
}));
// Invalid input, remain in Transport state
LpState::Transport { session }
}
// --- Close Transition (applies to ReadyToHandshake, Handshaking, Transport) ---
(
LpState::ReadyToHandshake { .. } // We consume the session here
| LpState::Handshaking { .. }
| LpState::Transport { .. },
LpInput::Close,
) => {
result_action = Some(Ok(LpAction::ConnectionClosed));
// Transition to Closed state
LpState::Closed { reason: "Closed by user".to_string() }
}
// Ignore Close if already Closed
(closed_state @ LpState::Closed { .. }, LpInput::Close) => {
// result_action remains None
// Return the original closed state
closed_state
}
// Ignore StartHandshake if Closed
// (closed_state @ LpState::Closed { .. }, LpInput::StartHandshake) => {
// result_action = Some(Err(LpError::LpSessionClosed));
// closed_state
// }
// Ignore ReceivePacket if Closed
(closed_state @ LpState::Closed { .. }, LpInput::ReceivePacket(_)) => {
result_action = Some(Err(LpError::LpSessionClosed));
closed_state
}
// Ignore SendData if Closed
(closed_state @ LpState::Closed { .. }, LpInput::SendData(_)) => {
result_action = Some(Err(LpError::LpSessionClosed));
closed_state
}
// Processing state should not be matched directly if using replace
(LpState::Processing, _) => {
// This case should ideally be unreachable if placeholder logic is correct
let err = LpError::Internal("Reached Processing state unexpectedly".to_string());
let reason = err.to_string();
result_action = Some(Err(err));
LpState::Closed { reason }
}
// --- Default: Invalid input for current state (if any combinations missed) ---
// Consider if this should transition to Closed state. For now, just report error
// and transition to Closed as a safety measure.
(invalid_state, input) => {
let err = LpError::InvalidStateTransition {
state: format!("{:?}", invalid_state), // Use owned state for debug info
input: format!("{:?}", input),
};
let reason = err.to_string();
result_action = Some(Err(err));
LpState::Closed { reason }
}
};
// 3. Put the calculated next state back into the machine.
self.state = next_state;
result_action // Return the determined action (or None)
}
// Helper to start the handshake (sends first message if initiator)
// Kept as it doesn't mutate self.state
fn start_handshake(&self, session: &LpSession) -> Option<Result<LpAction, LpError>> {
session
.prepare_handshake_message()
.map(|result| match result {
Ok(message) => match session.next_packet(message) {
Ok(packet) => Ok(LpAction::SendPacket(packet)),
Err(e) => Err(e),
},
Err(e) => Err(e),
})
}
// Helper to prepare an outgoing data packet
// Kept as it doesn't mutate self.state
fn prepare_data_packet(
&self,
session: &LpSession,
data: &[u8],
) -> Result<LpPacket, NoiseError> {
let encrypted_message = session.encrypt_data(data)?;
session
.next_packet(encrypted_message)
.map_err(|e| NoiseError::Other(e.to_string())) // Improve error conversion?
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::keypair::Keypair;
use bytes::Bytes;
#[test]
fn test_state_machine_init() {
let init_key = Keypair::new();
let resp_key = Keypair::new();
let psk = vec![0u8; 32];
let remote_pub_key = resp_key.public_key();
let initiator_sm = LpStateMachine::new(true, &init_key, remote_pub_key, &psk);
assert!(initiator_sm.is_ok());
let initiator_sm = initiator_sm.unwrap();
assert!(matches!(
initiator_sm.state,
LpState::ReadyToHandshake { .. }
));
let init_session = initiator_sm.session().unwrap();
assert!(init_session.is_initiator());
let responder_sm = LpStateMachine::new(false, &resp_key, init_key.public_key(), &psk);
assert!(responder_sm.is_ok());
let responder_sm = responder_sm.unwrap();
assert!(matches!(
responder_sm.state,
LpState::ReadyToHandshake { .. }
));
let resp_session = responder_sm.session().unwrap();
assert!(!resp_session.is_initiator());
// Check lp_id is the same
let expected_lp_id = make_lp_id(init_key.public_key(), remote_pub_key);
assert_eq!(init_session.id(), expected_lp_id);
assert_eq!(resp_session.id(), expected_lp_id);
}
#[test]
fn test_state_machine_simplified_flow() {
// Create test keys
let init_key = Keypair::new();
let resp_key = Keypair::new();
let psk = vec![0u8; 32];
// Create state machines (already in ReadyToHandshake)
let mut initiator = LpStateMachine::new(
true, // is_initiator
&init_key,
resp_key.public_key(),
&psk.clone(),
)
.unwrap();
let mut responder = LpStateMachine::new(
false, // is_initiator
&resp_key,
init_key.public_key(),
&psk,
)
.unwrap();
let lp_id = initiator.id().unwrap();
assert_eq!(lp_id, responder.id().unwrap());
// --- Start Handshake --- (No index exchange needed)
println!("--- Step 1: Initiator starts handshake ---");
let init_actions_1 = initiator.process_input(LpInput::StartHandshake);
let init_packet_1 = if let Some(Ok(LpAction::SendPacket(packet))) = init_actions_1 {
packet.clone()
} else {
panic!("Initiator should produce 1 action");
};
assert!(
matches!(initiator.state, LpState::Handshaking { .. }),
"Initiator should be Handshaking"
);
assert_eq!(
init_packet_1.header.session_id(),
lp_id,
"Packet 1 has wrong lp_id"
);
println!("--- Step 2: Responder starts handshake (waits) ---");
let resp_actions_1 = responder.process_input(LpInput::StartHandshake);
assert!(
resp_actions_1.is_none(),
"Responder should produce 0 actions initially"
);
assert!(
matches!(responder.state, LpState::Handshaking { .. }),
"Responder should be Handshaking"
);
// --- Handshake Message Exchange ---
println!("--- Step 3: Responder receives packet 1, sends packet 2 ---");
let resp_actions_2 = responder.process_input(LpInput::ReceivePacket(init_packet_1));
let resp_packet_2 = if let Some(Ok(LpAction::SendPacket(packet))) = resp_actions_2 {
packet.clone()
} else {
panic!("Responder should send packet 2");
};
assert!(
matches!(responder.state, LpState::Handshaking { .. }),
"Responder still Handshaking"
);
assert_eq!(
resp_packet_2.header.session_id(),
lp_id,
"Packet 2 has wrong lp_id"
);
println!("--- Step 4: Initiator receives packet 2, sends packet 3 ---");
let init_actions_2 = initiator.process_input(LpInput::ReceivePacket(resp_packet_2));
let init_packet_3 = if let Some(Ok(LpAction::SendPacket(packet))) = init_actions_2 {
packet.clone()
} else {
panic!("Initiator should send packet 3");
};
assert!(
matches!(initiator.state, LpState::Transport { .. }),
"Initiator should be Transport"
);
assert_eq!(
init_packet_3.header.session_id(),
lp_id,
"Packet 3 has wrong lp_id"
);
println!("--- Step 5: Responder receives packet 3, completes handshake ---");
let resp_actions_3 = responder.process_input(LpInput::ReceivePacket(init_packet_3));
assert!(
matches!(resp_actions_3, Some(Ok(LpAction::HandshakeComplete))),
"Responder should complete handshake"
);
assert!(
matches!(responder.state, LpState::Transport { .. }),
"Responder should be Transport"
);
// --- Transport Phase ---
println!("--- Step 6: Initiator sends data ---");
let data_to_send_1 = b"hello responder";
let init_actions_3 = initiator.process_input(LpInput::SendData(data_to_send_1.to_vec()));
let data_packet_1 = if let Some(Ok(LpAction::SendPacket(packet))) = init_actions_3 {
packet.clone()
} else {
panic!("Initiator should send data packet");
};
assert_eq!(data_packet_1.header.session_id(), lp_id);
println!("--- Step 7: Responder receives data ---");
let resp_actions_4 = responder.process_input(LpInput::ReceivePacket(data_packet_1));
let resp_data_1 = if let Some(Ok(LpAction::DeliverData(data))) = resp_actions_4 {
data
} else {
panic!("Responder should deliver data");
};
assert_eq!(resp_data_1, Bytes::copy_from_slice(data_to_send_1));
println!("--- Step 8: Responder sends data ---");
let data_to_send_2 = b"hello initiator";
let resp_actions_5 = responder.process_input(LpInput::SendData(data_to_send_2.to_vec()));
let data_packet_2 = if let Some(Ok(LpAction::SendPacket(packet))) = resp_actions_5 {
packet.clone()
} else {
panic!("Responder should send data packet");
};
assert_eq!(data_packet_2.header.session_id(), lp_id);
println!("--- Step 9: Initiator receives data ---");
let init_actions_4 = initiator.process_input(LpInput::ReceivePacket(data_packet_2));
if let Some(Ok(LpAction::DeliverData(data))) = init_actions_4 {
assert_eq!(data, Bytes::copy_from_slice(data_to_send_2));
} else {
panic!("Initiator should deliver data");
}
// --- Close ---
println!("--- Step 10: Initiator closes ---");
let init_actions_5 = initiator.process_input(LpInput::Close);
assert!(matches!(
init_actions_5,
Some(Ok(LpAction::ConnectionClosed))
));
assert!(matches!(initiator.state, LpState::Closed { .. }));
println!("--- Step 11: Responder closes ---");
let resp_actions_6 = responder.process_input(LpInput::Close);
assert!(matches!(
resp_actions_6,
Some(Ok(LpAction::ConnectionClosed))
));
assert!(matches!(responder.state, LpState::Closed { .. }));
}
}
+7
View File
@@ -12,9 +12,16 @@ license.workspace = true
workspace = true
[dependencies]
serde = { workspace = true, features = ["derive"] }
tokio-util.workspace = true
nym-authenticator-requests = { path = "../authenticator-requests" }
nym-credentials-interface = { path = "../credentials-interface" }
nym-crypto = { path = "../crypto" }
nym-ip-packet-requests = { path = "../ip-packet-requests" }
nym-sphinx = { path = "../nymsphinx" }
nym-wireguard-types = { path = "../wireguard-types" }
[dev-dependencies]
bincode.workspace = true
time.workspace = true
+7 -1
View File
@@ -1,12 +1,17 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
mod lp_messages;
pub use lp_messages::{LpRegistrationRequest, LpRegistrationResponse, RegistrationMode};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use nym_authenticator_requests::AuthenticatorVersion;
use nym_crypto::asymmetric::x25519::PublicKey;
use nym_ip_packet_requests::IpPair;
use nym_sphinx::addressing::{NodeIdentity, Recipient};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct NymNode {
@@ -14,10 +19,11 @@ pub struct NymNode {
pub ip_address: IpAddr,
pub ipr_address: Option<Recipient>,
pub authenticator_address: Option<Recipient>,
pub lp_address: Option<SocketAddr>,
pub version: AuthenticatorVersion,
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GatewayData {
pub public_key: PublicKey,
pub endpoint: SocketAddr,
+270
View File
@@ -0,0 +1,270 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! LP (Lewes Protocol) registration message types shared between client and gateway.
use nym_credentials_interface::{CredentialSpendingData, TicketType};
use serde::{Deserialize, Serialize};
use std::net::IpAddr;
use crate::GatewayData;
/// Registration request sent by client after LP handshake
/// Aligned with existing authenticator registration flow
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LpRegistrationRequest {
/// Client's WireGuard public key (for dVPN mode)
pub wg_public_key: nym_wireguard_types::PeerPublicKey,
/// Bandwidth credential for payment
pub credential: CredentialSpendingData,
/// Ticket type for bandwidth allocation
pub ticket_type: TicketType,
/// Registration mode
pub mode: RegistrationMode,
/// Client's IP address (for tracking/metrics)
pub client_ip: IpAddr,
/// Unix timestamp for replay protection
pub timestamp: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum RegistrationMode {
/// dVPN mode - register as WireGuard peer (most common)
Dvpn,
/// Mixnet mode - register for mixnet usage (future)
Mixnet {
/// Client identifier for mixnet mode
client_id: [u8; 32],
},
}
/// Registration response from gateway
/// Contains GatewayData for compatibility with existing client code
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LpRegistrationResponse {
/// Whether registration succeeded
pub success: bool,
/// Error message if registration failed
pub error: Option<String>,
/// Gateway configuration data (same as returned by authenticator)
/// This matches what WireguardRegistrationResult expects
pub gateway_data: Option<GatewayData>,
/// Allocated bandwidth in bytes
pub allocated_bandwidth: i64,
/// Session identifier for future reference
pub session_id: u32,
}
impl LpRegistrationRequest {
/// Create a new dVPN registration request
pub fn new_dvpn(
wg_public_key: nym_wireguard_types::PeerPublicKey,
credential: CredentialSpendingData,
ticket_type: TicketType,
client_ip: IpAddr,
) -> Self {
Self {
wg_public_key,
credential,
ticket_type,
mode: RegistrationMode::Dvpn,
client_ip,
#[allow(clippy::expect_used)]
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("System time before UNIX epoch")
.as_secs(),
}
}
/// Validate the request timestamp is within acceptable bounds
pub fn validate_timestamp(&self, max_skew_secs: u64) -> bool {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
(now as i64 - self.timestamp as i64).abs() <= max_skew_secs as i64
}
}
impl LpRegistrationResponse {
/// Create a success response with GatewayData
pub fn success(session_id: u32, allocated_bandwidth: i64, gateway_data: GatewayData) -> Self {
Self {
success: true,
error: None,
gateway_data: Some(gateway_data),
allocated_bandwidth,
session_id,
}
}
/// Create an error response
pub fn error(session_id: u32, error: String) -> Self {
Self {
success: false,
error: Some(error),
gateway_data: None,
allocated_bandwidth: 0,
session_id,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::Ipv4Addr;
// ==================== Helper Functions ====================
fn create_test_gateway_data() -> GatewayData {
use std::net::Ipv6Addr;
GatewayData {
public_key: nym_crypto::asymmetric::x25519::PublicKey::from(
nym_sphinx::PublicKey::from([1u8; 32]),
),
private_ipv4: Ipv4Addr::new(10, 0, 0, 1),
private_ipv6: Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 1),
endpoint: "192.168.1.1:8080".parse().expect("Valid test endpoint"),
}
}
// ==================== LpRegistrationRequest Tests ====================
// ==================== LpRegistrationResponse Tests ====================
#[test]
fn test_lp_registration_response_success() {
let gateway_data = create_test_gateway_data();
let session_id = 12345;
let allocated_bandwidth = 1_000_000_000;
let response =
LpRegistrationResponse::success(session_id, allocated_bandwidth, gateway_data.clone());
assert!(response.success);
assert!(response.error.is_none());
assert!(response.gateway_data.is_some());
assert_eq!(response.allocated_bandwidth, allocated_bandwidth);
assert_eq!(response.session_id, session_id);
let returned_gw_data = response
.gateway_data
.expect("Gateway data should be present in success response");
assert_eq!(returned_gw_data.public_key, gateway_data.public_key);
assert_eq!(returned_gw_data.private_ipv4, gateway_data.private_ipv4);
assert_eq!(returned_gw_data.private_ipv6, gateway_data.private_ipv6);
assert_eq!(returned_gw_data.endpoint, gateway_data.endpoint);
}
#[test]
fn test_lp_registration_response_error() {
let session_id = 54321;
let error_msg = String::from("Insufficient bandwidth");
let response = LpRegistrationResponse::error(session_id, error_msg.clone());
assert!(!response.success);
assert_eq!(response.error, Some(error_msg));
assert!(response.gateway_data.is_none());
assert_eq!(response.allocated_bandwidth, 0);
assert_eq!(response.session_id, session_id);
}
#[test]
fn test_lp_registration_response_serialize_deserialize_success() {
let gateway_data = create_test_gateway_data();
let original = LpRegistrationResponse::success(999, 5_000_000_000, gateway_data);
// Serialize
let serialized = bincode::serialize(&original).expect("Failed to serialize response");
// Deserialize
let deserialized: LpRegistrationResponse =
bincode::deserialize(&serialized).expect("Failed to deserialize response");
assert_eq!(deserialized.success, original.success);
assert_eq!(deserialized.error, original.error);
assert_eq!(
deserialized.allocated_bandwidth,
original.allocated_bandwidth
);
assert_eq!(deserialized.session_id, original.session_id);
assert!(deserialized.gateway_data.is_some());
}
#[test]
fn test_lp_registration_response_serialize_deserialize_error() {
let original = LpRegistrationResponse::error(777, String::from("Test error message"));
// Serialize
let serialized = bincode::serialize(&original).expect("Failed to serialize response");
// Deserialize
let deserialized: LpRegistrationResponse =
bincode::deserialize(&serialized).expect("Failed to deserialize response");
assert_eq!(deserialized.success, original.success);
assert_eq!(deserialized.error, original.error);
assert_eq!(deserialized.allocated_bandwidth, 0);
assert_eq!(deserialized.session_id, original.session_id);
assert!(deserialized.gateway_data.is_none());
}
#[test]
fn test_lp_registration_response_malformed_deserialize() {
// Create invalid bincode data
let invalid_data = vec![0xFF; 100];
// Attempt to deserialize
let result: Result<LpRegistrationResponse, _> = bincode::deserialize(&invalid_data);
assert!(
result.is_err(),
"Expected deserialization to fail for malformed data"
);
}
// ==================== RegistrationMode Tests ====================
#[test]
fn test_registration_mode_serialize_dvpn() {
let mode = RegistrationMode::Dvpn;
let serialized = bincode::serialize(&mode).expect("Failed to serialize mode");
let deserialized: RegistrationMode =
bincode::deserialize(&serialized).expect("Failed to deserialize mode");
assert!(matches!(deserialized, RegistrationMode::Dvpn));
}
#[test]
fn test_registration_mode_serialize_mixnet() {
let client_id = [99u8; 32];
let mode = RegistrationMode::Mixnet { client_id };
let serialized = bincode::serialize(&mode).expect("Failed to serialize mode");
let deserialized: RegistrationMode =
bincode::deserialize(&serialized).expect("Failed to deserialize mode");
match deserialized {
RegistrationMode::Mixnet { client_id: id } => {
assert_eq!(id, client_id);
}
_ => panic!("Expected Mixnet mode"),
}
}
}
+2 -2
View File
@@ -24,6 +24,6 @@ pub use crate::runtime_registry::RegistryAccessError;
/// Get or create a ShutdownTracker for SDK use.
/// This provides automatic task management without requiring manual setup.
pub fn create_sdk_shutdown_tracker() -> Result<ShutdownTracker, RegistryAccessError> {
Ok(runtime_registry::RuntimeRegistry::create_sdk()?.shutdown_tracker_owned())
pub fn get_sdk_shutdown_tracker() -> Result<ShutdownTracker, RegistryAccessError> {
Ok(runtime_registry::RuntimeRegistry::get_or_create_sdk()?.shutdown_tracker_owned())
}
+16 -34
View File
@@ -19,45 +19,30 @@ pub(crate) struct RuntimeRegistry {
pub enum RegistryAccessError {
#[error("the runtime registry is poisoned")]
Poisoned,
#[error("The SDK ShutdownManager already exists")]
ExistingShutdownManager,
#[error("No existing SDK ShutdownManager")]
MissingShutdownManager,
}
impl RuntimeRegistry {
/// Create a ShutdownManager for SDK use.
/// Get or create a ShutdownManager for SDK use.
/// This manager doesn't listen to OS signals, making it suitable for library use.
/// This function overwrite any existing manager!
pub(crate) fn create_sdk() -> Result<Arc<ShutdownManager>, RegistryAccessError> {
let mut guard = REGISTRY
.sdk_manager
.write()
.map_err(|_| RegistryAccessError::Poisoned)?;
Ok(guard
.insert(Arc::new(
ShutdownManager::new_without_signals().with_cancel_on_panic(),
))
.clone())
}
/// Get the ShutdownManager for SDK use.
/// This manager doesn't listen to OS signals, making it suitable for library use.
/// Not yet used, but maybe in the future
#[allow(dead_code)]
pub(crate) fn get_sdk() -> Result<Arc<ShutdownManager>, RegistryAccessError> {
pub(crate) fn get_or_create_sdk() -> Result<Arc<ShutdownManager>, RegistryAccessError> {
let guard = REGISTRY
.sdk_manager
.read()
.map_err(|_| RegistryAccessError::Poisoned)?;
if let Some(manager) = guard.as_ref() {
Ok(manager.clone())
} else {
Err(RegistryAccessError::MissingShutdownManager)
return Ok(manager.clone());
}
drop(guard);
let mut guard = REGISTRY
.sdk_manager
.write()
.map_err(|_| RegistryAccessError::Poisoned)?;
Ok(guard
.get_or_insert_with(|| {
Arc::new(ShutdownManager::new_without_signals().with_cancel_on_panic())
})
.clone())
}
/// Check if an SDK manager has been created.
@@ -100,13 +85,10 @@ mod tests {
assert!(!RuntimeRegistry::has_sdk_manager().unwrap());
// Error if nothing was created
assert!(RuntimeRegistry::get_sdk().is_err());
let manager1 = RuntimeRegistry::create_sdk().unwrap();
let manager1 = RuntimeRegistry::get_or_create_sdk().unwrap();
assert!(RuntimeRegistry::has_sdk_manager().unwrap());
let manager2 = RuntimeRegistry::get_sdk().unwrap();
let manager2 = RuntimeRegistry::get_or_create_sdk().unwrap();
// Should return the same instance
assert!(Arc::ptr_eq(&manager1, &manager2));
+1 -8
View File
@@ -15,10 +15,9 @@ jwt-simple = { workspace = true }
reqwest = { workspace = true, features = ["rustls-tls"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
time = { workspace = true, features = ["serde", "formatting", "parsing"] }
time = { workspace = true, features = ["serde"] }
thiserror = { workspace = true }
tracing = { workspace = true }
utoipa = { workspace = true, optional = true }
nym-http-api-client = { path = "../http-api-client", default-features = false }
nym-crypto = { path = "../crypto", features = ["asymmetric", "serde", "naive_jwt"] }
@@ -26,12 +25,6 @@ nym-crypto = { path = "../crypto", features = ["asymmetric", "serde", "naive_jwt
[dev-dependencies]
anyhow = { workspace = true }
time = { workspace = true, features = ["macros"] }
nym-test-utils = { path = "../test-utils" }
nym-crypto = { path = "../crypto", features = ["rand"] }
[features]
openapi = ["utoipa"]
[lints]
workspace = true
+16 -46
View File
@@ -1,43 +1,31 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::UpgradeModeCheckError;
use nym_crypto::asymmetric::ed25519;
use nym_http_api_client::generate_user_agent;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use time::OffsetDateTime;
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Copy)]
pub struct UpgradeModeAttestation {
#[serde(flatten)]
pub content: UpgradeModeAttestationContent,
#[serde(with = "ed25519::bs58_ed25519_signature")]
#[cfg_attr(feature = "openapi", schema(value_type = String))]
pub signature: ed25519::Signature,
}
impl UpgradeModeAttestation {
pub fn authorised_to_issue_jwt(&self, key: &ed25519::PublicKey) -> bool {
self.content.authorised_jwt_issuers.contains(key)
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Copy)]
#[serde(tag = "type")]
#[serde(rename = "upgrade_mode")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct UpgradeModeAttestationContent {
#[serde(with = "time::serde::rfc3339")]
#[cfg_attr(feature = "openapi", schema(value_type = String))]
#[serde(with = "time::serde::timestamp")]
pub starting_time: OffsetDateTime,
#[serde(with = "ed25519::bs58_ed25519_pubkey")]
#[cfg_attr(feature = "openapi", schema(value_type = String))]
pub attester_public_key: ed25519::PublicKey,
#[serde(with = "ed25519::vec_bs58_ed25519_pubkey")]
#[cfg_attr(feature = "openapi", schema(value_type = Vec<String>))]
pub authorised_jwt_issuers: Vec<ed25519::PublicKey>,
}
impl UpgradeModeAttestation {
@@ -57,26 +45,17 @@ impl UpgradeModeAttestationContent {
}
}
pub fn generate_new_attestation(
key: &ed25519::PrivateKey,
authorised_jwt_issuers: Vec<ed25519::PublicKey>,
) -> UpgradeModeAttestation {
generate_new_attestation_with_starting_time(
key,
authorised_jwt_issuers,
OffsetDateTime::now_utc(),
)
pub fn generate_new_attestation(key: &ed25519::PrivateKey) -> UpgradeModeAttestation {
generate_new_attestation_with_starting_time(key, OffsetDateTime::now_utc())
}
pub fn generate_new_attestation_with_starting_time(
key: &ed25519::PrivateKey,
authorised_jwt_issuers: Vec<ed25519::PublicKey>,
starting_time: OffsetDateTime,
) -> UpgradeModeAttestation {
let content = UpgradeModeAttestationContent {
starting_time,
attester_public_key: key.into(),
authorised_jwt_issuers,
};
UpgradeModeAttestation {
signature: key.sign(content.as_json()),
@@ -84,19 +63,17 @@ pub fn generate_new_attestation_with_starting_time(
}
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn attempt_retrieve_attestation(
pub async fn attempt_retrieve(
url: &str,
user_agent: Option<nym_http_api_client::UserAgent>,
) -> Result<Option<UpgradeModeAttestation>, crate::UpgradeModeCheckError> {
let retrieval_failure = |source| crate::UpgradeModeCheckError::AttestationRetrievalFailure {
) -> Result<Option<UpgradeModeAttestation>, UpgradeModeCheckError> {
let retrieval_failure = |source| UpgradeModeCheckError::AttestationRetrievalFailure {
url: url.to_string(),
source,
};
let attestation = reqwest::ClientBuilder::new()
.user_agent(user_agent.unwrap_or_else(|| nym_http_api_client::generate_user_agent!()))
.timeout(std::time::Duration::from_secs(5))
.user_agent(generate_user_agent!())
.timeout(Duration::from_secs(5))
.build()
.map_err(retrieval_failure)?
.get(url)
@@ -124,20 +101,13 @@ mod tests {
163, 122, 170, 79, 198, 87, 85, 36, 29, 243, 92, 64, 161,
])?;
let authorised_jwt_issuers = vec![ed25519::PublicKey::from_base58_string(
"Be9wH7xuXBRJAuV1pC7MALZv6a61RvWQ3SypsNarqTt",
)?];
let attestation = generate_new_attestation_with_starting_time(
&key,
authorised_jwt_issuers,
starting_time,
);
let attestation = generate_new_attestation_with_starting_time(&key, starting_time);
let attestation_json = serde_json::to_string(&attestation)?;
let attestation_content_json = attestation.content.as_json();
let expected_attestation = r#"{"type":"upgrade_mode","starting_time":"2021-08-23T12:00:00Z","attester_public_key":"3pkFcBXCEmbmXBT2G8CkFMuKisJcH54mbBGvncHaDibt","authorised_jwt_issuers":["Be9wH7xuXBRJAuV1pC7MALZv6a61RvWQ3SypsNarqTt"],"signature":"5Kt9dfwvnkdnDcENbwNyitrxghyckWUYycBv8jUUn7hJUMohWEMc6otb3scXQfCrAGSE7FD5m7kr6auBmkAmfczY"}"#;
let expected_content = r#"{"type":"upgrade_mode","starting_time":"2021-08-23T12:00:00Z","attester_public_key":"3pkFcBXCEmbmXBT2G8CkFMuKisJcH54mbBGvncHaDibt","authorised_jwt_issuers":["Be9wH7xuXBRJAuV1pC7MALZv6a61RvWQ3SypsNarqTt"]}"#;
let expected_attestation = r#"{"type":"upgrade_mode","starting_time":1629720000,"attester_public_key":"3pkFcBXCEmbmXBT2G8CkFMuKisJcH54mbBGvncHaDibt","signature":"5rWUr2ypaDTtrMKegMP3tQkkZGFAuhNTnEVCVe5Azv6QqvLzoGdQiMkFmeyhDd1XSfoXpL9fFM58rsdA1kf4GYMM"}"#;
let expected_content = r#"{"type":"upgrade_mode","starting_time":1629720000,"attester_public_key":"3pkFcBXCEmbmXBT2G8CkFMuKisJcH54mbBGvncHaDibt"}"#;
assert_eq!(attestation_content_json, expected_content);
assert_eq!(attestation_json, expected_attestation);
-3
View File
@@ -12,9 +12,6 @@ pub enum UpgradeModeCheckError {
#[error("the jwt metadata didn't contain explicit public key")]
MissingTokenPublicKey,
#[error("the jwt signer does not appear in the authorised attestation set")]
UnauthorisedIssuer,
#[error("the attached public key was not valid ed25519 public key")]
MalformedEd25519PublicKey { source: Ed25519RecoveryError },
+5 -27
View File
@@ -65,12 +65,6 @@ pub fn validate_upgrade_mode_jwt(
.map_err(|source| UpgradeModeCheckError::JwtVerificationFailure { source })?
.custom;
// jwt itself is cryptographically valid,
// but let's see if this entity has been permitted to issue the token in the first place
if !attestation.authorised_to_issue_jwt(&ed25519_pub_key) {
return Err(UpgradeModeCheckError::UnauthorisedIssuer);
}
Ok(attestation)
}
@@ -79,7 +73,6 @@ mod tests {
use super::*;
use crate::generate_new_attestation;
use nym_crypto::asymmetric::ed25519;
use nym_test_utils::helpers::deterministic_rng;
#[test]
fn generate_and_validate_jwt() {
@@ -93,25 +86,15 @@ mod tests {
2, 52, 215, 241, 219, 200, 18, 159, 241, 76, 111, 42, 32,
])
.unwrap();
let jwt_keys = ed25519::KeyPair::from(jwt_key);
let keys = ed25519::KeyPair::from(jwt_key);
let mut rng = deterministic_rng();
let unauthorised_jwt_keys = ed25519::KeyPair::new(&mut rng);
let attestation = generate_new_attestation(&attestation_key, vec![*jwt_keys.public_key()]);
let attestation = generate_new_attestation(&attestation_key);
let jwt_issuer = generate_jwt_for_upgrade_mode_attestation(
attestation.clone(),
attestation,
Duration::from_secs(60 * 60),
&jwt_keys,
&keys,
Some("nym-credential-proxy"),
);
let unauthorised_jwt = generate_jwt_for_upgrade_mode_attestation(
attestation.clone(),
Duration::from_secs(60 * 60),
&unauthorised_jwt_keys,
Some("nym-credential-proxy"),
);
// we expect 'nym-credential-proxy' issuer
assert!(validate_upgrade_mode_jwt(&jwt_issuer, Some("nym-credential-proxy")).is_ok());
@@ -121,15 +104,10 @@ mod tests {
// we expect another-issuer
assert!(validate_upgrade_mode_jwt(&jwt_issuer, Some("another-issuer")).is_err());
// the key is not in the authorised set inside the attestation
assert!(
validate_upgrade_mode_jwt(&unauthorised_jwt, Some("nym-credential-proxy")).is_err()
);
let jwt_no_issuer = generate_jwt_for_upgrade_mode_attestation(
attestation,
Duration::from_secs(60 * 60),
&jwt_keys,
&keys,
None,
);
// we expect 'nym-credential-proxy' issuer
+2 -4
View File
@@ -6,10 +6,8 @@ pub(crate) mod error;
pub(crate) mod jwt;
pub use attestation::{
UpgradeModeAttestation, generate_new_attestation, generate_new_attestation_with_starting_time,
UpgradeModeAttestation, attempt_retrieve, generate_new_attestation,
generate_new_attestation_with_starting_time,
};
pub use error::UpgradeModeCheckError;
pub use jwt::{generate_jwt_for_upgrade_mode_attestation, validate_upgrade_mode_jwt};
#[cfg(not(target_arch = "wasm32"))]
pub use attestation::attempt_retrieve_attestation;
+1
View File
@@ -37,6 +37,7 @@ nym-credential-verification = { path = "../credential-verification" }
nym-crypto = { path = "../crypto", features = ["asymmetric"] }
nym-gateway-storage = { path = "../gateway-storage" }
nym-gateway-requests = { path = "../gateway-requests" }
nym-metrics = { path = "../nym-metrics" }
nym-network-defaults = { path = "../network-defaults" }
nym-task = { path = "../task" }
nym-wireguard-types = { path = "../wireguard-types" }
+2 -1
View File
@@ -11,7 +11,6 @@ use defguard_wireguard_rs::{WGApi, WireguardInterfaceApi, host::Peer, key::Key,
use nym_credential_verification::ecash::EcashManager;
use nym_crypto::asymmetric::x25519::KeyPair;
use nym_wireguard_types::Config;
use peer_controller::PeerControlRequest;
use std::sync::Arc;
use tokio::sync::mpsc::{self, Receiver, Sender};
@@ -23,6 +22,8 @@ pub mod peer_controller;
pub mod peer_handle;
pub mod peer_storage_manager;
pub use peer_controller::PeerControlRequest;
pub const CONTROL_CHANNEL_SIZE: usize = 256;
pub struct WgApiWrapper {
+16 -1
View File
@@ -138,6 +138,8 @@ impl PeerController {
// Function that should be used for peer removal, to handle both storage and kernel interaction
pub async fn remove_peer(&mut self, key: &Key) -> Result<()> {
nym_metrics::inc!("wg_peer_removal_attempts");
self.ecash_verifier
.storage()
.remove_wireguard_peer(&key.to_string())
@@ -145,9 +147,12 @@ impl PeerController {
self.bw_storage_managers.remove(key);
let ret = self.wg_api.remove_peer(key);
if ret.is_err() {
nym_metrics::inc!("wg_peer_removal_failed");
log::error!(
"Wireguard peer could not be removed from wireguard kernel module. Process should be restarted so that the interface is reset."
);
} else {
nym_metrics::inc!("wg_peer_removal_success");
}
Ok(ret?)
}
@@ -177,7 +182,15 @@ impl PeerController {
}
async fn handle_add_request(&mut self, peer: &Peer) -> Result<()> {
self.wg_api.configure_peer(peer)?;
nym_metrics::inc!("wg_peer_addition_attempts");
// Try to configure WireGuard peer
if let Err(e) = self.wg_api.configure_peer(peer) {
nym_metrics::inc!("wg_peer_addition_failed");
nym_metrics::inc!("wg_config_errors_total");
return Err(e.into());
}
let bandwidth_storage_manager = SharedBandwidthStorageManager::new(
Arc::new(RwLock::new(
Self::generate_bandwidth_manager(self.ecash_verifier.storage(), &peer.public_key)
@@ -205,6 +218,8 @@ impl PeerController {
handle.run().await;
log::debug!("Peer handle shut down for {public_key}");
});
nym_metrics::inc!("wg_peer_addition_success");
Ok(())
}
+41
View File
@@ -0,0 +1,41 @@
# Single-stage Dockerfile for Nym localnet
# Builds: nym-node, nym-network-requester, nym-socks5-client
# Target: Apple Container Runtime with host networking
FROM rust:latest
WORKDIR /usr/src/nym
COPY ./ ./
ENV CARGO_BUILD_JOBS=8
# Build all required binaries in release mode
RUN cargo build --release --locked \
-p nym-node \
-p nym-network-requester \
-p nym-socks5-client
# Install runtime dependencies
RUN apt update && apt install -y \
python3 \
python3-pip \
netcat-openbsd \
jq \
iproute2 \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies for build_topology.py
RUN pip3 install --break-system-packages base58
# Move binaries to /usr/local/bin for easy access
RUN cp target/release/nym-node /usr/local/bin/ && \
cp target/release/nym-network-requester /usr/local/bin/ && \
cp target/release/nym-socks5-client /usr/local/bin/
# Copy supporting scripts
COPY ./docker/localnet/build_topology.py /usr/local/bin/
WORKDIR /nym
# Default command
CMD ["nym-node", "--help"]
+608
View File
@@ -0,0 +1,608 @@
# Nym Localnet for Kata Container Runtimes
A complete Nym mixnet test environment running on Apple's container runtime for macOS (for now).
## Overview
This localnet setup provides a fully functional Nym mixnet for local development and testing:
- **3 mixnodes** (layer 1, 2, 3)
- **1 gateway** (entry + exit mode)
- **1 network-requester** (service provider)
- **1 SOCKS5 client**
All components run in isolated containers with proper networking and dynamic IP resolution.
## Prerequisites
### Required
- **macOS** (tested on macOS Sequoia 15.0+)
- **Apple Container Runtime** - Built into macOS
- **Docker Desktop** (for building images only)
- **Python 3** with `base58` library
### Installation
```bash
# Install Python dependencies
pip3 install --break-system-packages base58
# Verify container runtime is available
container --version
# Verify Docker is installed (for building)
docker --version
```
## Quick Start
```bash
# Navigate to the localnet directory
cd docker/localnet
# Build the container image
./localnet.sh build
# Start the localnet
./localnet.sh start
# Test the SOCKS5 proxy
curl -L --socks5 localhost:1080 https://nymtech.net
# View logs
./localnet.sh logs gateway
./localnet.sh logs socks5
# Stop the localnet
./localnet.sh stop
# Clean up everything
./localnet.sh clean
```
## Architecture
### Container Network
All containers run on a custom bridge network (`nym-localnet-network`) with dynamic IP assignment:
```
Host Machine (macOS)
├── nym-localnet-network (bridge)
│ ├── nym-mixnode1 (192.168.66.3)
│ ├── nym-mixnode2 (192.168.66.4)
│ ├── nym-mixnode3 (192.168.66.5)
│ ├── nym-gateway (192.168.66.6)
│ ├── nym-network-requester (192.168.66.7)
│ └── nym-socks5-client (192.168.66.8)
```
Ports published to host:
- 1080 → SOCKS5 proxy
- 9000 → Gateway entry
- 10001-10004 → Mixnet ports
- 20001-20004 → Verloc ports
- 30001-30004 → HTTP APIs
### Startup Flow
1. **Container Initialization** (parallel)
- Each container starts and gets a dynamic IP
- Each node runs `nym-node run --init-only` with its container IP
- Bonding JSON files are written to shared volume
2. **Topology Generation** (sequential)
- Wait for all 4 bonding JSON files
- Get container IPs dynamically
- Run `build_topology.py` with container IPs
- Generate `network.json` with correct addresses
3. **Node Startup** (parallel)
- Each container starts its node with `--local` flag
- Nodes read configuration from init phase
- Clients use custom topology file
4. **Service Providers** (sequential)
- Network requester initializes and starts
- SOCKS5 client initializes with requester address
### Network Topology
The `network.json` file contains the complete network topology:
```json
{
"metadata": {
"key_rotation_id": 0,
"absolute_epoch_id": 0,
"refreshed_at": "2025-11-03T..."
},
"rewarded_set": {
"epoch_id": 0,
"entry_gateways": [4],
"exit_gateways": [4],
"layer1": [1],
"layer2": [2],
"layer3": [3],
"standby": []
},
"node_details": {
"1": { "mix_host": "192.168.66.3:10001", ... },
"2": { "mix_host": "192.168.66.4:10002", ... },
"3": { "mix_host": "192.168.66.5:10003", ... },
"4": { "mix_host": "192.168.66.6:10004", ... }
}
}
```
## Commands
### Build
```bash
./localnet.sh build
```
Builds the Docker image and loads it into Apple container runtime.
**Note**: First build takes ~5-10 minutes to compile all components.
### Start
```bash
./localnet.sh start
```
Starts all containers, generates topology, and launches the complete network.
**Expected output**:
```
[INFO] Starting Nym Localnet...
[SUCCESS] Network created: nym-localnet-network
[INFO] Starting nym-mixnode1...
[SUCCESS] nym-mixnode1 started
...
[INFO] Building network topology with container IPs...
[SUCCESS] Network topology created successfully
[SUCCESS] Nym Localnet is running!
Test with:
curl -x socks5h://127.0.0.1:1080 https://nymtech.net
```
### Stop
```bash
./localnet.sh stop
```
Stops and removes all running containers.
### Clean
```bash
./localnet.sh clean
```
Complete cleanup: removes containers, volumes, network, and temporary files.
### Logs
```bash
# View logs for a specific container
./localnet.sh logs <container-name>
# Container names:
# - mix1, mix2, mix3
# - gateway
# - requester
# - socks5
# Examples:
./localnet.sh logs gateway
./localnet.sh logs socks5
container logs nym-gateway --follow
```
### Status
```bash
# List all containers
container list
# Check specific container
container logs nym-gateway
# Inspect network
container network inspect nym-localnet-network
```
## Testing
### Basic SOCKS5 Test
```bash
# Simple HTTP request with redirect following
curl -L --socks5 localhost:1080 http://example.com
# HTTPS request
curl -L --socks5 localhost:1080 https://nymtech.net
# Download a file
curl -L --socks5 localhost:1080 \
https://test-download-files-nym.s3.amazonaws.com/download-files/1MB.zip \
--output /tmp/test.zip
```
### Verify Network Topology
```bash
# View the generated topology
container exec nym-gateway cat /localnet/network.json | jq .
# Check container IPs
container list | grep nym-
# Verify all bonding files exist
container exec nym-gateway ls -la /localnet/
```
### Test Mixnet Routing
```bash
# All traffic flows through: client → mix1 → mix2 → mix3 → gateway → internet
# Watch logs to verify routing:
container logs nym-mixnode1 --follow &
container logs nym-mixnode2 --follow &
container logs nym-mixnode3 --follow &
container logs nym-gateway --follow &
# Make a request
curl -L --socks5 localhost:1080 https://nymtech.com
```
## File Structure
```
docker/localnet/
├── README.md # This file
├── localnet.sh # Main orchestration script
├── Dockerfile.localnet # Docker image definition
└── build_topology.py # Topology generator
```
## How It Works
### Node Initialization
Each node initializes itself at runtime inside its container:
```bash
# Get container IP
CONTAINER_IP=$(hostname -i)
# Initialize with container IP
nym-node run --id mix1-localnet --init-only \
--unsafe-disable-replay-protection \
--local \
--mixnet-bind-address=0.0.0.0:10001 \
--verloc-bind-address=0.0.0.0:20001 \
--http-bind-address=0.0.0.0:30001 \
--http-access-token=lala \
--public-ips $CONTAINER_IP \
--output=json \
--bonding-information-output="/localnet/mix1.json"
```
**Key flags**:
- `--local`: Accept private IPs for local development
- `--public-ips`: Announce the container's IP address
- `--unsafe-disable-replay-protection`: Disable bloomfilter to save memory
### Dynamic Topology
The topology is built **after** containers start:
```bash
# Get container IPs
MIX1_IP=$(container exec nym-mixnode1 hostname -i)
MIX2_IP=$(container exec nym-mixnode2 hostname -i)
MIX3_IP=$(container exec nym-mixnode3 hostname -i)
GATEWAY_IP=$(container exec nym-gateway hostname -i)
# Build topology with actual IPs
python3 build_topology.py /localnet localnet \
$MIX1_IP $MIX2_IP $MIX3_IP $GATEWAY_IP
```
This ensures the topology contains reachable container addresses.
### Client Configuration
Clients use `--custom-mixnet` to read the local topology:
```bash
# Network requester
nym-network-requester init \
--id "network-requester-$SUFFIX" \
--open-proxy=true \
--custom-mixnet /localnet/network.json
# SOCKS5 client
nym-socks5-client init \
--id "socks5-client-$SUFFIX" \
--provider "$REQUESTER_ADDRESS" \
--custom-mixnet /localnet/network.json \
--host 0.0.0.0
```
The `--custom-mixnet` flag tells clients to use our local topology instead of fetching from nym-api.
## Troubleshooting
### Container Build Issues
**Problem**: Docker build fails
```bash
# Check Docker is running
docker info
# Clean Docker cache
docker system prune -a
# Rebuild with no cache
./localnet.sh build
```
**Problem**: Container image load fails
```bash
# Verify temp file was created
ls -lh /tmp/nym-localnet-image-*
# Check container runtime
container image list
# Manually load if needed
docker save -o /tmp/nym-image.tar nym-localnet:latest
container image load --input /tmp/nym-image.tar
```
### Network Issues
**Problem**: Containers can't communicate
```bash
# Check network exists
container network list | grep nym-localnet
# Inspect network
container network inspect nym-localnet-network
# Verify containers are on the network
container list | grep nym-
```
**Problem**: SOCKS5 connection refused
```bash
# Check SOCKS5 is listening
container logs nym-socks5-client | grep "Listening on"
# Verify port mapping
container list | grep socks5
# Test from host
nc -zv localhost 1080
```
### Node Issues
**Problem**: "No valid public addresses" error
- Ensure `--local` flag is present in both init and run commands
- Check container can resolve its own IP: `container exec nym-mixnode1 hostname -i`
- Verify `--public-ips` is using `$CONTAINER_IP` variable
**Problem**: "TUN device error"
- The gateway needs TUN device support for exit functionality
- Verify `iproute2` is installed in the image (adds `ip` command)
- Check gateway logs: `container logs nym-gateway`
- The gateway should show: "Created TUN device: nymtun0"
**Problem**: "Noise handshake" warnings
- These are warnings, not errors - nodes fall back to TCP
- Does not affect functionality in local development
- Safe to ignore for testing purposes
### Topology Issues
**Problem**: Network.json not created
```bash
# Check all bonding files exist
container exec nym-gateway ls -la /localnet/
# Verify build_topology.py ran
container logs nym-gateway | grep "Building network topology"
# Check Python dependencies
container exec nym-gateway python3 -c "import base58"
```
**Problem**: Clients can't connect to nodes
```bash
# Verify IPs in topology match container IPs
container exec nym-gateway cat /localnet/network.json | jq '.node_details'
container list | grep nym-
# Check containers can reach each other
container exec nym-socks5-client ping -c 1 192.168.66.6
```
### Startup Issues
**Problem**: Containers exit immediately
```bash
# Check logs for errors
container logs nym-mixnode1
# Common issues:
# - Missing network.json: Wait for topology to be built
# - Port already in use: Check for conflicting services
# - Init failed: Check for correct container IP
```
**Problem**: Topology build times out
```bash
# Verify all containers initialized
container exec nym-gateway ls -la /localnet/*.json
# Check for init errors
container logs nym-mixnode1 | grep -i error
# Manual cleanup and restart
./localnet.sh clean
./localnet.sh start
```
## Performance Notes
### Memory Usage
- Each mixnode: ~200MB
- Gateway: ~300MB (includes TUN device)
- Network requester: ~150MB
- SOCKS5 client: ~150MB
- **Total**: ~1.2GB + overhead
**Recommended**: 4GB+ system memory
### Startup Time
- Image build: ~5-10 minutes (first time)
- Network start: ~20-30 seconds
- Node initialization: ~5-10 seconds per node (parallel)
### Latency
Mixnet adds latency by design for privacy:
- ~1-3 seconds for SOCKS5 requests
- Cover traffic adds random delays
- Local testing may show variable timing
This is **expected behavior** - the mixnet provides privacy through traffic mixing.
## Advanced Configuration
### Custom Node Configuration
Edit node init commands in `localnet.sh` (search for `nym-node run --init-only`):
```bash
# Example: Change mixnode ports
--mixnet-bind-address=0.0.0.0:11001 \
--verloc-bind-address=0.0.0.0:21001 \
--http-bind-address=0.0.0.0:31001 \
```
Remember to update port mappings in the `container run` command as well.
### Enable Replay Protection
Remove `--unsafe-disable-replay-protection` flags (requires more memory):
```bash
# In start_mixnode() and start_gateway() functions
nym-node run --id mix1-localnet --init-only \
--local \
--mixnet-bind-address=0.0.0.0:10001 \
# ... other flags (without --unsafe-disable-replay-protection)
```
**Note**: Each node will require an additional ~1.5GB memory for bloomfilter.
### API Access
Each node exposes an HTTP API:
```bash
# Get gateway info
curl -H "Authorization: Bearer lala" http://localhost:30004/api/v1/gateway
# Get mixnode stats
curl -H "Authorization: Bearer lala" http://localhost:30001/api/v1/stats
# Get node description
curl -H "Authorization: Bearer lala" http://localhost:30001/api/v1/description
```
Access token is `lala` (configured with `--http-access-token=lala`).
### Add More Mixnodes
To add a 4th mixnode:
1. **Update constants** in `localnet.sh`:
```bash
MIXNODE4_CONTAINER="nym-mixnode4"
```
2. **Add start call** in `start_all()`:
```bash
start_mixnode 4 "$MIXNODE4_CONTAINER"
```
3. **Update topology builder** to include the new node
4. **Rebuild and restart**:
```bash
./localnet.sh clean
./localnet.sh build
./localnet.sh start
```
## Technical Details
### Container Runtime
Apple's container runtime is a native macOS container system:
- Uses Virtualization.framework for isolation
- Lightweight VMs for each container
- Native macOS integration
- Separate image store from Docker
- Natively uses [Kata Containers](https://github.com/kata-containers/kata-containers) images
### Initial setup for [Container Runtime](https://github.com/apple/container)
- **MUST** have MacOS Tahoe for inter-container networking
- `brew install --cask container`
- Download Kata Containers 3.20, this one can be loaded by `container` and has `CONFIG_TUN=y` kernel flag
- `https://github.com/kata-containers/kata-containers/releases/download/3.20.0/kata-static-3.20.0-arm64.tar.xz`
- Load new kernel
- `container system kernel set --tar kata-static-3.20.0-arm64.tar.xz --binary opt/kata/share/kata-containers/vmlinux-6.12.42-162`
- Validate kernel version once you have container running
- `uname -r` should return `6.12.42`
- `cat /proc/config.gz | grep CONFIG_TUN` should return `CONFIG_TUN=y`
### Image Building
Images are built with Docker then transferred:
1. `docker build` creates the image
2. `docker save` exports to tar file
3. `container image load` imports into container runtime
4. Temporary file is cleaned up
This approach allows using Docker's build cache while running on Apple's runtime.
### Network Architecture
The custom bridge network (`nym-localnet-network`):
- Provides container-to-container communication
- Assigns dynamic IPs from 192.168.66.0/24
- NAT for outbound internet access
- Port publishing for host access
### Volumes
Two types of volumes:
1. **Shared data** (`/tmp/nym-localnet-*`): Bonding files and topology
2. **Node configs** (`/tmp/nym-localnet-home-*`): Node configurations
Both are ephemeral by default (cleaned up on stop).
## Known Limitations
- **macOS only**: Apple container runtime requires macOS
- **No Docker Compose**: Uses custom orchestration script
- **Dynamic IPs**: Container IPs may change between restarts
- **Port conflicts**: Cannot run alongside services using same ports
- **TUN device**: Gateway requires `ip` command for network interfaces
## Support
For issues and questions:
- **GitHub Issues**: https://github.com/nymtech/nym/issues
- **Documentation**: https://nymtech.net/docs
- **Discord**: https://discord.gg/nym
## License
This localnet setup is part of the Nym project and follows the same license.
+287
View File
@@ -0,0 +1,287 @@
import json
import os
import subprocess
import sys
from datetime import datetime
from functools import lru_cache
from pathlib import Path
import base58
DEFAULT_OWNER = "n1jw6mp7d5xqc7w6xm79lha27glmd0vdt3l9artf"
DEFAULT_SUFFIX = os.environ.get("NYM_NODE_SUFFIX", "localnet")
NYM_NODES_ROOT = Path.home() / ".nym" / "nym-nodes"
def debug(msg):
"""Print debug message to stderr"""
print(f"[DEBUG] {msg}", file=sys.stderr, flush=True)
def error(msg):
"""Print error message to stderr"""
print(f"[ERROR] {msg}", file=sys.stderr, flush=True)
def maybe_assign(target, key, value):
if value is not None:
target[key] = value
@lru_cache(maxsize=None)
def get_nym_node_version():
try:
result = subprocess.run(
["nym-node", "--version"],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
except (subprocess.CalledProcessError, FileNotFoundError):
return None
version_line = result.stdout.strip()
if not version_line:
return None
parts = version_line.split()
for token in reversed(parts):
if token and token[0].isdigit():
return token
return version_line
def node_config_path(prefix, suffix):
path = NYM_NODES_ROOT / f"{prefix}-{suffix}" / "config" / "config.toml"
debug(f"Looking for config at: {path}")
if path.exists():
debug(f" ✓ Config found")
return path
else:
error(f" ✗ Config NOT found at {path}")
return None
def read_node_details(prefix, suffix):
config_path = node_config_path(prefix, suffix)
if config_path is None:
error(f"Cannot read node details for {prefix}-{suffix}: config not found")
return {}
debug(f"Running: nym-node node-details --config-file {config_path}")
try:
result = subprocess.run(
[
"nym-node",
"node-details",
"--config-file",
str(config_path),
"--output=json",
],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
debug(f" ✓ node-details command succeeded")
except subprocess.CalledProcessError as e:
error(f"node-details command failed for {prefix}-{suffix}: {e}")
error(f" stdout: {e.stdout}")
error(f" stderr: {e.stderr}")
return {}
except FileNotFoundError:
error("nym-node command not found in PATH")
return {}
try:
details = json.loads(result.stdout)
debug(f" ✓ Parsed node-details JSON")
except json.JSONDecodeError as e:
error(f"Failed to parse node-details JSON: {e}")
error(f" Output was: {result.stdout[:200]}")
return {}
info = {}
# Get sphinx key and decode from Base58 to byte array
sphinx_data = details.get("x25519_primary_sphinx_key")
if isinstance(sphinx_data, dict):
sphinx_key_b58 = sphinx_data.get("public_key")
if sphinx_key_b58:
debug(f" Got sphinx_key (Base58): {sphinx_key_b58[:20]}...")
try:
# Decode Base58 to byte array
sphinx_bytes = base58.b58decode(sphinx_key_b58)
info["sphinx_key"] = list(sphinx_bytes)
debug(f" ✓ Decoded to {len(sphinx_bytes)} bytes")
except Exception as e:
error(f" Failed to decode sphinx_key: {e}")
version = get_nym_node_version()
if version:
info["version"] = version
return info
def resolve_host(data):
# For localnet, always use 127.0.0.1 unless explicitly overridden
env_host = os.environ.get("LOCALNET_PUBLIC_IP") or os.environ.get("NYMNODE_PUBLIC_IP")
if env_host:
return env_host.split(",")[0].strip()
# Default to localhost for localnet (containers can reach each other via published ports)
return "127.0.0.1"
def create_mixnode_entry(base_dir, mix_id, port_delta, suffix, host_ip):
"""Create a node_details entry for a mixnode"""
debug(f"\n=== Creating mixnode{mix_id} entry ===")
mix_file = Path(base_dir) / f"mix{mix_id}.json"
debug(f"Reading bonding JSON from: {mix_file}")
with mix_file.open("r") as json_blob:
mix_data = json.load(json_blob)
node_details = read_node_details(f"mix{mix_id}", suffix)
# Get identity key from bonding JSON (already byte array)
identity = mix_data.get("identity_key")
if not identity:
raise RuntimeError(f"Missing identity_key in {mix_file}")
debug(f" ✓ Got identity_key from bonding JSON: {len(identity)} bytes")
# Get sphinx key from node-details (decoded from Base58)
sphinx_key = node_details.get("sphinx_key")
if not sphinx_key:
raise RuntimeError(f"Missing sphinx_key from node-details for mix{mix_id}")
host = host_ip
port = 10000 + port_delta
debug(f" Using host: {host}:{port}")
entry = {
"node_id": mix_id,
"mix_host": f"{host}:{port}",
"entry": None,
"identity_key": identity,
"sphinx_key": sphinx_key,
"supported_roles": {
"mixnode": True,
"mixnet_entry": False,
"mixnet_exit": False
}
}
maybe_assign(entry, "version", node_details.get("version") or mix_data.get("version"))
return entry
def create_gateway_entry(base_dir, node_id, port_delta, suffix, host_ip):
"""Create a node_details entry for a gateway"""
debug(f"\n=== Creating gateway entry ===")
gateway_file = Path(base_dir) / "gateway.json"
debug(f"Reading bonding JSON from: {gateway_file}")
with gateway_file.open("r") as json_blob:
gateway_data = json.load(json_blob)
node_details = read_node_details("gateway", suffix)
# Get identity key from bonding JSON (already byte array)
identity = gateway_data.get("identity_key")
if not identity:
raise RuntimeError("Missing identity_key in gateway.json")
debug(f" ✓ Got identity_key from bonding JSON: {len(identity)} bytes")
# Get sphinx key from node-details (decoded from Base58)
sphinx_key = node_details.get("sphinx_key")
if not sphinx_key:
raise RuntimeError("Missing sphinx_key from node-details for gateway")
host = host_ip
mix_port = 10000 + port_delta
clients_port = 9000
debug(f" Using host: {host} (mix:{mix_port}, clients:{clients_port})")
entry = {
"node_id": node_id,
"mix_host": f"{host}:{mix_port}",
"entry": {
"ip_addresses": [host],
"clients_ws_port": clients_port,
"hostname": None,
"clients_wss_port": None
},
"identity_key": identity,
"sphinx_key": sphinx_key,
"supported_roles": {
"mixnode": False,
"mixnet_entry": True,
"mixnet_exit": True
}
}
maybe_assign(entry, "version", node_details.get("version") or gateway_data.get("version"))
return entry
def main(args):
if not args:
raise SystemExit("Usage: build_topology.py <output_dir> [node_suffix] [mix1_ip] [mix2_ip] [mix3_ip] [gateway_ip]")
base_dir = args[0]
suffix = args[1] if len(args) > 1 and args[1] else DEFAULT_SUFFIX
# Get container IPs from arguments (or use 127.0.0.1 as fallback)
mix1_ip = args[2] if len(args) > 2 else "127.0.0.1"
mix2_ip = args[3] if len(args) > 3 else "127.0.0.1"
mix3_ip = args[4] if len(args) > 4 else "127.0.0.1"
gateway_ip = args[5] if len(args) > 5 else "127.0.0.1"
debug(f"\n=== Starting topology generation ===")
debug(f"Output directory: {base_dir}")
debug(f"Node suffix: {suffix}")
debug(f"Container IPs: mix1={mix1_ip}, mix2={mix2_ip}, mix3={mix3_ip}, gateway={gateway_ip}")
# Create node_details entries with integer keys
node_details = {
1: create_mixnode_entry(base_dir, 1, 1, suffix, mix1_ip),
2: create_mixnode_entry(base_dir, 2, 2, suffix, mix2_ip),
3: create_mixnode_entry(base_dir, 3, 3, suffix, mix3_ip),
4: create_gateway_entry(base_dir, 4, 4, suffix, gateway_ip)
}
# Create the NymTopology structure
topology = {
"metadata": {
"key_rotation_id": 0,
"absolute_epoch_id": 0,
"refreshed_at": datetime.utcnow().isoformat() + "Z"
},
"rewarded_set": {
"epoch_id": 0,
"entry_gateways": [4],
"exit_gateways": [4],
"layer1": [1],
"layer2": [2],
"layer3": [3],
"standby": []
},
"node_details": node_details
}
output_path = Path(base_dir) / "network.json"
debug(f"\nWriting topology to: {output_path}")
with output_path.open("w") as out:
json.dump(topology, out, indent=2)
print(f"✓ Generated topology with {len(node_details)} nodes")
print(f" - 3 mixnodes (layers 1, 2, 3)")
print(f" - 1 gateway (entry + exit)")
debug(f"\n=== Topology generation complete ===\n")
if __name__ == "__main__":
main(sys.argv[1:])

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