Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f77858f260 | |||
| 41a3f245be | |||
| d7476d1009 | |||
| b3d5861244 | |||
| 1a97a53bdb | |||
| 25f1442030 | |||
| 19a93c1926 | |||
| 1db3a15c97 | |||
| ec568ce8b6 | |||
| 86d8ed0f5a | |||
| f8906c4514 | |||
| 057e07948f |
@@ -63,3 +63,4 @@ nym-api/redocly/formatted-openapi.json
|
||||
|
||||
**/settings.sql
|
||||
**/enter_db.sh
|
||||
.beads
|
||||
@@ -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
@@ -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
+75
-1
@@ -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"
|
||||
@@ -2262,7 +2277,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5603,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,6 +5682,7 @@ dependencies = [
|
||||
"bs58",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"curve25519-dalek",
|
||||
"digest 0.10.7",
|
||||
"ed25519-dalek",
|
||||
"generic-array 0.14.7",
|
||||
@@ -5797,6 +5814,7 @@ dependencies = [
|
||||
"bincode",
|
||||
"bip39",
|
||||
"bs58",
|
||||
"bytes",
|
||||
"dashmap",
|
||||
"defguard_wireguard_rs",
|
||||
"fastrand 2.3.0",
|
||||
@@ -5815,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",
|
||||
@@ -6200,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"
|
||||
@@ -6211,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"
|
||||
@@ -6785,15 +6847,21 @@ 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",
|
||||
@@ -6805,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",
|
||||
]
|
||||
|
||||
@@ -7578,6 +7651,7 @@ dependencies = [
|
||||
"nym-crypto",
|
||||
"nym-gateway-requests",
|
||||
"nym-gateway-storage",
|
||||
"nym-metrics",
|
||||
"nym-network-defaults",
|
||||
"nym-node-metrics",
|
||||
"nym-task",
|
||||
|
||||
@@ -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",
|
||||
@@ -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"
|
||||
|
||||
@@ -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 -->
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
@@ -43,7 +44,7 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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. We’ll 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(())
|
||||
}
|
||||
@@ -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
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod codec;
|
||||
pub mod driver;
|
||||
pub mod error;
|
||||
pub mod packet;
|
||||
pub mod session;
|
||||
@@ -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
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "nym-lp-common"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(×tamp.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);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 { .. }));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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:])
|
||||
Executable
+568
@@ -0,0 +1,568 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -ex
|
||||
|
||||
# Nym Localnet Orchestration Script for Linux with Kata Containers
|
||||
# Adapted from macOS version to use nerdctl with Kata runtime
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
IMAGE_NAME="nym-localnet:latest"
|
||||
VOLUME_NAME="nym-localnet-data"
|
||||
VOLUME_PATH="/tmp/nym-localnet-$$"
|
||||
NYM_VOLUME_PATH="/tmp/nym-localnet-home-$$"
|
||||
|
||||
SUFFIX=${NYM_NODE_SUFFIX:-localnet}
|
||||
RUNTIME="io.containerd.kata.v2" # Use Kata runtime
|
||||
|
||||
# Container names
|
||||
MIXNODE1_CONTAINER="nym-mixnode1"
|
||||
MIXNODE2_CONTAINER="nym-mixnode2"
|
||||
MIXNODE3_CONTAINER="nym-mixnode3"
|
||||
GATEWAY_CONTAINER="nym-gateway"
|
||||
REQUESTER_CONTAINER="nym-network-requester"
|
||||
SOCKS5_CONTAINER="nym-socks5-client"
|
||||
|
||||
ALL_CONTAINERS=(
|
||||
"$MIXNODE1_CONTAINER"
|
||||
"$MIXNODE2_CONTAINER"
|
||||
"$MIXNODE3_CONTAINER"
|
||||
"$GATEWAY_CONTAINER"
|
||||
"$REQUESTER_CONTAINER"
|
||||
"$SOCKS5_CONTAINER"
|
||||
)
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
log_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $*"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $*"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $*"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $*"
|
||||
}
|
||||
|
||||
cleanup_host_state() {
|
||||
log_info "Cleaning local nym-node state for suffix ${SUFFIX}"
|
||||
for node in mix1 mix2 mix3 gateway; do
|
||||
rm -rf "$HOME/.nym/nym-nodes/${node}-${SUFFIX}"
|
||||
done
|
||||
}
|
||||
|
||||
# Check if prerequisites are met
|
||||
check_prerequisites() {
|
||||
if ! command -v nerdctl &> /dev/null; then
|
||||
log_error "nerdctl not found"
|
||||
log_error "Please install nerdctl first"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
log_error "Python 3 not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! python3 -c "import base58" 2>/dev/null; then
|
||||
log_error "Python base58 module not found"
|
||||
log_error "Install with: pip3 install --break-system-packages base58"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "All prerequisites satisfied"
|
||||
}
|
||||
|
||||
# Build the image
|
||||
build_image() {
|
||||
log_info "Building image: $IMAGE_NAME"
|
||||
log_warn "This will take 15-30 minutes on first build..."
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Build with nerdctl
|
||||
if ! sudo nerdctl build \
|
||||
-f "$SCRIPT_DIR/Dockerfile.localnet" \
|
||||
-t "$IMAGE_NAME" \
|
||||
"$PROJECT_ROOT"; then
|
||||
log_error "Build failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "Image built: $IMAGE_NAME"
|
||||
}
|
||||
|
||||
# Create shared volume directory
|
||||
create_volume() {
|
||||
log_info "Creating shared volume at: $VOLUME_PATH"
|
||||
mkdir -p "$VOLUME_PATH"
|
||||
chmod 777 "$VOLUME_PATH"
|
||||
log_success "Volume created"
|
||||
}
|
||||
|
||||
# Create shared nym home directory
|
||||
create_nym_volume() {
|
||||
log_info "Creating shared nym home volume at: $NYM_VOLUME_PATH"
|
||||
mkdir -p "$NYM_VOLUME_PATH"
|
||||
chmod 777 "$NYM_VOLUME_PATH"
|
||||
log_success "Nym home volume created"
|
||||
}
|
||||
|
||||
# Remove shared volume directory
|
||||
remove_volume() {
|
||||
if [ -d "$VOLUME_PATH" ]; then
|
||||
log_info "Removing volume: $VOLUME_PATH"
|
||||
rm -rf "$VOLUME_PATH"
|
||||
log_success "Volume removed"
|
||||
fi
|
||||
if [ -d "$NYM_VOLUME_PATH" ]; then
|
||||
log_info "Removing nym home volume: $NYM_VOLUME_PATH"
|
||||
rm -rf "$NYM_VOLUME_PATH"
|
||||
log_success "Nym home volume removed"
|
||||
fi
|
||||
}
|
||||
|
||||
NETWORK_NAME="nym-localnet-network"
|
||||
|
||||
# Create container network
|
||||
create_network() {
|
||||
log_info "Creating container network: $NETWORK_NAME"
|
||||
if sudo nerdctl network create "$NETWORK_NAME" 2>/dev/null; then
|
||||
log_success "Network created: $NETWORK_NAME"
|
||||
else
|
||||
log_info "Network $NETWORK_NAME already exists"
|
||||
fi
|
||||
}
|
||||
|
||||
# Remove container network
|
||||
remove_network() {
|
||||
if sudo nerdctl network ls | grep -q "$NETWORK_NAME"; then
|
||||
log_info "Removing network: $NETWORK_NAME"
|
||||
sudo nerdctl network rm "$NETWORK_NAME" 2>/dev/null || true
|
||||
log_success "Network removed"
|
||||
fi
|
||||
}
|
||||
|
||||
# Start a mixnode
|
||||
start_mixnode() {
|
||||
local node_id=$1
|
||||
local container_name=$2
|
||||
|
||||
log_info "Starting $container_name..."
|
||||
|
||||
local mixnet_port="1000${node_id}"
|
||||
local verloc_port="2000${node_id}"
|
||||
local http_port="3000${node_id}"
|
||||
|
||||
sudo nerdctl run \
|
||||
--runtime="$RUNTIME" \
|
||||
--name "$container_name" \
|
||||
-m 2G \
|
||||
--network "$NETWORK_NAME" \
|
||||
-p "${mixnet_port}:${mixnet_port}" \
|
||||
-p "${verloc_port}:${verloc_port}" \
|
||||
-p "${http_port}:${http_port}" \
|
||||
-v "$VOLUME_PATH:/localnet" \
|
||||
-v "$NYM_VOLUME_PATH:/root/.nym" \
|
||||
-d \
|
||||
-e "NYM_NODE_SUFFIX=$SUFFIX" \
|
||||
"$IMAGE_NAME" \
|
||||
sh -c '
|
||||
CONTAINER_IP=$(hostname -i);
|
||||
echo "Container IP: $CONTAINER_IP";
|
||||
echo "Initializing mix'"${node_id}"'...";
|
||||
nym-node run --id mix'"${node_id}"'-localnet --init-only \
|
||||
--unsafe-disable-replay-protection \
|
||||
--local \
|
||||
--mixnet-bind-address=0.0.0.0:'"${mixnet_port}"' \
|
||||
--verloc-bind-address=0.0.0.0:'"${verloc_port}"' \
|
||||
--http-bind-address=0.0.0.0:'"${http_port}"' \
|
||||
--http-access-token=lala \
|
||||
--public-ips $CONTAINER_IP \
|
||||
--output=json \
|
||||
--bonding-information-output="/localnet/mix'"${node_id}"'.json";
|
||||
|
||||
echo "Waiting for network.json...";
|
||||
while [ ! -f /localnet/network.json ]; do
|
||||
sleep 2;
|
||||
done;
|
||||
echo "Starting mix'"${node_id}"'...";
|
||||
exec nym-node run --id mix'"${node_id}"'-localnet --unsafe-disable-replay-protection --local
|
||||
'
|
||||
|
||||
log_success "$container_name started"
|
||||
}
|
||||
|
||||
# Start gateway
|
||||
start_gateway() {
|
||||
log_info "Starting $GATEWAY_CONTAINER..."
|
||||
|
||||
sudo nerdctl run \
|
||||
--runtime="$RUNTIME" \
|
||||
--name "$GATEWAY_CONTAINER" \
|
||||
-m 2G \
|
||||
--network "$NETWORK_NAME" \
|
||||
-p 9000:9000 \
|
||||
-p 10004:10004 \
|
||||
-p 20004:20004 \
|
||||
-p 30004:30004 \
|
||||
-v "$VOLUME_PATH:/localnet" \
|
||||
-v "$NYM_VOLUME_PATH:/root/.nym" \
|
||||
-d \
|
||||
-e "NYM_NODE_SUFFIX=$SUFFIX" \
|
||||
"$IMAGE_NAME" \
|
||||
sh -c '
|
||||
CONTAINER_IP=$(hostname -i);
|
||||
echo "Container IP: $CONTAINER_IP";
|
||||
echo "Initializing gateway...";
|
||||
nym-node run --id gateway-localnet --init-only \
|
||||
--unsafe-disable-replay-protection \
|
||||
--local \
|
||||
--mode entry-gateway \
|
||||
--mode exit-gateway \
|
||||
--mixnet-bind-address=0.0.0.0:10004 \
|
||||
--entry-bind-address=0.0.0.0:9000 \
|
||||
--verloc-bind-address=0.0.0.0:20004 \
|
||||
--http-bind-address=0.0.0.0:30004 \
|
||||
--http-access-token=lala \
|
||||
--public-ips $CONTAINER_IP \
|
||||
--output=json \
|
||||
--bonding-information-output="/localnet/gateway.json";
|
||||
|
||||
echo "Waiting for network.json...";
|
||||
while [ ! -f /localnet/network.json ]; do
|
||||
sleep 2;
|
||||
done;
|
||||
echo "Starting gateway...";
|
||||
exec nym-node run --id gateway-localnet --unsafe-disable-replay-protection --local
|
||||
'
|
||||
|
||||
log_success "$GATEWAY_CONTAINER started"
|
||||
|
||||
log_info "Waiting for gateway to listen on port 9000..."
|
||||
local retries=0
|
||||
local max_retries=30
|
||||
while ! nc -z 127.0.0.1 9000 2>/dev/null; do
|
||||
sleep 2
|
||||
retries=$((retries + 1))
|
||||
if [ $retries -ge $max_retries ]; then
|
||||
log_error "Gateway failed to start on port 9000"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
log_success "Gateway is ready on port 9000"
|
||||
}
|
||||
|
||||
# Start network requester
|
||||
start_network_requester() {
|
||||
log_info "Starting $REQUESTER_CONTAINER..."
|
||||
|
||||
log_info "Getting gateway IP address..."
|
||||
GATEWAY_IP=$(sudo nerdctl exec "$GATEWAY_CONTAINER" hostname -i)
|
||||
log_info "Gateway IP: $GATEWAY_IP"
|
||||
|
||||
sudo nerdctl run \
|
||||
--runtime="$RUNTIME" \
|
||||
--name "$REQUESTER_CONTAINER" \
|
||||
--network "$NETWORK_NAME" \
|
||||
-v "$VOLUME_PATH:/localnet" \
|
||||
-v "$NYM_VOLUME_PATH:/root/.nym" \
|
||||
-e "GATEWAY_IP=$GATEWAY_IP" \
|
||||
-d \
|
||||
"$IMAGE_NAME" \
|
||||
sh -c '
|
||||
while [ ! -f /localnet/network.json ]; do
|
||||
echo "Waiting for network.json...";
|
||||
sleep 2;
|
||||
done;
|
||||
while ! nc -z $GATEWAY_IP 9000 2>/dev/null; do
|
||||
echo "Waiting for gateway on port 9000 ($GATEWAY_IP)...";
|
||||
sleep 2;
|
||||
done;
|
||||
SUFFIX=$(date +%s);
|
||||
nym-network-requester init \
|
||||
--id "network-requester-$SUFFIX" \
|
||||
--open-proxy=true \
|
||||
--custom-mixnet /localnet/network.json \
|
||||
--output=json > /localnet/network_requester.json;
|
||||
exec nym-network-requester run \
|
||||
--id "network-requester-$SUFFIX" \
|
||||
--custom-mixnet /localnet/network.json
|
||||
'
|
||||
|
||||
log_success "$REQUESTER_CONTAINER started"
|
||||
}
|
||||
|
||||
# Start SOCKS5 client
|
||||
start_socks5_client() {
|
||||
log_info "Starting $SOCKS5_CONTAINER..."
|
||||
|
||||
sudo nerdctl run \
|
||||
--runtime="$RUNTIME" \
|
||||
--name "$SOCKS5_CONTAINER" \
|
||||
--network "$NETWORK_NAME" \
|
||||
-p 1080:1080 \
|
||||
-v "$VOLUME_PATH:/localnet:ro" \
|
||||
-v "$NYM_VOLUME_PATH:/root/.nym" \
|
||||
-d \
|
||||
"$IMAGE_NAME" \
|
||||
sh -c '
|
||||
while [ ! -f /localnet/network_requester.json ]; do
|
||||
echo "Waiting for network requester...";
|
||||
sleep 2;
|
||||
done;
|
||||
SUFFIX=$(date +%s);
|
||||
PROVIDER=$(cat /localnet/network_requester.json | grep -o "\"client_address\":\"[^\"]*\"" | cut -d\" -f4);
|
||||
if [ -z "$PROVIDER" ]; then
|
||||
echo "Error: Could not extract provider address";
|
||||
exit 1;
|
||||
fi;
|
||||
nym-socks5-client init \
|
||||
--id "socks5-client-$SUFFIX" \
|
||||
--provider "$PROVIDER" \
|
||||
--custom-mixnet /localnet/network.json \
|
||||
--no-cover;
|
||||
exec nym-socks5-client run \
|
||||
--id "socks5-client-$SUFFIX" \
|
||||
--custom-mixnet /localnet/network.json \
|
||||
--host 0.0.0.0
|
||||
'
|
||||
|
||||
log_success "$SOCKS5_CONTAINER started"
|
||||
|
||||
log_info "Waiting for SOCKS5 proxy on port 1080..."
|
||||
sleep 5
|
||||
local retries=0
|
||||
local max_retries=15
|
||||
while ! nc -z 127.0.0.1 1080 2>/dev/null; do
|
||||
sleep 2
|
||||
retries=$((retries + 1))
|
||||
if [ $retries -ge $max_retries ]; then
|
||||
log_warn "SOCKS5 proxy not responding on port 1080 yet"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
log_success "SOCKS5 proxy is ready on port 1080"
|
||||
}
|
||||
|
||||
# Stop all containers
|
||||
stop_containers() {
|
||||
log_info "Stopping all containers..."
|
||||
|
||||
for container_name in "${ALL_CONTAINERS[@]}"; do
|
||||
if sudo nerdctl inspect "$container_name" &>/dev/null; then
|
||||
log_info "Stopping $container_name"
|
||||
sudo nerdctl stop "$container_name" 2>/dev/null || true
|
||||
sudo nerdctl rm "$container_name" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
log_success "All containers stopped"
|
||||
|
||||
cleanup_host_state
|
||||
remove_network
|
||||
}
|
||||
|
||||
# Show container logs
|
||||
show_logs() {
|
||||
local container_name=${1:-}
|
||||
|
||||
if [ -z "$container_name" ]; then
|
||||
log_error "Please specify a container name"
|
||||
log_info "Available containers:"
|
||||
for name in "${ALL_CONTAINERS[@]}"; do
|
||||
echo " - $name"
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if sudo nerdctl inspect "$container_name" &>/dev/null; then
|
||||
sudo nerdctl logs -f "$container_name"
|
||||
else
|
||||
log_error "Container not found: $container_name"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Show container status
|
||||
show_status() {
|
||||
log_info "Container status:"
|
||||
echo ""
|
||||
|
||||
for container_name in "${ALL_CONTAINERS[@]}"; do
|
||||
if sudo nerdctl inspect "$container_name" &>/dev/null; then
|
||||
local status=$(sudo nerdctl inspect --format='{{.State.Status}}' "$container_name" 2>/dev/null || echo "unknown")
|
||||
echo -e " ${GREEN}●${NC} $container_name - $status"
|
||||
else
|
||||
echo -e " ${RED}○${NC} $container_name - not running"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
log_info "Port status:"
|
||||
for port in 9000 1080 10001 10002 10003 10004; do
|
||||
if nc -z 127.0.0.1 $port 2>/dev/null; then
|
||||
echo -e " ${GREEN}●${NC} Port $port - listening"
|
||||
else
|
||||
echo -e " ${RED}○${NC} Port $port - not listening"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Build network topology
|
||||
build_topology() {
|
||||
log_info "Building network topology with container IPs..."
|
||||
|
||||
log_info "Waiting for all nodes to complete initialization..."
|
||||
for file in mix1.json mix2.json mix3.json gateway.json; do
|
||||
while [ ! -f "$VOLUME_PATH/$file" ]; do
|
||||
echo " Waiting for $file..."
|
||||
sleep 1
|
||||
done
|
||||
log_success " $file created"
|
||||
done
|
||||
|
||||
log_info "Getting container IP addresses..."
|
||||
MIX1_IP=$(sudo nerdctl exec "$MIXNODE1_CONTAINER" hostname -i)
|
||||
MIX2_IP=$(sudo nerdctl exec "$MIXNODE2_CONTAINER" hostname -i)
|
||||
MIX3_IP=$(sudo nerdctl exec "$MIXNODE3_CONTAINER" hostname -i)
|
||||
GATEWAY_IP=$(sudo nerdctl exec "$GATEWAY_CONTAINER" hostname -i)
|
||||
|
||||
log_info "Container IPs:"
|
||||
echo " mix1: $MIX1_IP"
|
||||
echo " mix2: $MIX2_IP"
|
||||
echo " mix3: $MIX3_IP"
|
||||
echo " gateway: $GATEWAY_IP"
|
||||
|
||||
sudo nerdctl run \
|
||||
--runtime="$RUNTIME" \
|
||||
--name "nym-localnet-topology-builder" \
|
||||
--network "$NETWORK_NAME" \
|
||||
-v "$VOLUME_PATH:/localnet" \
|
||||
-v "$NYM_VOLUME_PATH:/root/.nym" \
|
||||
--rm \
|
||||
"$IMAGE_NAME" \
|
||||
python3 /usr/local/bin/build_topology.py \
|
||||
/localnet \
|
||||
"$SUFFIX" \
|
||||
"$MIX1_IP" \
|
||||
"$MIX2_IP" \
|
||||
"$MIX3_IP" \
|
||||
"$GATEWAY_IP"
|
||||
|
||||
if [ -f "$VOLUME_PATH/network.json" ]; then
|
||||
log_success "Network topology created successfully"
|
||||
else
|
||||
log_error "Failed to create network topology"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Start all services
|
||||
start_all() {
|
||||
log_info "Starting Nym Localnet..."
|
||||
|
||||
cleanup_host_state
|
||||
create_network
|
||||
create_volume
|
||||
create_nym_volume
|
||||
|
||||
start_mixnode 1 "$MIXNODE1_CONTAINER"
|
||||
start_mixnode 2 "$MIXNODE2_CONTAINER"
|
||||
start_mixnode 3 "$MIXNODE3_CONTAINER"
|
||||
start_gateway
|
||||
build_topology
|
||||
start_network_requester
|
||||
start_socks5_client
|
||||
|
||||
echo ""
|
||||
log_success "Nym Localnet is running!"
|
||||
echo ""
|
||||
echo "Test with:"
|
||||
echo " curl -x socks5h://127.0.0.1:1080 https://nymtech.net"
|
||||
echo ""
|
||||
echo "View logs:"
|
||||
echo " $0 logs gateway"
|
||||
echo " $0 logs socks5"
|
||||
echo ""
|
||||
echo "Stop:"
|
||||
echo " $0 down"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Main command handler
|
||||
main() {
|
||||
check_prerequisites
|
||||
|
||||
local command=${1:-help}
|
||||
shift || true
|
||||
|
||||
case "$command" in
|
||||
build)
|
||||
build_image
|
||||
;;
|
||||
up)
|
||||
build_image
|
||||
start_all
|
||||
;;
|
||||
start)
|
||||
start_all
|
||||
;;
|
||||
down|stop)
|
||||
stop_containers
|
||||
remove_volume
|
||||
;;
|
||||
restart)
|
||||
stop_containers
|
||||
start_all
|
||||
;;
|
||||
logs)
|
||||
show_logs "$@"
|
||||
;;
|
||||
status|ps)
|
||||
show_status
|
||||
;;
|
||||
help|--help|-h)
|
||||
cat <<EOF
|
||||
Nym Localnet Orchestration Script for Linux with Kata Containers
|
||||
|
||||
Usage: $0 <command> [options]
|
||||
|
||||
Commands:
|
||||
build Build the localnet image
|
||||
up Build image and start all services
|
||||
start Start all services (requires built image)
|
||||
down, stop Stop all services and clean up
|
||||
restart Restart all services
|
||||
logs <name> Show logs for specific container
|
||||
status, ps Show status of all containers and ports
|
||||
help Show this help message
|
||||
|
||||
Examples:
|
||||
$0 up # Build and start everything
|
||||
$0 logs gateway # View gateway logs
|
||||
$0 status # Check what's running
|
||||
$0 down # Stop and clean up
|
||||
|
||||
EOF
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown command: $command"
|
||||
echo "Run '$0 help' for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Tmux-based log viewer for Nym Localnet containers
|
||||
# Shows all container logs in a multi-pane layout
|
||||
|
||||
SESSION_NAME="nym-localnet-logs"
|
||||
|
||||
# Container names
|
||||
CONTAINERS=(
|
||||
"nym-mixnode1"
|
||||
"nym-mixnode2"
|
||||
"nym-mixnode3"
|
||||
"nym-gateway"
|
||||
"nym-network-requester"
|
||||
"nym-socks5-client"
|
||||
)
|
||||
|
||||
# Check if containers are running
|
||||
running_containers=()
|
||||
for container in "${CONTAINERS[@]}"; do
|
||||
if container inspect "$container" &>/dev/null; then
|
||||
running_containers+=("$container")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#running_containers[@]} -eq 0 ]; then
|
||||
echo "Error: No containers are running"
|
||||
echo "Start the localnet first: ./localnet.sh start"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if we're already in tmux
|
||||
if [ -n "$TMUX" ]; then
|
||||
# Inside tmux - create new window
|
||||
tmux new-window -n "logs" "container logs -f ${running_containers[0]}"
|
||||
|
||||
# Split for remaining containers
|
||||
for ((i=1; i<${#running_containers[@]}; i++)); do
|
||||
tmux split-window -t logs "container logs -f ${running_containers[$i]}"
|
||||
tmux select-layout -t logs tiled
|
||||
done
|
||||
|
||||
tmux select-layout -t logs tiled
|
||||
else
|
||||
# Not in tmux - check if session exists
|
||||
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
|
||||
# Session exists - attach to it
|
||||
exec tmux attach-session -t "$SESSION_NAME"
|
||||
else
|
||||
# Create new session
|
||||
tmux new-session -d -s "$SESSION_NAME" -n "logs" "container logs -f ${running_containers[0]}"
|
||||
|
||||
# Split for remaining containers
|
||||
for ((i=1; i<${#running_containers[@]}; i++)); do
|
||||
tmux split-window -t "$SESSION_NAME:logs" "container logs -f ${running_containers[$i]}"
|
||||
tmux select-layout -t "$SESSION_NAME:logs" tiled
|
||||
done
|
||||
|
||||
tmux select-layout -t "$SESSION_NAME:logs" tiled
|
||||
|
||||
# Attach to the session
|
||||
exec tmux attach-session -t "$SESSION_NAME"
|
||||
fi
|
||||
fi
|
||||
Executable
+590
@@ -0,0 +1,590 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -ex
|
||||
|
||||
# Nym Localnet Orchestration Script for Apple Container Runtime
|
||||
# Emulates docker-compose functionality
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
IMAGE_NAME="nym-localnet:latest"
|
||||
VOLUME_NAME="nym-localnet-data"
|
||||
VOLUME_PATH="/tmp/nym-localnet-$$"
|
||||
NYM_VOLUME_PATH="/tmp/nym-localnet-home-$$"
|
||||
|
||||
SUFFIX=${NYM_NODE_SUFFIX:-localnet}
|
||||
|
||||
# Container names
|
||||
INIT_CONTAINER="nym-localnet-init"
|
||||
MIXNODE1_CONTAINER="nym-mixnode1"
|
||||
MIXNODE2_CONTAINER="nym-mixnode2"
|
||||
MIXNODE3_CONTAINER="nym-mixnode3"
|
||||
GATEWAY_CONTAINER="nym-gateway"
|
||||
REQUESTER_CONTAINER="nym-network-requester"
|
||||
SOCKS5_CONTAINER="nym-socks5-client"
|
||||
|
||||
ALL_CONTAINERS=(
|
||||
"$MIXNODE1_CONTAINER"
|
||||
"$MIXNODE2_CONTAINER"
|
||||
"$MIXNODE3_CONTAINER"
|
||||
"$GATEWAY_CONTAINER"
|
||||
"$REQUESTER_CONTAINER"
|
||||
"$SOCKS5_CONTAINER"
|
||||
)
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
log_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $*"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $*"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $*"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $*"
|
||||
}
|
||||
|
||||
cleanup_host_state() {
|
||||
log_info "Cleaning local nym-node state for suffix ${SUFFIX}"
|
||||
for node in mix1 mix2 mix3 gateway; do
|
||||
rm -rf "$HOME/.nym/nym-nodes/${node}-${SUFFIX}"
|
||||
done
|
||||
}
|
||||
|
||||
# Check if container command exists
|
||||
check_prerequisites() {
|
||||
if ! command -v container &> /dev/null; then
|
||||
log_error "Apple 'container' command not found"
|
||||
log_error "Install from: https://github.com/apple/container"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Build the Docker image
|
||||
build_image() {
|
||||
log_info "Building image: $IMAGE_NAME"
|
||||
log_warn "This will take 15-30 minutes on first build..."
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Build with Docker
|
||||
log_info "Building with Docker..."
|
||||
if ! docker build \
|
||||
-f "$SCRIPT_DIR/Dockerfile.localnet" \
|
||||
-t "$IMAGE_NAME" \
|
||||
"$PROJECT_ROOT"; then
|
||||
log_error "Docker build failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Transfer image to container runtime
|
||||
log_info "Transferring image to container runtime..."
|
||||
|
||||
# Save to temporary file (container image load doesn't support stdin)
|
||||
TEMP_IMAGE="/tmp/nym-localnet-image-$$.tar"
|
||||
if ! docker save -o "$TEMP_IMAGE" "$IMAGE_NAME"; then
|
||||
log_error "Failed to save Docker image"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Load into container runtime from file
|
||||
if ! container image load --input "$TEMP_IMAGE"; then
|
||||
rm -f "$TEMP_IMAGE"
|
||||
log_error "Failed to load image into container runtime"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clean up temporary file
|
||||
rm -f "$TEMP_IMAGE"
|
||||
|
||||
# Verify image is available
|
||||
if ! container image inspect "$IMAGE_NAME" &>/dev/null; then
|
||||
log_error "Image not found in container runtime after load"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "Image built and loaded: $IMAGE_NAME"
|
||||
}
|
||||
|
||||
# Create shared volume directory
|
||||
create_volume() {
|
||||
log_info "Creating shared volume at: $VOLUME_PATH"
|
||||
mkdir -p "$VOLUME_PATH"
|
||||
chmod 777 "$VOLUME_PATH"
|
||||
log_success "Volume created"
|
||||
}
|
||||
|
||||
# Create shared nym home directory
|
||||
create_nym_volume() {
|
||||
log_info "Creating shared nym home volume at: $NYM_VOLUME_PATH"
|
||||
mkdir -p "$NYM_VOLUME_PATH"
|
||||
chmod 777 "$NYM_VOLUME_PATH"
|
||||
log_success "Nym home volume created"
|
||||
}
|
||||
|
||||
# Remove shared volume directory
|
||||
remove_volume() {
|
||||
if [ -d "$VOLUME_PATH" ]; then
|
||||
log_info "Removing volume: $VOLUME_PATH"
|
||||
rm -rf "$VOLUME_PATH"
|
||||
log_success "Volume removed"
|
||||
fi
|
||||
if [ -d "$NYM_VOLUME_PATH" ]; then
|
||||
log_info "Removing nym home volume: $NYM_VOLUME_PATH"
|
||||
rm -rf "$NYM_VOLUME_PATH"
|
||||
log_success "Nym home volume removed"
|
||||
fi
|
||||
}
|
||||
|
||||
# Network name
|
||||
NETWORK_NAME="nym-localnet-network"
|
||||
|
||||
# Create container network
|
||||
create_network() {
|
||||
log_info "Creating container network: $NETWORK_NAME"
|
||||
if container network create "$NETWORK_NAME" 2>/dev/null; then
|
||||
log_success "Network created: $NETWORK_NAME"
|
||||
else
|
||||
log_info "Network $NETWORK_NAME already exists or creation failed"
|
||||
fi
|
||||
}
|
||||
|
||||
# Remove container network
|
||||
remove_network() {
|
||||
if container network list | grep -q "$NETWORK_NAME"; then
|
||||
log_info "Removing network: $NETWORK_NAME"
|
||||
container network rm "$NETWORK_NAME" 2>/dev/null || true
|
||||
log_success "Network removed"
|
||||
fi
|
||||
}
|
||||
|
||||
# Start a mixnode
|
||||
start_mixnode() {
|
||||
local node_id=$1
|
||||
local container_name=$2
|
||||
|
||||
log_info "Starting $container_name..."
|
||||
|
||||
# Calculate port numbers based on node_id
|
||||
local mixnet_port="1000${node_id}"
|
||||
local verloc_port="2000${node_id}"
|
||||
local http_port="3000${node_id}"
|
||||
|
||||
container run \
|
||||
--name "$container_name" \
|
||||
-m 2G \
|
||||
--network "$NETWORK_NAME" \
|
||||
-p "${mixnet_port}:${mixnet_port}" \
|
||||
-p "${verloc_port}:${verloc_port}" \
|
||||
-p "${http_port}:${http_port}" \
|
||||
-v "$VOLUME_PATH:/localnet" \
|
||||
-v "$NYM_VOLUME_PATH:/root/.nym" \
|
||||
-d \
|
||||
-e "NYM_NODE_SUFFIX=$SUFFIX" \
|
||||
"$IMAGE_NAME" \
|
||||
sh -c '
|
||||
CONTAINER_IP=$(hostname -i);
|
||||
echo "Container IP: $CONTAINER_IP";
|
||||
echo "Initializing mix'"${node_id}"'...";
|
||||
nym-node run --id mix'"${node_id}"'-localnet --init-only \
|
||||
--unsafe-disable-replay-protection \
|
||||
--local \
|
||||
--mixnet-bind-address=0.0.0.0:'"${mixnet_port}"' \
|
||||
--verloc-bind-address=0.0.0.0:'"${verloc_port}"' \
|
||||
--http-bind-address=0.0.0.0:'"${http_port}"' \
|
||||
--http-access-token=lala \
|
||||
--public-ips $CONTAINER_IP \
|
||||
--output=json \
|
||||
--bonding-information-output="/localnet/mix'"${node_id}"'.json";
|
||||
|
||||
echo "Waiting for network.json...";
|
||||
while [ ! -f /localnet/network.json ]; do
|
||||
sleep 2;
|
||||
done;
|
||||
echo "Starting mix'"${node_id}"'...";
|
||||
exec nym-node run --id mix'"${node_id}"'-localnet --unsafe-disable-replay-protection --local
|
||||
'
|
||||
|
||||
log_success "$container_name started"
|
||||
}
|
||||
# Start gateway
|
||||
start_gateway() {
|
||||
log_info "Starting $GATEWAY_CONTAINER..."
|
||||
|
||||
container run \
|
||||
--name "$GATEWAY_CONTAINER" \
|
||||
-m 2G \
|
||||
--network "$NETWORK_NAME" \
|
||||
-p 9000:9000 \
|
||||
-p 10004:10004 \
|
||||
-p 20004:20004 \
|
||||
-p 30004:30004 \
|
||||
-v "$VOLUME_PATH:/localnet" \
|
||||
-v "$NYM_VOLUME_PATH:/root/.nym" \
|
||||
-d \
|
||||
-e "NYM_NODE_SUFFIX=$SUFFIX" \
|
||||
"$IMAGE_NAME" \
|
||||
sh -c '
|
||||
CONTAINER_IP=$(hostname -i);
|
||||
echo "Container IP: $CONTAINER_IP";
|
||||
echo "Initializing gateway...";
|
||||
nym-node run --id gateway-localnet --init-only \
|
||||
--unsafe-disable-replay-protection \
|
||||
--local \
|
||||
--mode entry-gateway \
|
||||
--mode exit-gateway \
|
||||
--mixnet-bind-address=0.0.0.0:10004 \
|
||||
--entry-bind-address=0.0.0.0:9000 \
|
||||
--verloc-bind-address=0.0.0.0:20004 \
|
||||
--http-bind-address=0.0.0.0:30004 \
|
||||
--http-access-token=lala \
|
||||
--public-ips $CONTAINER_IP \
|
||||
--output=json \
|
||||
--bonding-information-output="/localnet/gateway.json";
|
||||
|
||||
echo "Waiting for network.json...";
|
||||
while [ ! -f /localnet/network.json ]; do
|
||||
sleep 2;
|
||||
done;
|
||||
echo "Starting gateway...";
|
||||
exec nym-node run --id gateway-localnet --unsafe-disable-replay-protection --local
|
||||
'
|
||||
|
||||
log_success "$GATEWAY_CONTAINER started"
|
||||
|
||||
# Wait for gateway to be ready
|
||||
log_info "Waiting for gateway to listen on port 9000..."
|
||||
local retries=0
|
||||
local max_retries=30
|
||||
while ! nc -z 127.0.0.1 9000 2>/dev/null; do
|
||||
sleep 2
|
||||
retries=$((retries + 1))
|
||||
if [ $retries -ge $max_retries ]; then
|
||||
log_error "Gateway failed to start on port 9000"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
log_success "Gateway is ready on port 9000"
|
||||
}
|
||||
# Start network requester
|
||||
start_network_requester() {
|
||||
log_info "Starting $REQUESTER_CONTAINER..."
|
||||
|
||||
# Get gateway IP address
|
||||
log_info "Getting gateway IP address..."
|
||||
GATEWAY_IP=$(container exec "$GATEWAY_CONTAINER" hostname -i)
|
||||
log_info "Gateway IP: $GATEWAY_IP"
|
||||
|
||||
container run \
|
||||
--name "$REQUESTER_CONTAINER" \
|
||||
--network "$NETWORK_NAME" \
|
||||
-v "$VOLUME_PATH:/localnet" \
|
||||
-v "$NYM_VOLUME_PATH:/root/.nym" \
|
||||
-e "GATEWAY_IP=$GATEWAY_IP" \
|
||||
-d \
|
||||
"$IMAGE_NAME" \
|
||||
sh -c '
|
||||
while [ ! -f /localnet/network.json ]; do
|
||||
echo "Waiting for network.json...";
|
||||
sleep 2;
|
||||
done;
|
||||
while ! nc -z $GATEWAY_IP 9000 2>/dev/null; do
|
||||
echo "Waiting for gateway on port 9000 ($GATEWAY_IP)...";
|
||||
sleep 2;
|
||||
done;
|
||||
SUFFIX=$(date +%s);
|
||||
nym-network-requester init \
|
||||
--id "network-requester-$SUFFIX" \
|
||||
--open-proxy=true \
|
||||
--custom-mixnet /localnet/network.json \
|
||||
--output=json > /localnet/network_requester.json;
|
||||
exec nym-network-requester run \
|
||||
--id "network-requester-$SUFFIX" \
|
||||
--custom-mixnet /localnet/network.json
|
||||
'
|
||||
|
||||
log_success "$REQUESTER_CONTAINER started"
|
||||
}
|
||||
|
||||
# Start SOCKS5 client
|
||||
start_socks5_client() {
|
||||
log_info "Starting $SOCKS5_CONTAINER..."
|
||||
|
||||
container run \
|
||||
--name "$SOCKS5_CONTAINER" \
|
||||
--network "$NETWORK_NAME" \
|
||||
-p 1080:1080 \
|
||||
-v "$VOLUME_PATH:/localnet:ro" \
|
||||
-v "$NYM_VOLUME_PATH:/root/.nym" \
|
||||
-d \
|
||||
"$IMAGE_NAME" \
|
||||
sh -c '
|
||||
while [ ! -f /localnet/network_requester.json ]; do
|
||||
echo "Waiting for network requester...";
|
||||
sleep 2;
|
||||
done;
|
||||
SUFFIX=$(date +%s);
|
||||
PROVIDER=$(cat /localnet/network_requester.json | grep -o "\"client_address\":\"[^\"]*\"" | cut -d\" -f4);
|
||||
if [ -z "$PROVIDER" ]; then
|
||||
echo "Error: Could not extract provider address";
|
||||
exit 1;
|
||||
fi;
|
||||
nym-socks5-client init \
|
||||
--id "socks5-client-$SUFFIX" \
|
||||
--provider "$PROVIDER" \
|
||||
--custom-mixnet /localnet/network.json \
|
||||
--no-cover;
|
||||
exec nym-socks5-client run \
|
||||
--id "socks5-client-$SUFFIX" \
|
||||
--custom-mixnet /localnet/network.json \
|
||||
--host 0.0.0.0
|
||||
'
|
||||
|
||||
log_success "$SOCKS5_CONTAINER started"
|
||||
|
||||
# Wait for SOCKS5 to be ready
|
||||
log_info "Waiting for SOCKS5 proxy on port 1080..."
|
||||
sleep 5
|
||||
local retries=0
|
||||
local max_retries=15
|
||||
while ! nc -z 127.0.0.1 1080 2>/dev/null; do
|
||||
sleep 2
|
||||
retries=$((retries + 1))
|
||||
if [ $retries -ge $max_retries ]; then
|
||||
log_warn "SOCKS5 proxy not responding on port 1080 yet"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
log_success "SOCKS5 proxy is ready on port 1080"
|
||||
}
|
||||
|
||||
# Stop all containers
|
||||
stop_containers() {
|
||||
log_info "Stopping all containers..."
|
||||
|
||||
for container_name in "${ALL_CONTAINERS[@]}"; do
|
||||
if container inspect "$container_name" &>/dev/null; then
|
||||
log_info "Stopping $container_name"
|
||||
container stop "$container_name" 2>/dev/null || true
|
||||
container rm "$container_name" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
# Also clean up init container if it exists
|
||||
container rm "$INIT_CONTAINER" 2>/dev/null || true
|
||||
|
||||
log_success "All containers stopped"
|
||||
|
||||
cleanup_host_state
|
||||
remove_network
|
||||
}
|
||||
|
||||
# Show container logs
|
||||
show_logs() {
|
||||
local container_name=${1:-}
|
||||
|
||||
if [ -z "$container_name" ]; then
|
||||
# No container specified - launch tmux log viewer
|
||||
log_info "Launching tmux log viewer for all containers..."
|
||||
exec "$SCRIPT_DIR/localnet-logs.sh"
|
||||
fi
|
||||
|
||||
# Show logs for specific container
|
||||
if container inspect "$container_name" &>/dev/null; then
|
||||
container logs -f "$container_name"
|
||||
else
|
||||
log_error "Container not found: $container_name"
|
||||
log_info "Available containers:"
|
||||
for name in "${ALL_CONTAINERS[@]}"; do
|
||||
echo " - $name"
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Show container status
|
||||
show_status() {
|
||||
log_info "Container status:"
|
||||
echo ""
|
||||
|
||||
for container_name in "${ALL_CONTAINERS[@]}"; do
|
||||
if container inspect "$container_name" &>/dev/null; then
|
||||
local status=$(container inspect "$container_name" 2>/dev/null | grep -o '"Status":"[^"]*"' | cut -d'"' -f4 || echo "unknown")
|
||||
echo -e " ${GREEN}●${NC} $container_name - $status"
|
||||
else
|
||||
echo -e " ${RED}○${NC} $container_name - not running"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
log_info "Port status:"
|
||||
for port in 9000 1080 10001 10002 10003 10004; do
|
||||
if nc -z 127.0.0.1 $port 2>/dev/null; then
|
||||
echo -e " ${GREEN}●${NC} Port $port - listening"
|
||||
else
|
||||
echo -e " ${RED}○${NC} Port $port - not listening"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Build network topology with container IPs
|
||||
build_topology() {
|
||||
log_info "Building network topology with container IPs..."
|
||||
|
||||
# Wait for all bonding JSON files to be created
|
||||
log_info "Waiting for all nodes to complete initialization..."
|
||||
for file in mix1.json mix2.json mix3.json gateway.json; do
|
||||
while [ ! -f "$VOLUME_PATH/$file" ]; do
|
||||
echo " Waiting for $file..."
|
||||
sleep 1
|
||||
done
|
||||
log_success " $file created"
|
||||
done
|
||||
|
||||
# Get container IPs
|
||||
log_info "Getting container IP addresses..."
|
||||
MIX1_IP=$(container exec "$MIXNODE1_CONTAINER" hostname -i)
|
||||
MIX2_IP=$(container exec "$MIXNODE2_CONTAINER" hostname -i)
|
||||
MIX3_IP=$(container exec "$MIXNODE3_CONTAINER" hostname -i)
|
||||
GATEWAY_IP=$(container exec "$GATEWAY_CONTAINER" hostname -i)
|
||||
|
||||
log_info "Container IPs:"
|
||||
echo " mix1: $MIX1_IP"
|
||||
echo " mix2: $MIX2_IP"
|
||||
echo " mix3: $MIX3_IP"
|
||||
echo " gateway: $GATEWAY_IP"
|
||||
|
||||
# Run build_topology.py in a container with access to the volumes
|
||||
container run \
|
||||
--name "nym-localnet-topology-builder" \
|
||||
--network "$NETWORK_NAME" \
|
||||
-v "$VOLUME_PATH:/localnet" \
|
||||
-v "$NYM_VOLUME_PATH:/root/.nym" \
|
||||
--rm \
|
||||
"$IMAGE_NAME" \
|
||||
python3 /usr/local/bin/build_topology.py \
|
||||
/localnet \
|
||||
"$SUFFIX" \
|
||||
"$MIX1_IP" \
|
||||
"$MIX2_IP" \
|
||||
"$MIX3_IP" \
|
||||
"$GATEWAY_IP"
|
||||
|
||||
# Verify network.json was created
|
||||
if [ -f "$VOLUME_PATH/network.json" ]; then
|
||||
log_success "Network topology created successfully"
|
||||
else
|
||||
log_error "Failed to create network topology"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Start all services
|
||||
start_all() {
|
||||
log_info "Starting Nym Localnet..."
|
||||
|
||||
cleanup_host_state
|
||||
create_network
|
||||
create_volume
|
||||
create_nym_volume
|
||||
|
||||
start_mixnode 1 "$MIXNODE1_CONTAINER"
|
||||
start_mixnode 2 "$MIXNODE2_CONTAINER"
|
||||
start_mixnode 3 "$MIXNODE3_CONTAINER"
|
||||
start_gateway
|
||||
build_topology
|
||||
start_network_requester
|
||||
start_socks5_client
|
||||
|
||||
echo ""
|
||||
log_success "Nym Localnet is running!"
|
||||
echo ""
|
||||
echo "Test with:"
|
||||
echo " curl -x socks5h://127.0.0.1:1080 https://nymtech.net"
|
||||
echo ""
|
||||
echo "View logs:"
|
||||
echo " $0 logs # All containers in tmux"
|
||||
echo " $0 logs gateway # Single container"
|
||||
echo ""
|
||||
echo "Stop:"
|
||||
echo " $0 down"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Main command handler
|
||||
main() {
|
||||
check_prerequisites
|
||||
|
||||
local command=${1:-help}
|
||||
shift || true
|
||||
|
||||
case "$command" in
|
||||
build)
|
||||
build_image
|
||||
;;
|
||||
up)
|
||||
build_image
|
||||
start_all
|
||||
;;
|
||||
start)
|
||||
start_all
|
||||
;;
|
||||
down|stop)
|
||||
stop_containers
|
||||
remove_volume
|
||||
;;
|
||||
restart)
|
||||
stop_containers
|
||||
start_all
|
||||
;;
|
||||
logs)
|
||||
show_logs "$@"
|
||||
;;
|
||||
status|ps)
|
||||
show_status
|
||||
;;
|
||||
help|--help|-h)
|
||||
cat <<EOF
|
||||
Nym Localnet Orchestration Script
|
||||
|
||||
Usage: $0 <command> [options]
|
||||
|
||||
Commands:
|
||||
build Build the localnet image
|
||||
up Build image and start all services
|
||||
start Start all services (requires built image)
|
||||
down, stop Stop all services and clean up
|
||||
restart Restart all services
|
||||
logs [name] Show logs (no args = tmux overlay, with name = single container)
|
||||
status, ps Show status of all containers and ports
|
||||
help Show this help message
|
||||
|
||||
Examples:
|
||||
$0 up # Build and start everything
|
||||
$0 logs # View all logs in tmux overlay
|
||||
$0 logs gateway # View gateway logs only
|
||||
$0 status # Check what's running
|
||||
$0 down # Stop and clean up
|
||||
|
||||
EOF
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown command: $command"
|
||||
echo "Run '$0 help' for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -70,6 +70,7 @@ nym-types = { path = "../common/types" }
|
||||
nym-validator-client = { path = "../common/client-libs/validator-client" }
|
||||
nym-ip-packet-router = { path = "../service-providers/ip-packet-router" }
|
||||
nym-node-metrics = { path = "../nym-node/nym-node-metrics" }
|
||||
nym-metrics = { path = "../common/nym-metrics" }
|
||||
|
||||
nym-wireguard = { path = "../common/wireguard" }
|
||||
nym-wireguard-private-metadata-server = { path = "../common/wireguard-private-metadata/server" }
|
||||
@@ -80,6 +81,11 @@ nym-client-core = { path = "../common/client-core", features = ["cli"] }
|
||||
nym-id = { path = "../common/nym-id" }
|
||||
nym-service-provider-requests-common = { path = "../common/service-provider-requests-common" }
|
||||
|
||||
# LP dependencies
|
||||
nym-lp = { path = "../common/nym-lp" }
|
||||
nym-kcp = { path = "../common/nym-kcp" }
|
||||
nym-registration-common = { path = "../common/registration" }
|
||||
bytes = { workspace = true }
|
||||
|
||||
defguard_wireguard_rs = { workspace = true }
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ pub struct Config {
|
||||
|
||||
pub ip_packet_router: IpPacketRouter,
|
||||
|
||||
pub lp: crate::node::lp_listener::LpConfig,
|
||||
|
||||
pub debug: Debug,
|
||||
}
|
||||
|
||||
@@ -21,12 +23,14 @@ impl Config {
|
||||
gateway: impl Into<Gateway>,
|
||||
network_requester: impl Into<NetworkRequester>,
|
||||
ip_packet_router: impl Into<IpPacketRouter>,
|
||||
lp: impl Into<crate::node::lp_listener::LpConfig>,
|
||||
debug: impl Into<Debug>,
|
||||
) -> Self {
|
||||
Config {
|
||||
gateway: gateway.into(),
|
||||
network_requester: network_requester.into(),
|
||||
ip_packet_router: ip_packet_router.into(),
|
||||
lp: lp.into(),
|
||||
debug: debug.into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,27 @@ pub enum GatewayError {
|
||||
|
||||
#[error("{0}")]
|
||||
CredentialVefiricationError(#[from] nym_credential_verification::Error),
|
||||
|
||||
#[error("LP connection error: {0}")]
|
||||
LpConnectionError(String),
|
||||
|
||||
#[error("LP protocol error: {0}")]
|
||||
LpProtocolError(String),
|
||||
|
||||
#[error("LP handshake error: {0}")]
|
||||
LpHandshakeError(String),
|
||||
|
||||
#[error("Service provider {service} is not running")]
|
||||
ServiceProviderNotRunning { service: String },
|
||||
|
||||
#[error("Internal error: {0}")]
|
||||
InternalError(String),
|
||||
|
||||
#[error("Failed to bind listener to {address}: {source}")]
|
||||
ListenerBindFailure {
|
||||
address: String,
|
||||
source: Box<dyn std::error::Error + Send + Sync>,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<ClientCoreError> for GatewayError {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use crate::node::ActiveClientsStore;
|
||||
use nym_credential_verification::{ecash::EcashManager, BandwidthFlushingBehaviourConfig};
|
||||
use nym_credential_verification::BandwidthFlushingBehaviourConfig;
|
||||
use nym_crypto::asymmetric::ed25519;
|
||||
use nym_gateway_storage::GatewayStorage;
|
||||
use nym_mixnet_client::forwarder::MixForwardingSender;
|
||||
@@ -22,7 +22,8 @@ pub(crate) struct Config {
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct CommonHandlerState {
|
||||
pub(crate) cfg: Config,
|
||||
pub(crate) ecash_verifier: Arc<EcashManager>,
|
||||
pub(crate) ecash_verifier:
|
||||
Arc<dyn nym_credential_verification::ecash::traits::EcashManager + Send + Sync>,
|
||||
pub(crate) storage: GatewayStorage,
|
||||
pub(crate) local_identity: Arc<ed25519::KeyPair>,
|
||||
pub(crate) metrics: NymNodeMetrics,
|
||||
|
||||
@@ -5,7 +5,6 @@ use crate::node::internal_service_providers::authenticator::error::Authenticator
|
||||
use futures::channel::oneshot;
|
||||
use ipnetwork::IpNetwork;
|
||||
use nym_client_core::{HardcodedTopologyProvider, TopologyProvider};
|
||||
use nym_credential_verification::ecash::EcashManager;
|
||||
use nym_sdk::{mixnet::Recipient, GatewayTransceiver};
|
||||
use nym_task::ShutdownTracker;
|
||||
use nym_wireguard::WireguardGatewayData;
|
||||
@@ -38,7 +37,7 @@ pub struct Authenticator {
|
||||
custom_topology_provider: Option<Box<dyn TopologyProvider + Send + Sync>>,
|
||||
custom_gateway_transceiver: Option<Box<dyn GatewayTransceiver + Send + Sync>>,
|
||||
wireguard_gateway_data: WireguardGatewayData,
|
||||
ecash_verifier: Arc<EcashManager>,
|
||||
ecash_verifier: Arc<dyn nym_credential_verification::ecash::traits::EcashManager + Send + Sync>,
|
||||
used_private_network_ips: Vec<IpAddr>,
|
||||
shutdown: ShutdownTracker,
|
||||
on_start: Option<oneshot::Sender<OnStartData>>,
|
||||
@@ -49,7 +48,9 @@ impl Authenticator {
|
||||
config: crate::node::internal_service_providers::authenticator::Config,
|
||||
wireguard_gateway_data: WireguardGatewayData,
|
||||
used_private_network_ips: Vec<IpAddr>,
|
||||
ecash_verifier: Arc<EcashManager>,
|
||||
ecash_verifier: Arc<
|
||||
dyn nym_credential_verification::ecash::traits::EcashManager + Send + Sync,
|
||||
>,
|
||||
shutdown: ShutdownTracker,
|
||||
) -> Self {
|
||||
Self {
|
||||
|
||||
@@ -0,0 +1,998 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use super::handshake::LpGatewayHandshake;
|
||||
use super::messages::{LpRegistrationRequest, LpRegistrationResponse};
|
||||
use super::registration::process_registration;
|
||||
use super::LpHandlerState;
|
||||
use crate::error::GatewayError;
|
||||
use nym_lp::{
|
||||
keypair::{Keypair, PublicKey},
|
||||
LpMessage, LpPacket, LpSession,
|
||||
};
|
||||
use nym_metrics::{add_histogram_obs, inc};
|
||||
use std::net::SocketAddr;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use tracing::*;
|
||||
|
||||
// Histogram buckets for LP operation duration tracking
|
||||
// Covers typical LP operations from 10ms to 10 seconds
|
||||
// - Most handshakes should complete in < 100ms
|
||||
// - Registration with credential verification typically 100ms - 1s
|
||||
// - Slow operations (network issues, DB contention) up to 10s
|
||||
const LP_DURATION_BUCKETS: &[f64] = &[
|
||||
0.01, // 10ms
|
||||
0.05, // 50ms
|
||||
0.1, // 100ms
|
||||
0.25, // 250ms
|
||||
0.5, // 500ms
|
||||
1.0, // 1s
|
||||
2.5, // 2.5s
|
||||
5.0, // 5s
|
||||
10.0, // 10s
|
||||
];
|
||||
|
||||
// Histogram buckets for LP connection lifecycle duration
|
||||
// LP connections can be very short (registration only: ~1s) or very long (dVPN sessions: hours/days)
|
||||
// Covers full range from seconds to 24 hours
|
||||
const LP_CONNECTION_DURATION_BUCKETS: &[f64] = &[
|
||||
1.0, // 1 second
|
||||
5.0, // 5 seconds
|
||||
10.0, // 10 seconds
|
||||
30.0, // 30 seconds
|
||||
60.0, // 1 minute
|
||||
300.0, // 5 minutes
|
||||
600.0, // 10 minutes
|
||||
1800.0, // 30 minutes
|
||||
3600.0, // 1 hour
|
||||
7200.0, // 2 hours
|
||||
14400.0, // 4 hours
|
||||
28800.0, // 8 hours
|
||||
43200.0, // 12 hours
|
||||
86400.0, // 24 hours
|
||||
];
|
||||
|
||||
/// Connection lifecycle statistics tracking
|
||||
struct ConnectionStats {
|
||||
/// When the connection started
|
||||
start_time: std::time::Instant,
|
||||
/// Total bytes received (including protocol framing)
|
||||
bytes_received: u64,
|
||||
/// Total bytes sent (including protocol framing)
|
||||
bytes_sent: u64,
|
||||
}
|
||||
|
||||
impl ConnectionStats {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
start_time: std::time::Instant::now(),
|
||||
bytes_received: 0,
|
||||
bytes_sent: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn record_bytes_received(&mut self, bytes: usize) {
|
||||
self.bytes_received += bytes as u64;
|
||||
}
|
||||
|
||||
fn record_bytes_sent(&mut self, bytes: usize) {
|
||||
self.bytes_sent += bytes as u64;
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LpConnectionHandler {
|
||||
stream: TcpStream,
|
||||
remote_addr: SocketAddr,
|
||||
state: LpHandlerState,
|
||||
stats: ConnectionStats,
|
||||
}
|
||||
|
||||
impl LpConnectionHandler {
|
||||
pub fn new(stream: TcpStream, remote_addr: SocketAddr, state: LpHandlerState) -> Self {
|
||||
Self {
|
||||
stream,
|
||||
remote_addr,
|
||||
state,
|
||||
stats: ConnectionStats::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle(mut self) -> Result<(), GatewayError> {
|
||||
debug!("Handling LP connection from {}", self.remote_addr);
|
||||
|
||||
// Track total LP connections handled
|
||||
inc!("lp_connections_total");
|
||||
|
||||
// For LP, we need:
|
||||
// 1. Gateway's keypair (from local_identity)
|
||||
// 2. Client's public key (will be received during handshake)
|
||||
// 3. PSK (pre-shared key) - for now use a placeholder
|
||||
|
||||
// Generate fresh LP keypair (x25519) for this connection
|
||||
// Using Keypair::default() which generates a new random x25519 keypair
|
||||
// This is secure and simple - each connection gets its own keypair
|
||||
let gateway_keypair = Keypair::default();
|
||||
|
||||
// Receive client's public key and salt via ClientHello message
|
||||
// The client initiates by sending ClientHello as first packet
|
||||
let (client_pubkey, salt) = match self.receive_client_hello().await {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
// Track ClientHello failures (timestamp validation, protocol errors, etc.)
|
||||
inc!("lp_client_hello_failed");
|
||||
// Emit lifecycle metrics before returning
|
||||
self.emit_lifecycle_metrics(false);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Derive PSK using ECDH + Blake3 KDF (nym-109)
|
||||
// Both client and gateway derive the same PSK from their respective keys
|
||||
let psk = nym_lp::derive_psk(gateway_keypair.private_key(), &client_pubkey, &salt);
|
||||
tracing::trace!("Derived PSK from LP keys and ClientHello salt");
|
||||
|
||||
// Create LP handshake as responder
|
||||
let handshake = LpGatewayHandshake::new_responder(&gateway_keypair, &client_pubkey, &psk)?;
|
||||
|
||||
// Complete the LP handshake with duration tracking
|
||||
let handshake_start = std::time::Instant::now();
|
||||
let session = match handshake.complete(&mut self.stream).await {
|
||||
Ok(s) => {
|
||||
let duration = handshake_start.elapsed().as_secs_f64();
|
||||
add_histogram_obs!(
|
||||
"lp_handshake_duration_seconds",
|
||||
duration,
|
||||
LP_DURATION_BUCKETS
|
||||
);
|
||||
inc!("lp_handshakes_success");
|
||||
s
|
||||
}
|
||||
Err(e) => {
|
||||
inc!("lp_handshakes_failed");
|
||||
inc!("lp_errors_handshake");
|
||||
// Emit lifecycle metrics before returning
|
||||
self.emit_lifecycle_metrics(false);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
"LP handshake completed for {} (session {})",
|
||||
self.remote_addr,
|
||||
session.id()
|
||||
);
|
||||
|
||||
// After handshake, receive registration request
|
||||
let request = self.receive_registration_request(&session).await?;
|
||||
|
||||
debug!(
|
||||
"LP registration request from {}: mode={:?}",
|
||||
self.remote_addr, request.mode
|
||||
);
|
||||
|
||||
// Process registration (verify credentials, add peer, etc.)
|
||||
let response = process_registration(request, &self.state).await;
|
||||
|
||||
// Send response
|
||||
if let Err(e) = self
|
||||
.send_registration_response(&session, response.clone())
|
||||
.await
|
||||
{
|
||||
warn!("Failed to send LP response to {}: {}", self.remote_addr, e);
|
||||
inc!("lp_errors_send_response");
|
||||
// Emit lifecycle metrics before returning
|
||||
self.emit_lifecycle_metrics(false);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
if response.success {
|
||||
info!(
|
||||
"LP registration successful for {} (session {})",
|
||||
self.remote_addr, response.session_id
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"LP registration failed for {}: {:?}",
|
||||
self.remote_addr, response.error
|
||||
);
|
||||
}
|
||||
|
||||
// Emit lifecycle metrics on graceful completion
|
||||
self.emit_lifecycle_metrics(true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validates that a ClientHello timestamp is within the acceptable time window.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `client_timestamp` - Unix timestamp (seconds) from ClientHello salt
|
||||
/// * `tolerance_secs` - Maximum acceptable age in seconds
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(())` if timestamp is valid (within tolerance window)
|
||||
/// * `Err(GatewayError)` if timestamp is too old or too far in the future
|
||||
///
|
||||
/// # Security
|
||||
/// This prevents replay attacks by rejecting stale ClientHello messages.
|
||||
/// The tolerance window should be:
|
||||
/// - Large enough for clock skew + network latency
|
||||
/// - Small enough to limit replay attack window
|
||||
fn validate_timestamp(client_timestamp: u64, tolerance_secs: u64) -> Result<(), GatewayError> {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("System time before UNIX epoch")
|
||||
.as_secs();
|
||||
|
||||
let age = if now >= client_timestamp {
|
||||
now - client_timestamp
|
||||
} else {
|
||||
// Client timestamp is in the future
|
||||
client_timestamp - now
|
||||
};
|
||||
|
||||
if age > tolerance_secs {
|
||||
let direction = if now >= client_timestamp {
|
||||
"old"
|
||||
} else {
|
||||
"future"
|
||||
};
|
||||
|
||||
// Track timestamp validation failures
|
||||
inc!("lp_timestamp_validation_rejected");
|
||||
if now >= client_timestamp {
|
||||
inc!("lp_errors_timestamp_too_old");
|
||||
} else {
|
||||
inc!("lp_errors_timestamp_too_far_future");
|
||||
}
|
||||
|
||||
return Err(GatewayError::LpProtocolError(format!(
|
||||
"ClientHello timestamp is too {} (age: {}s, tolerance: {}s)",
|
||||
direction, age, tolerance_secs
|
||||
)));
|
||||
}
|
||||
|
||||
// Track successful timestamp validation
|
||||
inc!("lp_timestamp_validation_accepted");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Receive client's public key and salt via ClientHello message
|
||||
async fn receive_client_hello(&mut self) -> Result<(PublicKey, [u8; 32]), GatewayError> {
|
||||
// Receive first packet which should be ClientHello
|
||||
let packet = self.receive_lp_packet().await?;
|
||||
|
||||
// Verify it's a ClientHello message
|
||||
match packet.message() {
|
||||
LpMessage::ClientHello(hello_data) => {
|
||||
// Validate protocol version (currently only v1)
|
||||
if hello_data.protocol_version != 1 {
|
||||
return Err(GatewayError::LpProtocolError(format!(
|
||||
"Unsupported protocol version: {}",
|
||||
hello_data.protocol_version
|
||||
)));
|
||||
}
|
||||
|
||||
// Extract and validate timestamp (nym-110: replay protection)
|
||||
let timestamp = hello_data.extract_timestamp();
|
||||
Self::validate_timestamp(timestamp, self.state.lp_config.timestamp_tolerance_secs)?;
|
||||
|
||||
tracing::debug!(
|
||||
"ClientHello timestamp validated: {} (age: {}s, tolerance: {}s)",
|
||||
timestamp,
|
||||
{
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("System time before UNIX epoch")
|
||||
.as_secs();
|
||||
if now >= timestamp {
|
||||
now - timestamp
|
||||
} else {
|
||||
timestamp - now
|
||||
}
|
||||
},
|
||||
self.state.lp_config.timestamp_tolerance_secs
|
||||
);
|
||||
|
||||
// Convert bytes to PublicKey
|
||||
let client_pubkey = PublicKey::from_bytes(&hello_data.client_lp_public_key)
|
||||
.map_err(|e| {
|
||||
GatewayError::LpProtocolError(format!("Invalid client public key: {}", e))
|
||||
})?;
|
||||
|
||||
// Extract salt for PSK derivation
|
||||
let salt = hello_data.salt;
|
||||
|
||||
Ok((client_pubkey, salt))
|
||||
}
|
||||
other => Err(GatewayError::LpProtocolError(format!(
|
||||
"Expected ClientHello, got {}",
|
||||
other
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Receive registration request after handshake
|
||||
async fn receive_registration_request(
|
||||
&mut self,
|
||||
session: &LpSession,
|
||||
) -> Result<LpRegistrationRequest, GatewayError> {
|
||||
// Read LP packet containing the registration request
|
||||
let packet = self.receive_lp_packet().await?;
|
||||
|
||||
// Verify it's from the correct session
|
||||
if packet.header().session_id != session.id() {
|
||||
return Err(GatewayError::LpProtocolError(format!(
|
||||
"Session ID mismatch: expected {}, got {}",
|
||||
session.id(),
|
||||
packet.header().session_id
|
||||
)));
|
||||
}
|
||||
|
||||
// Extract registration request from LP message
|
||||
match packet.message() {
|
||||
LpMessage::EncryptedData(data) => {
|
||||
// Deserialize registration request
|
||||
bincode::deserialize(&data).map_err(|e| {
|
||||
GatewayError::LpProtocolError(format!(
|
||||
"Failed to deserialize registration request: {}",
|
||||
e
|
||||
))
|
||||
})
|
||||
}
|
||||
other => Err(GatewayError::LpProtocolError(format!(
|
||||
"Expected EncryptedData message, got {:?}",
|
||||
other
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send registration response after processing
|
||||
async fn send_registration_response(
|
||||
&mut self,
|
||||
session: &LpSession,
|
||||
response: LpRegistrationResponse,
|
||||
) -> Result<(), GatewayError> {
|
||||
// Serialize response
|
||||
let data = bincode::serialize(&response).map_err(|e| {
|
||||
GatewayError::LpProtocolError(format!("Failed to serialize response: {}", e))
|
||||
})?;
|
||||
|
||||
// Create LP packet with response
|
||||
let packet = session.create_data_packet(data).map_err(|e| {
|
||||
GatewayError::LpProtocolError(format!("Failed to create data packet: {}", e))
|
||||
})?;
|
||||
|
||||
// Send the packet
|
||||
self.send_lp_packet(&packet).await
|
||||
}
|
||||
|
||||
/// Receive an LP packet from the stream with proper length-prefixed framing
|
||||
async fn receive_lp_packet(&mut self) -> Result<LpPacket, GatewayError> {
|
||||
use nym_lp::codec::parse_lp_packet;
|
||||
|
||||
// Read 4-byte length prefix (u32 big-endian)
|
||||
let mut len_buf = [0u8; 4];
|
||||
self.stream.read_exact(&mut len_buf).await.map_err(|e| {
|
||||
GatewayError::LpConnectionError(format!("Failed to read packet length: {}", e))
|
||||
})?;
|
||||
|
||||
let packet_len = u32::from_be_bytes(len_buf) as usize;
|
||||
|
||||
// Sanity check to prevent huge allocations
|
||||
const MAX_PACKET_SIZE: usize = 65536; // 64KB max
|
||||
if packet_len > MAX_PACKET_SIZE {
|
||||
return Err(GatewayError::LpProtocolError(format!(
|
||||
"Packet size {} exceeds maximum {}",
|
||||
packet_len, MAX_PACKET_SIZE
|
||||
)));
|
||||
}
|
||||
|
||||
// Read the actual packet data
|
||||
let mut packet_buf = vec![0u8; packet_len];
|
||||
self.stream.read_exact(&mut packet_buf).await.map_err(|e| {
|
||||
GatewayError::LpConnectionError(format!("Failed to read packet data: {}", e))
|
||||
})?;
|
||||
|
||||
// Track bytes received (4 byte header + packet data)
|
||||
self.stats.record_bytes_received(4 + packet_len);
|
||||
|
||||
parse_lp_packet(&packet_buf)
|
||||
.map_err(|e| GatewayError::LpProtocolError(format!("Failed to parse LP packet: {}", e)))
|
||||
}
|
||||
|
||||
/// Send an LP packet over the stream with proper length-prefixed framing
|
||||
async fn send_lp_packet(&mut self, packet: &LpPacket) -> Result<(), GatewayError> {
|
||||
use bytes::BytesMut;
|
||||
use nym_lp::codec::serialize_lp_packet;
|
||||
|
||||
// Serialize the packet first
|
||||
let mut packet_buf = BytesMut::new();
|
||||
serialize_lp_packet(packet, &mut packet_buf).map_err(|e| {
|
||||
GatewayError::LpProtocolError(format!("Failed to serialize packet: {}", e))
|
||||
})?;
|
||||
|
||||
// Send 4-byte length prefix (u32 big-endian)
|
||||
let len = packet_buf.len() as u32;
|
||||
self.stream
|
||||
.write_all(&len.to_be_bytes())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
GatewayError::LpConnectionError(format!("Failed to send packet length: {}", e))
|
||||
})?;
|
||||
|
||||
// Send the actual packet data
|
||||
self.stream.write_all(&packet_buf).await.map_err(|e| {
|
||||
GatewayError::LpConnectionError(format!("Failed to send packet data: {}", e))
|
||||
})?;
|
||||
|
||||
self.stream.flush().await.map_err(|e| {
|
||||
GatewayError::LpConnectionError(format!("Failed to flush stream: {}", e))
|
||||
})?;
|
||||
|
||||
// Track bytes sent (4 byte header + packet data)
|
||||
self.stats.record_bytes_sent(4 + packet_buf.len());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Emit connection lifecycle metrics
|
||||
fn emit_lifecycle_metrics(&self, graceful: bool) {
|
||||
use nym_metrics::inc_by;
|
||||
|
||||
// Track connection duration
|
||||
let duration = self.stats.start_time.elapsed().as_secs_f64();
|
||||
add_histogram_obs!(
|
||||
"lp_connection_duration_seconds",
|
||||
duration,
|
||||
LP_CONNECTION_DURATION_BUCKETS
|
||||
);
|
||||
|
||||
// Track bytes transferred
|
||||
inc_by!(
|
||||
"lp_connection_bytes_received_total",
|
||||
self.stats.bytes_received as i64
|
||||
);
|
||||
inc_by!("lp_connection_bytes_sent_total", self.stats.bytes_sent as i64);
|
||||
|
||||
// Track completion type
|
||||
if graceful {
|
||||
inc!("lp_connections_completed_gracefully");
|
||||
} else {
|
||||
inc!("lp_connections_completed_with_error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extension trait for LpSession to create packets
|
||||
// This would ideally be part of nym-lp
|
||||
trait LpSessionExt {
|
||||
fn create_data_packet(&self, data: Vec<u8>) -> Result<LpPacket, nym_lp::LpError>;
|
||||
}
|
||||
|
||||
impl LpSessionExt for LpSession {
|
||||
fn create_data_packet(&self, data: Vec<u8>) -> Result<LpPacket, nym_lp::LpError> {
|
||||
use nym_lp::packet::LpHeader;
|
||||
|
||||
let header = LpHeader {
|
||||
protocol_version: 1,
|
||||
session_id: self.id(),
|
||||
counter: 0, // TODO: Use actual counter from session
|
||||
};
|
||||
|
||||
let message = LpMessage::EncryptedData(data);
|
||||
|
||||
Ok(LpPacket::new(header, message))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::node::lp_listener::LpConfig;
|
||||
use crate::node::ActiveClientsStore;
|
||||
use bytes::BytesMut;
|
||||
use nym_lp::codec::{parse_lp_packet, serialize_lp_packet};
|
||||
use nym_lp::keypair::Keypair;
|
||||
use nym_lp::message::{ClientHelloData, LpMessage};
|
||||
use nym_lp::packet::{LpHeader, LpPacket};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
// ==================== Test Helpers ====================
|
||||
|
||||
/// Create a minimal test state for handler tests
|
||||
async fn create_minimal_test_state() -> LpHandlerState {
|
||||
use nym_crypto::asymmetric::ed25519;
|
||||
use rand::rngs::OsRng;
|
||||
|
||||
// Create in-memory storage for testing
|
||||
let storage = nym_gateway_storage::GatewayStorage::init(":memory:", 100)
|
||||
.await
|
||||
.expect("Failed to create test storage");
|
||||
|
||||
// Create mock ecash manager for testing
|
||||
let ecash_verifier =
|
||||
nym_credential_verification::ecash::MockEcashManager::new(Box::new(storage.clone()));
|
||||
|
||||
LpHandlerState {
|
||||
lp_config: LpConfig {
|
||||
enabled: true,
|
||||
timestamp_tolerance_secs: 30,
|
||||
..Default::default()
|
||||
},
|
||||
ecash_verifier: Arc::new(ecash_verifier)
|
||||
as Arc<dyn nym_credential_verification::ecash::traits::EcashManager + Send + Sync>,
|
||||
storage,
|
||||
local_identity: Arc::new(ed25519::KeyPair::new(&mut OsRng)),
|
||||
metrics: nym_node_metrics::NymNodeMetrics::default(),
|
||||
active_clients_store: ActiveClientsStore::new(),
|
||||
wg_peer_controller: None,
|
||||
wireguard_data: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to write an LP packet to a stream with proper framing
|
||||
async fn write_lp_packet_to_stream<W: AsyncWriteExt + Unpin>(
|
||||
stream: &mut W,
|
||||
packet: &LpPacket,
|
||||
) -> Result<(), std::io::Error> {
|
||||
let mut packet_buf = BytesMut::new();
|
||||
serialize_lp_packet(packet, &mut packet_buf)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
|
||||
|
||||
// Write length prefix
|
||||
let len = packet_buf.len() as u32;
|
||||
stream.write_all(&len.to_be_bytes()).await?;
|
||||
|
||||
// Write packet data
|
||||
stream.write_all(&packet_buf).await?;
|
||||
stream.flush().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper to read an LP packet from a stream with proper framing
|
||||
async fn read_lp_packet_from_stream<R: AsyncReadExt + Unpin>(
|
||||
stream: &mut R,
|
||||
) -> Result<LpPacket, std::io::Error> {
|
||||
// Read length prefix
|
||||
let mut len_buf = [0u8; 4];
|
||||
stream.read_exact(&mut len_buf).await?;
|
||||
let packet_len = u32::from_be_bytes(len_buf) as usize;
|
||||
|
||||
// Read packet data
|
||||
let mut packet_buf = vec![0u8; packet_len];
|
||||
stream.read_exact(&mut packet_buf).await?;
|
||||
|
||||
// Parse packet
|
||||
parse_lp_packet(&packet_buf)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))
|
||||
}
|
||||
|
||||
// ==================== Existing Tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_validate_timestamp_current() {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
// Current timestamp should always pass
|
||||
assert!(LpConnectionHandler::validate_timestamp(now, 30).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_timestamp_within_tolerance() {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
// 10 seconds old, tolerance 30s -> should pass
|
||||
let old_timestamp = now - 10;
|
||||
assert!(LpConnectionHandler::validate_timestamp(old_timestamp, 30).is_ok());
|
||||
|
||||
// 10 seconds in future, tolerance 30s -> should pass
|
||||
let future_timestamp = now + 10;
|
||||
assert!(LpConnectionHandler::validate_timestamp(future_timestamp, 30).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_timestamp_too_old() {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
// 60 seconds old, tolerance 30s -> should fail
|
||||
let old_timestamp = now - 60;
|
||||
let result = LpConnectionHandler::validate_timestamp(old_timestamp, 30);
|
||||
assert!(result.is_err());
|
||||
assert!(format!("{:?}", result).contains("too old"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_timestamp_too_far_future() {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
// 60 seconds in future, tolerance 30s -> should fail
|
||||
let future_timestamp = now + 60;
|
||||
let result = LpConnectionHandler::validate_timestamp(future_timestamp, 30);
|
||||
assert!(result.is_err());
|
||||
assert!(format!("{:?}", result).contains("too future"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_timestamp_boundary() {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
// Exactly at tolerance boundary -> should pass
|
||||
let boundary_timestamp = now - 30;
|
||||
assert!(LpConnectionHandler::validate_timestamp(boundary_timestamp, 30).is_ok());
|
||||
|
||||
// Just beyond boundary -> should fail
|
||||
let beyond_timestamp = now - 31;
|
||||
assert!(LpConnectionHandler::validate_timestamp(beyond_timestamp, 30).is_err());
|
||||
}
|
||||
|
||||
// ==================== Packet I/O Tests ====================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_receive_lp_packet_valid() {
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
// Bind to localhost
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
// Spawn server task
|
||||
let server_task = tokio::spawn(async move {
|
||||
let (stream, remote_addr) = listener.accept().await.unwrap();
|
||||
let state = create_minimal_test_state().await;
|
||||
let mut handler = LpConnectionHandler::new(stream, remote_addr, state);
|
||||
handler.receive_lp_packet().await
|
||||
});
|
||||
|
||||
// Connect as client
|
||||
let mut client_stream = TcpStream::connect(addr).await.unwrap();
|
||||
|
||||
// Send a valid packet from client side
|
||||
let packet = LpPacket::new(
|
||||
LpHeader {
|
||||
protocol_version: 1,
|
||||
session_id: 42,
|
||||
counter: 0,
|
||||
},
|
||||
LpMessage::Busy,
|
||||
);
|
||||
write_lp_packet_to_stream(&mut client_stream, &packet)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Handler should receive and parse it correctly
|
||||
let received = server_task.await.unwrap().unwrap();
|
||||
assert_eq!(received.header().protocol_version, 1);
|
||||
assert_eq!(received.header().session_id, 42);
|
||||
assert_eq!(received.header().counter, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_receive_lp_packet_exceeds_max_size() {
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let server_task = tokio::spawn(async move {
|
||||
let (stream, remote_addr) = listener.accept().await.unwrap();
|
||||
let state = create_minimal_test_state().await;
|
||||
let mut handler = LpConnectionHandler::new(stream, remote_addr, state);
|
||||
handler.receive_lp_packet().await
|
||||
});
|
||||
|
||||
let mut client_stream = TcpStream::connect(addr).await.unwrap();
|
||||
|
||||
// Send a packet size that exceeds MAX_PACKET_SIZE (64KB)
|
||||
let oversized_len: u32 = 70000; // > 65536
|
||||
client_stream
|
||||
.write_all(&oversized_len.to_be_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
client_stream.flush().await.unwrap();
|
||||
|
||||
// Handler should reject it
|
||||
let result = server_task.await.unwrap();
|
||||
assert!(result.is_err());
|
||||
let err_msg = format!("{:?}", result.unwrap_err());
|
||||
assert!(err_msg.contains("exceeds maximum"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_lp_packet_valid() {
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let server_task = tokio::spawn(async move {
|
||||
let (stream, remote_addr) = listener.accept().await.unwrap();
|
||||
let state = create_minimal_test_state().await;
|
||||
let mut handler = LpConnectionHandler::new(stream, remote_addr, state);
|
||||
|
||||
let packet = LpPacket::new(
|
||||
LpHeader {
|
||||
protocol_version: 1,
|
||||
session_id: 99,
|
||||
counter: 5,
|
||||
},
|
||||
LpMessage::Busy,
|
||||
);
|
||||
handler.send_lp_packet(&packet).await
|
||||
});
|
||||
|
||||
let mut client_stream = TcpStream::connect(addr).await.unwrap();
|
||||
|
||||
// Wait for server to send
|
||||
server_task.await.unwrap().unwrap();
|
||||
|
||||
// Client should receive it correctly
|
||||
let received = read_lp_packet_from_stream(&mut client_stream)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(received.header().session_id, 99);
|
||||
assert_eq!(received.header().counter, 5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_receive_handshake_message() {
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let handshake_data = vec![1, 2, 3, 4, 5, 6, 7, 8];
|
||||
let expected_data = handshake_data.clone();
|
||||
|
||||
let server_task = tokio::spawn(async move {
|
||||
let (stream, remote_addr) = listener.accept().await.unwrap();
|
||||
let state = create_minimal_test_state().await;
|
||||
let mut handler = LpConnectionHandler::new(stream, remote_addr, state);
|
||||
|
||||
let packet = LpPacket::new(
|
||||
LpHeader {
|
||||
protocol_version: 1,
|
||||
session_id: 100,
|
||||
counter: 10,
|
||||
},
|
||||
LpMessage::Handshake(handshake_data),
|
||||
);
|
||||
handler.send_lp_packet(&packet).await
|
||||
});
|
||||
|
||||
let mut client_stream = TcpStream::connect(addr).await.unwrap();
|
||||
server_task.await.unwrap().unwrap();
|
||||
|
||||
let received = read_lp_packet_from_stream(&mut client_stream)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(received.header().session_id, 100);
|
||||
assert_eq!(received.header().counter, 10);
|
||||
match received.message() {
|
||||
LpMessage::Handshake(data) => assert_eq!(data, &expected_data),
|
||||
_ => panic!("Expected Handshake message"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_receive_encrypted_data_message() {
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let encrypted_payload = vec![42u8; 256];
|
||||
let expected_payload = encrypted_payload.clone();
|
||||
|
||||
let server_task = tokio::spawn(async move {
|
||||
let (stream, remote_addr) = listener.accept().await.unwrap();
|
||||
let state = create_minimal_test_state().await;
|
||||
let mut handler = LpConnectionHandler::new(stream, remote_addr, state);
|
||||
|
||||
let packet = LpPacket::new(
|
||||
LpHeader {
|
||||
protocol_version: 1,
|
||||
session_id: 200,
|
||||
counter: 20,
|
||||
},
|
||||
LpMessage::EncryptedData(encrypted_payload),
|
||||
);
|
||||
handler.send_lp_packet(&packet).await
|
||||
});
|
||||
|
||||
let mut client_stream = TcpStream::connect(addr).await.unwrap();
|
||||
server_task.await.unwrap().unwrap();
|
||||
|
||||
let received = read_lp_packet_from_stream(&mut client_stream)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(received.header().session_id, 200);
|
||||
assert_eq!(received.header().counter, 20);
|
||||
match received.message() {
|
||||
LpMessage::EncryptedData(data) => assert_eq!(data, &expected_payload),
|
||||
_ => panic!("Expected EncryptedData message"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_receive_client_hello_message() {
|
||||
use nym_lp::message::ClientHelloData;
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let client_key = [7u8; 32];
|
||||
let hello_data = ClientHelloData::new_with_fresh_salt(client_key, 1);
|
||||
let expected_salt = hello_data.salt; // Clone salt before moving hello_data
|
||||
|
||||
let server_task = tokio::spawn(async move {
|
||||
let (stream, remote_addr) = listener.accept().await.unwrap();
|
||||
let state = create_minimal_test_state().await;
|
||||
let mut handler = LpConnectionHandler::new(stream, remote_addr, state);
|
||||
|
||||
let packet = LpPacket::new(
|
||||
LpHeader {
|
||||
protocol_version: 1,
|
||||
session_id: 300,
|
||||
counter: 30,
|
||||
},
|
||||
LpMessage::ClientHello(hello_data),
|
||||
);
|
||||
handler.send_lp_packet(&packet).await
|
||||
});
|
||||
|
||||
let mut client_stream = TcpStream::connect(addr).await.unwrap();
|
||||
server_task.await.unwrap().unwrap();
|
||||
|
||||
let received = read_lp_packet_from_stream(&mut client_stream)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(received.header().session_id, 300);
|
||||
assert_eq!(received.header().counter, 30);
|
||||
match received.message() {
|
||||
LpMessage::ClientHello(data) => {
|
||||
assert_eq!(data.client_lp_public_key, client_key);
|
||||
assert_eq!(data.protocol_version, 1);
|
||||
assert_eq!(data.salt, expected_salt);
|
||||
}
|
||||
_ => panic!("Expected ClientHello message"),
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== receive_client_hello Tests ====================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_receive_client_hello_valid() {
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let server_task = tokio::spawn(async move {
|
||||
let (stream, remote_addr) = listener.accept().await.unwrap();
|
||||
let state = create_minimal_test_state().await;
|
||||
let mut handler = LpConnectionHandler::new(stream, remote_addr, state);
|
||||
handler.receive_client_hello().await
|
||||
});
|
||||
|
||||
let mut client_stream = TcpStream::connect(addr).await.unwrap();
|
||||
|
||||
// Create and send valid ClientHello
|
||||
let client_keypair = Keypair::default();
|
||||
let hello_data = ClientHelloData::new_with_fresh_salt(
|
||||
client_keypair.public_key().to_bytes(),
|
||||
1, // protocol version
|
||||
);
|
||||
let packet = LpPacket::new(
|
||||
LpHeader {
|
||||
protocol_version: 1,
|
||||
session_id: 0,
|
||||
counter: 0,
|
||||
},
|
||||
LpMessage::ClientHello(hello_data.clone()),
|
||||
);
|
||||
write_lp_packet_to_stream(&mut client_stream, &packet)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Handler should receive and parse it
|
||||
let result = server_task.await.unwrap();
|
||||
assert!(result.is_ok());
|
||||
|
||||
let (pubkey, salt) = result.unwrap();
|
||||
assert_eq!(pubkey.as_bytes(), &client_keypair.public_key().to_bytes());
|
||||
assert_eq!(salt, hello_data.salt);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_receive_client_hello_timestamp_too_old() {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let server_task = tokio::spawn(async move {
|
||||
let (stream, remote_addr) = listener.accept().await.unwrap();
|
||||
let state = create_minimal_test_state().await;
|
||||
let mut handler = LpConnectionHandler::new(stream, remote_addr, state);
|
||||
handler.receive_client_hello().await
|
||||
});
|
||||
|
||||
let mut client_stream = TcpStream::connect(addr).await.unwrap();
|
||||
|
||||
// Create ClientHello with old timestamp
|
||||
let client_keypair = Keypair::default();
|
||||
let mut hello_data =
|
||||
ClientHelloData::new_with_fresh_salt(client_keypair.public_key().to_bytes(), 1);
|
||||
|
||||
// Manually set timestamp to be very old (100 seconds ago)
|
||||
let old_timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
- 100;
|
||||
hello_data.salt[..8].copy_from_slice(&old_timestamp.to_le_bytes());
|
||||
|
||||
let packet = LpPacket::new(
|
||||
LpHeader {
|
||||
protocol_version: 1,
|
||||
session_id: 0,
|
||||
counter: 0,
|
||||
},
|
||||
LpMessage::ClientHello(hello_data),
|
||||
);
|
||||
write_lp_packet_to_stream(&mut client_stream, &packet)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Should fail with timestamp error
|
||||
let result = server_task.await.unwrap();
|
||||
assert!(result.is_err());
|
||||
// Note: Can't use unwrap_err() directly because PublicKey doesn't implement Debug
|
||||
// Just check that it failed
|
||||
match result {
|
||||
Err(e) => {
|
||||
let err_msg = format!("{}", e);
|
||||
assert!(
|
||||
err_msg.contains("too old"),
|
||||
"Expected 'too old' in error, got: {}",
|
||||
err_msg
|
||||
);
|
||||
}
|
||||
Ok(_) => panic!("Expected error but got success"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use crate::error::GatewayError;
|
||||
use nym_lp::{
|
||||
keypair::{Keypair, PublicKey},
|
||||
state_machine::{LpAction, LpInput, LpStateMachine},
|
||||
LpPacket, LpSession,
|
||||
};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use tracing::*;
|
||||
|
||||
/// Wrapper around the nym-lp state machine for gateway-side LP connections
|
||||
pub struct LpGatewayHandshake {
|
||||
state_machine: LpStateMachine,
|
||||
}
|
||||
|
||||
impl LpGatewayHandshake {
|
||||
/// Create a new responder (gateway side) handshake
|
||||
pub fn new_responder(
|
||||
local_keypair: &Keypair,
|
||||
remote_public_key: &PublicKey,
|
||||
psk: &[u8; 32],
|
||||
) -> Result<Self, GatewayError> {
|
||||
let state_machine = LpStateMachine::new(
|
||||
false, // responder
|
||||
local_keypair,
|
||||
remote_public_key,
|
||||
psk,
|
||||
)
|
||||
.map_err(|e| {
|
||||
GatewayError::LpHandshakeError(format!("Failed to create state machine: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(Self { state_machine })
|
||||
}
|
||||
|
||||
/// Complete the handshake and return the established session
|
||||
pub async fn complete(mut self, stream: &mut TcpStream) -> Result<LpSession, GatewayError> {
|
||||
debug!("Starting LP handshake as responder");
|
||||
|
||||
// Start the handshake
|
||||
if let Some(action) = self.state_machine.process_input(LpInput::StartHandshake) {
|
||||
match action {
|
||||
Ok(LpAction::SendPacket(packet)) => {
|
||||
self.send_packet(stream, &packet).await?;
|
||||
}
|
||||
Ok(_) => {
|
||||
// Unexpected action at this stage
|
||||
return Err(GatewayError::LpHandshakeError(
|
||||
"Unexpected action at handshake start".to_string(),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(GatewayError::LpHandshakeError(format!(
|
||||
"Failed to start handshake: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Continue handshake until complete
|
||||
loop {
|
||||
// Read incoming packet
|
||||
let packet = self.receive_packet(stream).await?;
|
||||
|
||||
// Process the received packet
|
||||
if let Some(action) = self
|
||||
.state_machine
|
||||
.process_input(LpInput::ReceivePacket(packet))
|
||||
{
|
||||
match action {
|
||||
Ok(LpAction::SendPacket(response_packet)) => {
|
||||
self.send_packet(stream, &response_packet).await?;
|
||||
}
|
||||
Ok(LpAction::HandshakeComplete) => {
|
||||
info!("LP handshake completed successfully");
|
||||
break;
|
||||
}
|
||||
Ok(other) => {
|
||||
debug!("Received action during handshake: {:?}", other);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(GatewayError::LpHandshakeError(format!(
|
||||
"Handshake error: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract the session from the state machine
|
||||
self.state_machine.into_session().map_err(|e| {
|
||||
GatewayError::LpHandshakeError(format!("Failed to get session after handshake: {}", e))
|
||||
})
|
||||
}
|
||||
|
||||
/// Send an LP packet over the stream with proper length-prefixed framing
|
||||
async fn send_packet(
|
||||
&self,
|
||||
stream: &mut TcpStream,
|
||||
packet: &LpPacket,
|
||||
) -> Result<(), GatewayError> {
|
||||
use bytes::BytesMut;
|
||||
use nym_lp::codec::serialize_lp_packet;
|
||||
|
||||
// Serialize the packet first
|
||||
let mut packet_buf = BytesMut::new();
|
||||
serialize_lp_packet(packet, &mut packet_buf).map_err(|e| {
|
||||
GatewayError::LpProtocolError(format!("Failed to serialize packet: {}", e))
|
||||
})?;
|
||||
|
||||
// Send 4-byte length prefix (u32 big-endian)
|
||||
let len = packet_buf.len() as u32;
|
||||
stream.write_all(&len.to_be_bytes()).await.map_err(|e| {
|
||||
GatewayError::LpConnectionError(format!("Failed to send packet length: {}", e))
|
||||
})?;
|
||||
|
||||
// Send the actual packet data
|
||||
stream.write_all(&packet_buf).await.map_err(|e| {
|
||||
GatewayError::LpConnectionError(format!("Failed to send packet data: {}", e))
|
||||
})?;
|
||||
|
||||
stream.flush().await.map_err(|e| {
|
||||
GatewayError::LpConnectionError(format!("Failed to flush stream: {}", e))
|
||||
})?;
|
||||
|
||||
debug!(
|
||||
"Sent LP packet ({} bytes + 4 byte header)",
|
||||
packet_buf.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Receive an LP packet from the stream with proper length-prefixed framing
|
||||
async fn receive_packet(&self, stream: &mut TcpStream) -> Result<LpPacket, GatewayError> {
|
||||
use nym_lp::codec::parse_lp_packet;
|
||||
|
||||
// Read 4-byte length prefix (u32 big-endian)
|
||||
let mut len_buf = [0u8; 4];
|
||||
stream.read_exact(&mut len_buf).await.map_err(|e| {
|
||||
GatewayError::LpConnectionError(format!("Failed to read packet length: {}", e))
|
||||
})?;
|
||||
|
||||
let packet_len = u32::from_be_bytes(len_buf) as usize;
|
||||
|
||||
// Sanity check to prevent huge allocations
|
||||
const MAX_PACKET_SIZE: usize = 65536; // 64KB max
|
||||
if packet_len > MAX_PACKET_SIZE {
|
||||
return Err(GatewayError::LpProtocolError(format!(
|
||||
"Packet size {} exceeds maximum {}",
|
||||
packet_len, MAX_PACKET_SIZE
|
||||
)));
|
||||
}
|
||||
|
||||
// Read the actual packet data
|
||||
let mut packet_buf = vec![0u8; packet_len];
|
||||
stream.read_exact(&mut packet_buf).await.map_err(|e| {
|
||||
GatewayError::LpConnectionError(format!("Failed to read packet data: {}", e))
|
||||
})?;
|
||||
|
||||
let packet = parse_lp_packet(&packet_buf)
|
||||
.map_err(|e| GatewayError::LpProtocolError(format!("Failed to parse packet: {}", e)))?;
|
||||
|
||||
debug!("Received LP packet ({} bytes + 4 byte header)", packet_len);
|
||||
Ok(packet)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
//! LP registration message types.
|
||||
//!
|
||||
//! Re-exports shared message types from nym-registration-common.
|
||||
|
||||
pub use nym_registration_common::{
|
||||
LpRegistrationRequest, LpRegistrationResponse, RegistrationMode,
|
||||
};
|
||||
@@ -0,0 +1,305 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
// LP (Lewes Protocol) Metrics Documentation
|
||||
//
|
||||
// This module implements comprehensive metrics collection for LP operations using nym-metrics macros.
|
||||
// All metrics are automatically prefixed with the package name (nym_gateway) when registered.
|
||||
//
|
||||
// ## Connection Metrics (via NetworkStats in nym-node-metrics)
|
||||
// - active_lp_connections: Gauge tracking current active LP connections (incremented on accept, decremented on close)
|
||||
//
|
||||
// ## Handler Metrics (in handler.rs)
|
||||
// - lp_connections_total: Counter for total LP connections handled
|
||||
// - lp_client_hello_failed: Counter for ClientHello failures (timestamp validation, protocol errors)
|
||||
// - lp_handshakes_success: Counter for successful handshake completions
|
||||
// - lp_handshakes_failed: Counter for failed handshakes
|
||||
// - lp_handshake_duration_seconds: Histogram of handshake durations (buckets: 10ms to 10s)
|
||||
// - lp_timestamp_validation_accepted: Counter for timestamp validations that passed
|
||||
// - lp_timestamp_validation_rejected: Counter for timestamp validations that failed
|
||||
// - lp_errors_handshake: Counter for handshake errors
|
||||
// - lp_errors_send_response: Counter for errors sending registration responses
|
||||
// - lp_errors_timestamp_too_old: Counter for ClientHello timestamps that are too old
|
||||
// - lp_errors_timestamp_too_far_future: Counter for ClientHello timestamps that are too far in the future
|
||||
//
|
||||
// ## Registration Metrics (in registration.rs)
|
||||
// - lp_registration_attempts_total: Counter for all registration attempts
|
||||
// - lp_registration_success_total: Counter for successful registrations (any mode)
|
||||
// - lp_registration_failed_total: Counter for failed registrations (any mode)
|
||||
// - lp_registration_failed_timestamp: Counter for registrations rejected due to invalid timestamp
|
||||
// - lp_registration_duration_seconds: Histogram of registration durations (buckets: 100ms to 30s)
|
||||
//
|
||||
// ## Mode-Specific Registration Metrics (in registration.rs)
|
||||
// - lp_registration_dvpn_attempts: Counter for dVPN mode registration attempts
|
||||
// - lp_registration_dvpn_success: Counter for successful dVPN registrations
|
||||
// - lp_registration_dvpn_failed: Counter for failed dVPN registrations
|
||||
// - lp_registration_mixnet_attempts: Counter for Mixnet mode registration attempts
|
||||
// - lp_registration_mixnet_success: Counter for successful Mixnet registrations
|
||||
// - lp_registration_mixnet_failed: Counter for failed Mixnet registrations
|
||||
//
|
||||
// ## Credential Verification Metrics (in registration.rs)
|
||||
// - lp_credential_verification_attempts: Counter for credential verification attempts
|
||||
// - lp_credential_verification_success: Counter for successful credential verifications
|
||||
// - lp_credential_verification_failed: Counter for failed credential verifications
|
||||
// - lp_bandwidth_allocated_bytes_total: Counter for total bandwidth allocated (in bytes)
|
||||
//
|
||||
// ## Error Categorization Metrics
|
||||
// - lp_errors_wg_peer_registration: Counter for WireGuard peer registration failures
|
||||
//
|
||||
// ## Connection Lifecycle Metrics (in handler.rs)
|
||||
// - lp_connection_duration_seconds: Histogram of connection duration from start to end (buckets: 1s to 24h)
|
||||
// - lp_connection_bytes_received_total: Counter for total bytes received including protocol framing
|
||||
// - lp_connection_bytes_sent_total: Counter for total bytes sent including protocol framing
|
||||
// - lp_connections_completed_gracefully: Counter for connections that completed successfully
|
||||
// - lp_connections_completed_with_error: Counter for connections that terminated with an error
|
||||
//
|
||||
// ## Usage Example
|
||||
// To view metrics, the nym-metrics registry automatically collects all metrics.
|
||||
// They can be exported via Prometheus format using the metrics endpoint.
|
||||
|
||||
use crate::error::GatewayError;
|
||||
use crate::node::ActiveClientsStore;
|
||||
use nym_crypto::asymmetric::ed25519;
|
||||
use nym_gateway_storage::GatewayStorage;
|
||||
use nym_node_metrics::NymNodeMetrics;
|
||||
use nym_task::ShutdownTracker;
|
||||
use nym_wireguard::{PeerControlRequest, WireguardGatewayData};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::*;
|
||||
|
||||
mod handler;
|
||||
mod handshake;
|
||||
mod messages;
|
||||
mod registration;
|
||||
|
||||
/// Configuration for LP listener
|
||||
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct LpConfig {
|
||||
/// Enable/disable LP listener
|
||||
pub enabled: bool,
|
||||
|
||||
/// Bind address for control port
|
||||
#[serde(default = "default_bind_address")]
|
||||
pub bind_address: String,
|
||||
|
||||
/// Control port (default: 41264)
|
||||
#[serde(default = "default_control_port")]
|
||||
pub control_port: u16,
|
||||
|
||||
/// Data port (default: 51264)
|
||||
#[serde(default = "default_data_port")]
|
||||
pub data_port: u16,
|
||||
|
||||
/// Maximum concurrent connections
|
||||
#[serde(default = "default_max_connections")]
|
||||
pub max_connections: usize,
|
||||
|
||||
/// Maximum acceptable age of ClientHello timestamp in seconds (default: 30)
|
||||
///
|
||||
/// ClientHello messages with timestamps older than this will be rejected
|
||||
/// to prevent replay attacks. Value should be:
|
||||
/// - Large enough to account for clock skew and network latency
|
||||
/// - Small enough to limit replay attack window
|
||||
///
|
||||
/// Recommended: 30-60 seconds
|
||||
#[serde(default = "default_timestamp_tolerance_secs")]
|
||||
pub timestamp_tolerance_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for LpConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
bind_address: default_bind_address(),
|
||||
control_port: default_control_port(),
|
||||
data_port: default_data_port(),
|
||||
max_connections: default_max_connections(),
|
||||
timestamp_tolerance_secs: default_timestamp_tolerance_secs(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_bind_address() -> String {
|
||||
"0.0.0.0".to_string()
|
||||
}
|
||||
|
||||
fn default_control_port() -> u16 {
|
||||
41264
|
||||
}
|
||||
|
||||
fn default_data_port() -> u16 {
|
||||
51264
|
||||
}
|
||||
|
||||
fn default_max_connections() -> usize {
|
||||
10000
|
||||
}
|
||||
|
||||
fn default_timestamp_tolerance_secs() -> u64 {
|
||||
30 // 30 seconds - balances security vs clock skew tolerance
|
||||
}
|
||||
|
||||
/// Shared state for LP connection handlers
|
||||
#[derive(Clone)]
|
||||
pub struct LpHandlerState {
|
||||
/// Ecash verifier for bandwidth credentials
|
||||
pub ecash_verifier:
|
||||
Arc<dyn nym_credential_verification::ecash::traits::EcashManager + Send + Sync>,
|
||||
|
||||
/// Storage backend for persistence
|
||||
pub storage: GatewayStorage,
|
||||
|
||||
/// Gateway's identity keypair
|
||||
pub local_identity: Arc<ed25519::KeyPair>,
|
||||
|
||||
/// Metrics collection
|
||||
pub metrics: NymNodeMetrics,
|
||||
|
||||
/// Active clients tracking
|
||||
pub active_clients_store: ActiveClientsStore,
|
||||
|
||||
/// WireGuard peer controller channel (for dVPN registrations)
|
||||
pub wg_peer_controller: Option<mpsc::Sender<PeerControlRequest>>,
|
||||
|
||||
/// WireGuard gateway data (contains keypair and config)
|
||||
pub wireguard_data: Option<WireguardGatewayData>,
|
||||
|
||||
/// LP configuration (for timestamp validation, etc.)
|
||||
pub lp_config: LpConfig,
|
||||
}
|
||||
|
||||
/// LP listener that accepts TCP connections on port 41264
|
||||
pub struct LpListener {
|
||||
/// Address to bind the LP control port (41264)
|
||||
control_address: SocketAddr,
|
||||
|
||||
/// Port for data plane (51264) - reserved for future use
|
||||
data_port: u16,
|
||||
|
||||
/// Shared state for connection handlers
|
||||
handler_state: LpHandlerState,
|
||||
|
||||
/// Maximum concurrent connections
|
||||
max_connections: usize,
|
||||
|
||||
/// Shutdown coordination
|
||||
shutdown: ShutdownTracker,
|
||||
}
|
||||
|
||||
impl LpListener {
|
||||
pub fn new(
|
||||
bind_address: SocketAddr,
|
||||
data_port: u16,
|
||||
handler_state: LpHandlerState,
|
||||
max_connections: usize,
|
||||
shutdown: ShutdownTracker,
|
||||
) -> Self {
|
||||
Self {
|
||||
control_address: bind_address,
|
||||
data_port,
|
||||
handler_state,
|
||||
max_connections,
|
||||
shutdown,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(&mut self) -> Result<(), GatewayError> {
|
||||
let listener = TcpListener::bind(self.control_address).await.map_err(|e| {
|
||||
error!(
|
||||
"Failed to bind LP listener to {}: {}",
|
||||
self.control_address, e
|
||||
);
|
||||
GatewayError::ListenerBindFailure {
|
||||
address: self.control_address.to_string(),
|
||||
source: Box::new(e),
|
||||
}
|
||||
})?;
|
||||
|
||||
info!(
|
||||
"LP listener started on {} (data port reserved: {})",
|
||||
self.control_address, self.data_port
|
||||
);
|
||||
|
||||
let shutdown_token = self.shutdown.clone_shutdown_token();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
|
||||
_ = shutdown_token.cancelled() => {
|
||||
trace!("LP listener: received shutdown signal");
|
||||
break;
|
||||
}
|
||||
|
||||
result = listener.accept() => {
|
||||
match result {
|
||||
Ok((stream, addr)) => {
|
||||
self.handle_connection(stream, addr);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to accept LP connection: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("LP listener shutdown complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_connection(&self, stream: tokio::net::TcpStream, remote_addr: SocketAddr) {
|
||||
// Check connection limit
|
||||
let active_connections = self.active_lp_connections();
|
||||
if active_connections >= self.max_connections {
|
||||
warn!(
|
||||
"LP connection limit exceeded ({}/{}), rejecting connection from {}",
|
||||
active_connections, self.max_connections, remote_addr
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Accepting LP connection from {} ({} active connections)",
|
||||
remote_addr, active_connections
|
||||
);
|
||||
|
||||
// Increment connection counter
|
||||
self.handler_state.metrics.network.new_lp_connection();
|
||||
|
||||
// Spawn handler task
|
||||
let handler =
|
||||
handler::LpConnectionHandler::new(stream, remote_addr, self.handler_state.clone());
|
||||
|
||||
let metrics = self.handler_state.metrics.clone();
|
||||
self.shutdown.try_spawn_named(
|
||||
async move {
|
||||
let result = handler.handle().await;
|
||||
|
||||
// Handler emits lifecycle metrics internally on success
|
||||
// For errors, we need to emit them here since handler is consumed
|
||||
if let Err(e) = result {
|
||||
warn!("LP handler error for {}: {}", remote_addr, e);
|
||||
// Note: metrics are emitted in handle() for graceful path
|
||||
// On error path, handle() returns early without emitting
|
||||
// So we track errors here
|
||||
}
|
||||
|
||||
// Decrement connection counter on exit
|
||||
metrics.network.lp_connection_closed();
|
||||
},
|
||||
&format!("LP::{}", remote_addr),
|
||||
);
|
||||
}
|
||||
|
||||
fn active_lp_connections(&self) -> usize {
|
||||
self.handler_state
|
||||
.metrics
|
||||
.network
|
||||
.active_lp_connections_count()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use super::messages::{LpRegistrationRequest, LpRegistrationResponse, RegistrationMode};
|
||||
use super::LpHandlerState;
|
||||
use crate::error::GatewayError;
|
||||
use defguard_wireguard_rs::host::Peer;
|
||||
use defguard_wireguard_rs::key::Key;
|
||||
use futures::channel::oneshot;
|
||||
use nym_credential_verification::ecash::traits::EcashManager;
|
||||
use nym_credential_verification::{
|
||||
bandwidth_storage_manager::BandwidthStorageManager, BandwidthFlushingBehaviourConfig,
|
||||
ClientBandwidth, CredentialVerifier,
|
||||
};
|
||||
use nym_credentials_interface::CredentialSpendingData;
|
||||
use nym_gateway_requests::models::CredentialSpendingRequest;
|
||||
use nym_gateway_storage::models::PersistedBandwidth;
|
||||
use nym_gateway_storage::traits::BandwidthGatewayStorage;
|
||||
use nym_metrics::{add_histogram_obs, inc, inc_by};
|
||||
use nym_registration_common::GatewayData;
|
||||
use nym_wireguard::PeerControlRequest;
|
||||
use rand::RngCore;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tracing::*;
|
||||
|
||||
// Histogram buckets for LP registration duration tracking
|
||||
// Registration includes credential verification, DB operations, and potentially WireGuard peer setup
|
||||
// Expected durations: 100ms - 5s for normal operations, up to 30s for slow DB or network issues
|
||||
const LP_REGISTRATION_DURATION_BUCKETS: &[f64] = &[
|
||||
0.1, // 100ms
|
||||
0.25, // 250ms
|
||||
0.5, // 500ms
|
||||
1.0, // 1s
|
||||
2.5, // 2.5s
|
||||
5.0, // 5s
|
||||
10.0, // 10s
|
||||
30.0, // 30s
|
||||
];
|
||||
|
||||
// Histogram buckets for WireGuard peer controller channel latency
|
||||
// Measures time to send request and receive response from peer controller
|
||||
// Expected: 1ms-100ms for normal operations, up to 2s for slow conditions
|
||||
const WG_CONTROLLER_LATENCY_BUCKETS: &[f64] = &[
|
||||
0.001, // 1ms
|
||||
0.005, // 5ms
|
||||
0.01, // 10ms
|
||||
0.05, // 50ms
|
||||
0.1, // 100ms
|
||||
0.25, // 250ms
|
||||
0.5, // 500ms
|
||||
1.0, // 1s
|
||||
2.0, // 2s
|
||||
];
|
||||
|
||||
/// Prepare bandwidth storage for a client
|
||||
async fn credential_storage_preparation(
|
||||
ecash_verifier: Arc<dyn EcashManager + Send + Sync>,
|
||||
client_id: i64,
|
||||
) -> Result<PersistedBandwidth, GatewayError> {
|
||||
ecash_verifier
|
||||
.storage()
|
||||
.create_bandwidth_entry(client_id)
|
||||
.await?;
|
||||
let bandwidth = ecash_verifier
|
||||
.storage()
|
||||
.get_available_bandwidth(client_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
GatewayError::InternalError("bandwidth entry should have just been created".to_string())
|
||||
})?;
|
||||
Ok(bandwidth)
|
||||
}
|
||||
|
||||
/// Verify credential and allocate bandwidth using CredentialVerifier
|
||||
async fn credential_verification(
|
||||
ecash_verifier: Arc<dyn EcashManager + Send + Sync>,
|
||||
credential: CredentialSpendingData,
|
||||
client_id: i64,
|
||||
) -> Result<i64, GatewayError> {
|
||||
let bandwidth = credential_storage_preparation(ecash_verifier.clone(), client_id).await?;
|
||||
let client_bandwidth = ClientBandwidth::new(bandwidth.into());
|
||||
let mut verifier = CredentialVerifier::new(
|
||||
CredentialSpendingRequest::new(credential),
|
||||
ecash_verifier.clone(),
|
||||
BandwidthStorageManager::new(
|
||||
ecash_verifier.storage(),
|
||||
client_bandwidth,
|
||||
client_id,
|
||||
BandwidthFlushingBehaviourConfig::default(),
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
// Track credential verification attempts
|
||||
inc!("lp_credential_verification_attempts");
|
||||
|
||||
match verifier.verify().await {
|
||||
Ok(allocated) => {
|
||||
inc!("lp_credential_verification_success");
|
||||
// Track allocated bandwidth
|
||||
inc_by!("lp_bandwidth_allocated_bytes_total", allocated);
|
||||
Ok(allocated)
|
||||
}
|
||||
Err(e) => {
|
||||
inc!("lp_credential_verification_failed");
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Process an LP registration request
|
||||
pub async fn process_registration(
|
||||
request: LpRegistrationRequest,
|
||||
state: &LpHandlerState,
|
||||
) -> LpRegistrationResponse {
|
||||
let session_id = rand::random::<u32>();
|
||||
let registration_start = std::time::Instant::now();
|
||||
|
||||
// Track total registration attempts
|
||||
inc!("lp_registration_attempts_total");
|
||||
|
||||
// 1. Validate timestamp for replay protection
|
||||
if !request.validate_timestamp(30) {
|
||||
warn!("LP registration failed: timestamp too old or too far in future");
|
||||
inc!("lp_registration_failed_timestamp");
|
||||
return LpRegistrationResponse::error(session_id, "Invalid timestamp".to_string());
|
||||
}
|
||||
|
||||
// 2. Process based on mode
|
||||
let result = match request.mode {
|
||||
RegistrationMode::Dvpn => {
|
||||
// Track dVPN registration attempts
|
||||
inc!("lp_registration_dvpn_attempts");
|
||||
// Register as WireGuard peer first to get client_id
|
||||
let (gateway_data, client_id) = match register_wg_peer(
|
||||
request.wg_public_key.inner().as_ref(),
|
||||
request.client_ip,
|
||||
request.ticket_type,
|
||||
state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
error!("LP WireGuard peer registration failed: {}", e);
|
||||
inc!("lp_registration_dvpn_failed");
|
||||
inc!("lp_errors_wg_peer_registration");
|
||||
return LpRegistrationResponse::error(
|
||||
session_id,
|
||||
format!("WireGuard peer registration failed: {}", e),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Verify credential with CredentialVerifier (handles double-spend, storage, etc.)
|
||||
let allocated_bandwidth = match credential_verification(
|
||||
state.ecash_verifier.clone(),
|
||||
request.credential,
|
||||
client_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(bandwidth) => bandwidth,
|
||||
Err(e) => {
|
||||
// Credential verification failed, remove the peer
|
||||
warn!(
|
||||
"LP credential verification failed for client {}: {}",
|
||||
client_id, e
|
||||
);
|
||||
inc!("lp_registration_dvpn_failed");
|
||||
if let Err(remove_err) = state
|
||||
.storage
|
||||
.remove_wireguard_peer(&request.wg_public_key.to_string())
|
||||
.await
|
||||
{
|
||||
error!(
|
||||
"Failed to remove peer after credential verification failure: {}",
|
||||
remove_err
|
||||
);
|
||||
}
|
||||
return LpRegistrationResponse::error(
|
||||
session_id,
|
||||
format!("Credential verification failed: {}", e),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
"LP dVPN registration successful for session {} (client_id: {})",
|
||||
session_id, client_id
|
||||
);
|
||||
inc!("lp_registration_dvpn_success");
|
||||
LpRegistrationResponse::success(session_id, allocated_bandwidth, gateway_data)
|
||||
}
|
||||
RegistrationMode::Mixnet {
|
||||
client_id: client_id_bytes,
|
||||
} => {
|
||||
// Track mixnet registration attempts
|
||||
inc!("lp_registration_mixnet_attempts");
|
||||
|
||||
// Generate i64 client_id from the [u8; 32] in the request
|
||||
let client_id = i64::from_be_bytes(client_id_bytes[0..8].try_into().unwrap());
|
||||
|
||||
info!(
|
||||
"LP Mixnet registration for client_id {}, session {}",
|
||||
client_id, session_id
|
||||
);
|
||||
|
||||
// Verify credential with CredentialVerifier
|
||||
let allocated_bandwidth = match credential_verification(
|
||||
state.ecash_verifier.clone(),
|
||||
request.credential,
|
||||
client_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(bandwidth) => bandwidth,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"LP Mixnet credential verification failed for client {}: {}",
|
||||
client_id, e
|
||||
);
|
||||
inc!("lp_registration_mixnet_failed");
|
||||
return LpRegistrationResponse::error(
|
||||
session_id,
|
||||
format!("Credential verification failed: {}", e),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// For mixnet mode, we don't have WireGuard data
|
||||
// In the future, this would set up mixnet-specific state
|
||||
info!(
|
||||
"LP Mixnet registration successful for session {} (client_id: {})",
|
||||
session_id, client_id
|
||||
);
|
||||
inc!("lp_registration_mixnet_success");
|
||||
LpRegistrationResponse {
|
||||
success: true,
|
||||
error: None,
|
||||
gateway_data: None,
|
||||
allocated_bandwidth,
|
||||
session_id,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Track registration duration
|
||||
let duration = registration_start.elapsed().as_secs_f64();
|
||||
add_histogram_obs!(
|
||||
"lp_registration_duration_seconds",
|
||||
duration,
|
||||
LP_REGISTRATION_DURATION_BUCKETS
|
||||
);
|
||||
|
||||
// Track overall success/failure
|
||||
if result.success {
|
||||
inc!("lp_registration_success_total");
|
||||
} else {
|
||||
inc!("lp_registration_failed_total");
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Register a WireGuard peer and return gateway data along with the client_id
|
||||
async fn register_wg_peer(
|
||||
public_key_bytes: &[u8],
|
||||
client_ip: IpAddr,
|
||||
ticket_type: nym_credentials_interface::TicketType,
|
||||
state: &LpHandlerState,
|
||||
) -> Result<(GatewayData, i64), GatewayError> {
|
||||
let Some(wg_controller) = &state.wg_peer_controller else {
|
||||
return Err(GatewayError::ServiceProviderNotRunning {
|
||||
service: "WireGuard".to_string(),
|
||||
});
|
||||
};
|
||||
|
||||
let Some(wg_data) = &state.wireguard_data else {
|
||||
return Err(GatewayError::ServiceProviderNotRunning {
|
||||
service: "WireGuard".to_string(),
|
||||
});
|
||||
};
|
||||
|
||||
// Convert public key bytes to WireGuard Key
|
||||
let mut key_bytes = [0u8; 32];
|
||||
if public_key_bytes.len() != 32 {
|
||||
return Err(GatewayError::LpProtocolError(
|
||||
"Invalid WireGuard public key length".to_string(),
|
||||
));
|
||||
}
|
||||
key_bytes.copy_from_slice(public_key_bytes);
|
||||
let peer_key = Key::new(key_bytes);
|
||||
|
||||
// Allocate IP addresses for the client
|
||||
// TODO: Proper IP pool management - for now use random in private range
|
||||
inc!("wg_ip_allocation_attempts");
|
||||
let last_octet = {
|
||||
let mut rng = rand::thread_rng();
|
||||
(rng.next_u32() % 254 + 1) as u8
|
||||
};
|
||||
|
||||
let client_ipv4 = Ipv4Addr::new(10, 1, 0, last_octet);
|
||||
let client_ipv6 = Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, last_octet as u16);
|
||||
inc!("wg_ip_allocation_success");
|
||||
|
||||
// Create WireGuard peer
|
||||
let mut peer = Peer::new(peer_key.clone());
|
||||
peer.preshared_key = Some(Key::new(state.local_identity.public_key().to_bytes()));
|
||||
peer.endpoint = Some(
|
||||
format!("{}:51820", client_ip)
|
||||
.parse()
|
||||
.unwrap_or_else(|_| SocketAddr::from_str("0.0.0.0:51820").unwrap()),
|
||||
);
|
||||
peer.allowed_ips = vec![
|
||||
format!("{}/32", client_ipv4).parse().unwrap(),
|
||||
format!("{}/128", client_ipv6).parse().unwrap(),
|
||||
];
|
||||
peer.persistent_keepalive_interval = Some(25);
|
||||
|
||||
// Send to WireGuard peer controller and track latency
|
||||
let controller_start = std::time::Instant::now();
|
||||
let (tx, rx) = oneshot::channel();
|
||||
wg_controller
|
||||
.send(PeerControlRequest::AddPeer {
|
||||
peer: peer.clone(),
|
||||
response_tx: tx,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| GatewayError::InternalError(format!("Failed to send peer request: {}", e)))?;
|
||||
|
||||
let result = rx
|
||||
.await
|
||||
.map_err(|e| {
|
||||
GatewayError::InternalError(format!("Failed to receive peer response: {}", e))
|
||||
})?
|
||||
.map_err(|e| GatewayError::InternalError(format!("Failed to add peer: {:?}", e)));
|
||||
|
||||
// Record peer controller channel latency
|
||||
let latency = controller_start.elapsed().as_secs_f64();
|
||||
add_histogram_obs!(
|
||||
"wg_peer_controller_channel_latency_seconds",
|
||||
latency,
|
||||
WG_CONTROLLER_LATENCY_BUCKETS
|
||||
);
|
||||
|
||||
result?;
|
||||
|
||||
// Store bandwidth allocation and get client_id
|
||||
let client_id = state
|
||||
.storage
|
||||
.insert_wireguard_peer(&peer, ticket_type.into())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to store WireGuard peer in database: {}", e);
|
||||
GatewayError::InternalError(format!("Failed to store peer: {}", e))
|
||||
})?;
|
||||
|
||||
// Get gateway's actual WireGuard public key
|
||||
let gateway_pubkey = *wg_data.keypair().public_key();
|
||||
|
||||
// Get gateway's WireGuard endpoint from config
|
||||
let gateway_endpoint = wg_data.config().bind_address;
|
||||
|
||||
// Create GatewayData response (matching authenticator response format)
|
||||
Ok((
|
||||
GatewayData {
|
||||
public_key: gateway_pubkey,
|
||||
endpoint: gateway_endpoint,
|
||||
private_ipv4: client_ipv4,
|
||||
private_ipv6: client_ipv6,
|
||||
},
|
||||
client_id,
|
||||
))
|
||||
}
|
||||
+52
-3
@@ -32,6 +32,7 @@ use zeroize::Zeroizing;
|
||||
|
||||
pub(crate) mod client_handling;
|
||||
pub(crate) mod internal_service_providers;
|
||||
pub mod lp_listener;
|
||||
mod stale_data_cleaner;
|
||||
|
||||
use crate::config::Config;
|
||||
@@ -233,13 +234,22 @@ impl GatewayTasksBuilder {
|
||||
Ok(Arc::new(ecash_manager))
|
||||
}
|
||||
|
||||
async fn ecash_manager(&mut self) -> Result<Arc<EcashManager>, GatewayError> {
|
||||
async fn ecash_manager(
|
||||
&mut self,
|
||||
) -> Result<
|
||||
Arc<dyn nym_credential_verification::ecash::traits::EcashManager + Send + Sync>,
|
||||
GatewayError,
|
||||
> {
|
||||
match self.ecash_manager.clone() {
|
||||
Some(cached) => Ok(cached),
|
||||
Some(cached) => Ok(cached
|
||||
as Arc<dyn nym_credential_verification::ecash::traits::EcashManager + Send + Sync>),
|
||||
None => {
|
||||
let manager = self.build_ecash_manager().await?;
|
||||
self.ecash_manager = Some(manager.clone());
|
||||
Ok(manager)
|
||||
Ok(manager
|
||||
as Arc<
|
||||
dyn nym_credential_verification::ecash::traits::EcashManager + Send + Sync,
|
||||
>)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -271,6 +281,45 @@ impl GatewayTasksBuilder {
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn build_lp_listener(
|
||||
&mut self,
|
||||
active_clients_store: ActiveClientsStore,
|
||||
) -> Result<lp_listener::LpListener, GatewayError> {
|
||||
// Get WireGuard peer controller if available
|
||||
let wg_peer_controller = if let Some(wg_data) = &self.wireguard_data {
|
||||
Some(wg_data.inner.peer_tx().clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let handler_state = lp_listener::LpHandlerState {
|
||||
ecash_verifier: self.ecash_manager().await?,
|
||||
storage: self.storage.clone(),
|
||||
local_identity: Arc::clone(&self.identity_keypair),
|
||||
metrics: self.metrics.clone(),
|
||||
active_clients_store,
|
||||
wg_peer_controller,
|
||||
wireguard_data: self.wireguard_data.as_ref().map(|wd| wd.inner.clone()),
|
||||
lp_config: self.config.lp.clone(),
|
||||
};
|
||||
|
||||
// Parse bind address from config
|
||||
let bind_addr = format!(
|
||||
"{}:{}",
|
||||
self.config.lp.bind_address, self.config.lp.control_port
|
||||
)
|
||||
.parse()
|
||||
.map_err(|e| GatewayError::InternalError(format!("Invalid LP bind address: {}", e)))?;
|
||||
|
||||
Ok(lp_listener::LpListener::new(
|
||||
bind_addr,
|
||||
self.config.lp.data_port,
|
||||
handler_state,
|
||||
self.config.lp.max_connections,
|
||||
self.shutdown_tracker.clone(),
|
||||
))
|
||||
}
|
||||
|
||||
fn build_network_requester(
|
||||
&mut self,
|
||||
topology_provider: Box<dyn TopologyProvider + Send + Sync>,
|
||||
|
||||
@@ -15,6 +15,8 @@ pub struct NetworkStats {
|
||||
// designed with metrics in mind and this single counter has been woven through
|
||||
// the call stack
|
||||
active_egress_mixnet_connections: Arc<AtomicUsize>,
|
||||
|
||||
active_lp_connections: AtomicUsize,
|
||||
}
|
||||
|
||||
impl NetworkStats {
|
||||
@@ -56,4 +58,16 @@ impl NetworkStats {
|
||||
self.active_egress_mixnet_connections
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn new_lp_connection(&self) {
|
||||
self.active_lp_connections.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn lp_connection_closed(&self) {
|
||||
self.active_lp_connections.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn active_lp_connections_count(&self) -> usize {
|
||||
self.active_lp_connections.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ pub struct GatewayTasksConfig {
|
||||
#[serde(deserialize_with = "de_maybe_port")]
|
||||
pub announce_wss_port: Option<u16>,
|
||||
|
||||
#[serde(default)]
|
||||
pub lp: nym_gateway::node::lp_listener::LpConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub debug: Debug,
|
||||
}
|
||||
@@ -208,6 +211,7 @@ impl GatewayTasksConfig {
|
||||
ws_bind_address: SocketAddr::new(in6addr_any_init(), DEFAULT_WS_PORT),
|
||||
announce_ws_port: None,
|
||||
announce_wss_port: None,
|
||||
lp: Default::default(),
|
||||
debug: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ fn ephemeral_gateway_config(config: &Config) -> nym_gateway::config::Config {
|
||||
nym_gateway::config::IpPacketRouter {
|
||||
enabled: config.service_providers.network_requester.debug.enabled,
|
||||
},
|
||||
config.gateway_tasks.lp.clone(),
|
||||
nym_gateway::config::Debug {
|
||||
client_bandwidth_max_flushing_rate: config
|
||||
.gateway_tasks
|
||||
|
||||
@@ -1395,6 +1395,7 @@ pub async fn try_upgrade_config_v10<P: AsRef<Path>>(
|
||||
.maximum_time_between_redemption,
|
||||
},
|
||||
},
|
||||
lp: Default::default(),
|
||||
},
|
||||
service_providers: ServiceProvidersConfig {
|
||||
storage_paths: ServiceProvidersPaths {
|
||||
|
||||
@@ -638,6 +638,23 @@ impl NymNode {
|
||||
.await?;
|
||||
self.shutdown_tracker()
|
||||
.try_spawn_named(async move { websocket.run().await }, "EntryWebsocket");
|
||||
|
||||
// Start LP listener if enabled
|
||||
if self.config.gateway_tasks.lp.enabled {
|
||||
info!(
|
||||
"starting the LP listener on {}:{} (data port: {})",
|
||||
self.config.gateway_tasks.lp.bind_address,
|
||||
self.config.gateway_tasks.lp.control_port,
|
||||
self.config.gateway_tasks.lp.data_port
|
||||
);
|
||||
let mut lp_listener = gateway_tasks_builder
|
||||
.build_lp_listener(active_clients_store.clone())
|
||||
.await?;
|
||||
self.shutdown_tracker()
|
||||
.try_spawn_named(async move { lp_listener.run().await }, "LpListener");
|
||||
} else {
|
||||
info!("LP listener is disabled");
|
||||
}
|
||||
} else {
|
||||
info!("node not running in entry mode: the websocket will remain closed");
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@ license.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
bincode.workspace = true
|
||||
bytes.workspace = true
|
||||
futures.workspace = true
|
||||
rand.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-util.workspace = true
|
||||
@@ -23,7 +26,10 @@ nym-authenticator-client = { path = "../nym-authenticator-client" }
|
||||
nym-bandwidth-controller = { path = "../common/bandwidth-controller" }
|
||||
nym-credential-storage = { path = "../common/credential-storage" }
|
||||
nym-credentials-interface = { path = "../common/credentials-interface" }
|
||||
nym-crypto = { path = "../common/crypto" }
|
||||
nym-ip-packet-client = { path = "../nym-ip-packet-client" }
|
||||
nym-lp = { path = "../common/nym-lp" }
|
||||
nym-registration-common = { path = "../common/registration" }
|
||||
nym-sdk = { path = "../sdk/rust/nym-sdk" }
|
||||
nym-validator-client = { path = "../common/client-libs/validator-client" }
|
||||
nym-wireguard-types = { path = "../common/wireguard-types" }
|
||||
|
||||
@@ -16,6 +16,7 @@ use std::os::fd::RawFd;
|
||||
use std::{path::PathBuf, sync::Arc, time::Duration};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::config::RegistrationMode;
|
||||
use crate::error::RegistrationClientError;
|
||||
|
||||
const VPN_AVERAGE_PACKET_DELAY: Duration = Duration::from_millis(15);
|
||||
@@ -31,7 +32,7 @@ pub struct BuilderConfig {
|
||||
pub exit_node: NymNodeWithKeys,
|
||||
pub data_path: Option<PathBuf>,
|
||||
pub mixnet_client_config: MixnetClientConfig,
|
||||
pub two_hops: bool,
|
||||
pub mode: RegistrationMode,
|
||||
pub user_agent: UserAgent,
|
||||
pub custom_topology_provider: Box<dyn TopologyProvider + Send + Sync>,
|
||||
pub network_env: NymNetworkDetails,
|
||||
@@ -65,7 +66,7 @@ impl BuilderConfig {
|
||||
exit_node: NymNodeWithKeys,
|
||||
data_path: Option<PathBuf>,
|
||||
mixnet_client_config: MixnetClientConfig,
|
||||
two_hops: bool,
|
||||
mode: RegistrationMode,
|
||||
user_agent: UserAgent,
|
||||
custom_topology_provider: Box<dyn TopologyProvider + Send + Sync>,
|
||||
network_env: NymNetworkDetails,
|
||||
@@ -77,7 +78,7 @@ impl BuilderConfig {
|
||||
exit_node,
|
||||
data_path,
|
||||
mixnet_client_config,
|
||||
two_hops,
|
||||
mode,
|
||||
user_agent,
|
||||
custom_topology_provider,
|
||||
network_env,
|
||||
@@ -104,10 +105,13 @@ impl BuilderConfig {
|
||||
}
|
||||
|
||||
pub fn mixnet_client_debug_config(&self) -> DebugConfig {
|
||||
if self.two_hops {
|
||||
two_hop_debug_config(&self.mixnet_client_config)
|
||||
} else {
|
||||
mixnet_debug_config(&self.mixnet_client_config)
|
||||
match self.mode {
|
||||
// Mixnet mode uses 5-hop configuration
|
||||
RegistrationMode::Mixnet => mixnet_debug_config(&self.mixnet_client_config),
|
||||
// Wireguard and LP both use 2-hop configuration
|
||||
RegistrationMode::Wireguard | RegistrationMode::Lp => {
|
||||
two_hop_debug_config(&self.mixnet_client_config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,10 +153,9 @@ impl BuilderConfig {
|
||||
<S::GatewaysDetailsStore as GatewaysDetailsStore>::StorageError: Send + Sync,
|
||||
{
|
||||
let debug_config = self.mixnet_client_debug_config();
|
||||
let remember_me = if self.two_hops {
|
||||
RememberMe::new_vpn()
|
||||
} else {
|
||||
RememberMe::new_mixnet()
|
||||
let remember_me = match self.mode {
|
||||
RegistrationMode::Mixnet => RememberMe::new_mixnet(),
|
||||
RegistrationMode::Wireguard | RegistrationMode::Lp => RememberMe::new_vpn(),
|
||||
};
|
||||
|
||||
let builder = builder
|
||||
@@ -264,6 +267,8 @@ pub enum BuilderConfigError {
|
||||
MissingExitNode,
|
||||
#[error("mixnet_client_config is required")]
|
||||
MissingMixnetClientConfig,
|
||||
#[error("mode is required (use mode(), wireguard_mode(), lp_mode(), or mixnet_mode())")]
|
||||
MissingMode,
|
||||
#[error("user_agent is required")]
|
||||
MissingUserAgent,
|
||||
#[error("custom_topology_provider is required")]
|
||||
@@ -281,13 +286,12 @@ pub enum BuilderConfigError {
|
||||
///
|
||||
/// This provides a more convenient way to construct a `BuilderConfig` compared to the
|
||||
/// `new()` constructor with many arguments.
|
||||
#[derive(Default)]
|
||||
pub struct BuilderConfigBuilder {
|
||||
entry_node: Option<NymNodeWithKeys>,
|
||||
exit_node: Option<NymNodeWithKeys>,
|
||||
data_path: Option<PathBuf>,
|
||||
mixnet_client_config: Option<MixnetClientConfig>,
|
||||
two_hops: bool,
|
||||
mode: Option<RegistrationMode>,
|
||||
user_agent: Option<UserAgent>,
|
||||
custom_topology_provider: Option<Box<dyn TopologyProvider + Send + Sync>>,
|
||||
network_env: Option<NymNetworkDetails>,
|
||||
@@ -296,6 +300,24 @@ pub struct BuilderConfigBuilder {
|
||||
connection_fd_callback: Option<Arc<dyn Fn(RawFd) + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl Default for BuilderConfigBuilder {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
entry_node: None,
|
||||
exit_node: None,
|
||||
data_path: None,
|
||||
mixnet_client_config: None,
|
||||
mode: None,
|
||||
user_agent: None,
|
||||
custom_topology_provider: None,
|
||||
network_env: None,
|
||||
cancel_token: None,
|
||||
#[cfg(unix)]
|
||||
connection_fd_callback: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BuilderConfigBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
@@ -321,11 +343,41 @@ impl BuilderConfigBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn two_hops(mut self, two_hops: bool) -> Self {
|
||||
self.two_hops = two_hops;
|
||||
/// Set the registration mode
|
||||
pub fn mode(mut self, mode: RegistrationMode) -> Self {
|
||||
self.mode = Some(mode);
|
||||
self
|
||||
}
|
||||
|
||||
/// Convenience method to set Mixnet mode (5-hop with IPR)
|
||||
pub fn mixnet_mode(self) -> Self {
|
||||
self.mode(RegistrationMode::Mixnet)
|
||||
}
|
||||
|
||||
/// Convenience method to set Wireguard mode (2-hop with authenticator)
|
||||
pub fn wireguard_mode(self) -> Self {
|
||||
self.mode(RegistrationMode::Wireguard)
|
||||
}
|
||||
|
||||
/// Convenience method to set LP mode (2-hop with Lewes Protocol)
|
||||
pub fn lp_mode(self) -> Self {
|
||||
self.mode(RegistrationMode::Lp)
|
||||
}
|
||||
|
||||
/// Legacy method for backward compatibility
|
||||
/// Use `wireguard_mode()` or `mixnet_mode()` instead
|
||||
#[deprecated(
|
||||
since = "0.1.0",
|
||||
note = "Use `mode()`, `wireguard_mode()`, or `mixnet_mode()` instead"
|
||||
)]
|
||||
pub fn two_hops(self, two_hops: bool) -> Self {
|
||||
if two_hops {
|
||||
self.wireguard_mode()
|
||||
} else {
|
||||
self.mixnet_mode()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn user_agent(mut self, user_agent: UserAgent) -> Self {
|
||||
self.user_agent = Some(user_agent);
|
||||
self
|
||||
@@ -371,7 +423,7 @@ impl BuilderConfigBuilder {
|
||||
mixnet_client_config: self
|
||||
.mixnet_client_config
|
||||
.ok_or(BuilderConfigError::MissingMixnetClientConfig)?,
|
||||
two_hops: self.two_hops,
|
||||
mode: self.mode.ok_or(BuilderConfigError::MissingMode)?,
|
||||
user_agent: self
|
||||
.user_agent
|
||||
.ok_or(BuilderConfigError::MissingUserAgent)?,
|
||||
|
||||
@@ -35,7 +35,7 @@ impl RegistrationClientBuilder {
|
||||
let config = RegistrationClientConfig {
|
||||
entry: self.config.entry_node.clone(),
|
||||
exit: self.config.exit_node.clone(),
|
||||
two_hops: self.config.two_hops,
|
||||
mode: self.config.mode,
|
||||
};
|
||||
let cancel_token = self.config.cancel_token.clone();
|
||||
let (event_tx, event_rx) = mpsc::unbounded();
|
||||
|
||||
@@ -3,8 +3,19 @@
|
||||
|
||||
use crate::builder::config::NymNodeWithKeys;
|
||||
|
||||
/// Registration mode for the client
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RegistrationMode {
|
||||
/// 5-hop mixnet with IPR (IP Packet Router)
|
||||
Mixnet,
|
||||
/// 2-hop WireGuard with authenticator
|
||||
Wireguard,
|
||||
/// 2-hop WireGuard with LP (Lewes Protocol)
|
||||
Lp,
|
||||
}
|
||||
|
||||
pub struct RegistrationClientConfig {
|
||||
pub(crate) entry: NymNodeWithKeys,
|
||||
pub(crate) exit: NymNodeWithKeys,
|
||||
pub(crate) two_hops: bool,
|
||||
pub(crate) mode: RegistrationMode,
|
||||
}
|
||||
|
||||
@@ -50,4 +50,23 @@ pub enum RegistrationClientError {
|
||||
#[source]
|
||||
source: Box<nym_authenticator_client::Error>,
|
||||
},
|
||||
|
||||
#[error("LP registration not possible for gateway {node_id}: no LP address available")]
|
||||
LpRegistrationNotPossible { node_id: String },
|
||||
|
||||
#[error("failed to register LP with entry gateway {gateway_id} at {lp_address}: {source}")]
|
||||
EntryGatewayRegisterLp {
|
||||
gateway_id: String,
|
||||
lp_address: std::net::SocketAddr,
|
||||
#[source]
|
||||
source: Box<crate::lp_client::LpClientError>,
|
||||
},
|
||||
|
||||
#[error("failed to register LP with exit gateway {gateway_id} at {lp_address}: {source}")]
|
||||
ExitGatewayRegisterLp {
|
||||
gateway_id: String,
|
||||
lp_address: std::net::SocketAddr,
|
||||
#[source]
|
||||
source: Box<crate::lp_client::LpClientError>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -7,14 +7,18 @@ use nym_authenticator_client::{AuthClientMixnetListener, AuthenticatorClient};
|
||||
use nym_bandwidth_controller::BandwidthTicketProvider;
|
||||
use nym_credentials_interface::TicketType;
|
||||
use nym_ip_packet_client::IprClientConnect;
|
||||
use nym_lp::keypair::{Keypair as LpKeypair, PublicKey as LpPublicKey};
|
||||
use nym_registration_common::AssignedAddresses;
|
||||
use nym_sdk::mixnet::{EventReceiver, MixnetClient, Recipient};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::RegistrationClientConfig;
|
||||
use crate::lp_client::{LpClientError, LpRegistrationClient, LpTransport};
|
||||
|
||||
mod builder;
|
||||
mod config;
|
||||
mod error;
|
||||
mod lp_client;
|
||||
mod types;
|
||||
|
||||
pub use builder::RegistrationClientBuilder;
|
||||
@@ -22,8 +26,12 @@ pub use builder::config::{
|
||||
BuilderConfig as RegistrationClientBuilderConfig, MixnetClientConfig,
|
||||
NymNodeWithKeys as RegistrationNymNode,
|
||||
};
|
||||
pub use config::RegistrationMode;
|
||||
pub use error::RegistrationClientError;
|
||||
pub use types::{MixnetRegistrationResult, RegistrationResult, WireguardRegistrationResult};
|
||||
pub use lp_client::LpConfig;
|
||||
pub use types::{
|
||||
LpRegistrationResult, MixnetRegistrationResult, RegistrationResult, WireguardRegistrationResult,
|
||||
};
|
||||
|
||||
pub struct RegistrationClient {
|
||||
mixnet_client: MixnetClient,
|
||||
@@ -146,14 +154,177 @@ impl RegistrationClient {
|
||||
)))
|
||||
}
|
||||
|
||||
async fn register_lp(self) -> Result<RegistrationResult, RegistrationClientError> {
|
||||
// Extract and validate LP addresses
|
||||
let entry_lp_address = self.config.entry.node.lp_address.ok_or(
|
||||
RegistrationClientError::LpRegistrationNotPossible {
|
||||
node_id: self.config.entry.node.identity.to_base58_string(),
|
||||
},
|
||||
)?;
|
||||
|
||||
let exit_lp_address = self.config.exit.node.lp_address.ok_or(
|
||||
RegistrationClientError::LpRegistrationNotPossible {
|
||||
node_id: self.config.exit.node.identity.to_base58_string(),
|
||||
},
|
||||
)?;
|
||||
|
||||
tracing::debug!("Entry gateway LP address: {}", entry_lp_address);
|
||||
tracing::debug!("Exit gateway LP address: {}", exit_lp_address);
|
||||
|
||||
// For now, use gateway identities as LP public keys
|
||||
// TODO(nym-87): Implement proper key derivation
|
||||
let entry_gateway_lp_key =
|
||||
LpPublicKey::from_bytes(&self.config.entry.node.identity.to_bytes()).map_err(|e| {
|
||||
RegistrationClientError::LpRegistrationNotPossible {
|
||||
node_id: format!(
|
||||
"{}: invalid LP key: {}",
|
||||
self.config.entry.node.identity.to_base58_string(),
|
||||
e
|
||||
),
|
||||
}
|
||||
})?;
|
||||
|
||||
let exit_gateway_lp_key =
|
||||
LpPublicKey::from_bytes(&self.config.exit.node.identity.to_bytes()).map_err(|e| {
|
||||
RegistrationClientError::LpRegistrationNotPossible {
|
||||
node_id: format!(
|
||||
"{}: invalid LP key: {}",
|
||||
self.config.exit.node.identity.to_base58_string(),
|
||||
e
|
||||
),
|
||||
}
|
||||
})?;
|
||||
|
||||
// Generate LP keypairs for this connection
|
||||
let client_lp_keypair = Arc::new(LpKeypair::default());
|
||||
|
||||
// Register entry gateway via LP
|
||||
let entry_fut = {
|
||||
let bandwidth_controller = &self.bandwidth_controller;
|
||||
let entry_keys = self.config.entry.keys.clone();
|
||||
let entry_identity = self.config.entry.node.identity;
|
||||
let entry_ip = self.config.entry.node.ip_address;
|
||||
let lp_keypair = client_lp_keypair.clone();
|
||||
|
||||
async move {
|
||||
let mut client = LpRegistrationClient::new_with_default_psk(
|
||||
lp_keypair,
|
||||
entry_gateway_lp_key,
|
||||
entry_lp_address,
|
||||
entry_ip,
|
||||
);
|
||||
|
||||
// Connect
|
||||
client.connect().await?;
|
||||
|
||||
// Perform handshake
|
||||
client.perform_handshake().await?;
|
||||
|
||||
// Send registration request
|
||||
client
|
||||
.send_registration_request(
|
||||
&entry_keys,
|
||||
&entry_identity,
|
||||
&**bandwidth_controller,
|
||||
TicketType::V1WireguardEntry,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Receive registration response
|
||||
let gateway_data = client.receive_registration_response().await?;
|
||||
|
||||
// Convert to transport for ongoing communication
|
||||
let transport = client.into_transport()?;
|
||||
|
||||
Ok::<(LpTransport, _), LpClientError>((transport, gateway_data))
|
||||
}
|
||||
};
|
||||
|
||||
// Register exit gateway via LP
|
||||
let exit_fut = {
|
||||
let bandwidth_controller = &self.bandwidth_controller;
|
||||
let exit_keys = self.config.exit.keys.clone();
|
||||
let exit_identity = self.config.exit.node.identity;
|
||||
let exit_ip = self.config.exit.node.ip_address;
|
||||
let lp_keypair = client_lp_keypair;
|
||||
|
||||
async move {
|
||||
let mut client = LpRegistrationClient::new_with_default_psk(
|
||||
lp_keypair,
|
||||
exit_gateway_lp_key,
|
||||
exit_lp_address,
|
||||
exit_ip,
|
||||
);
|
||||
|
||||
// Connect
|
||||
client.connect().await?;
|
||||
|
||||
// Perform handshake
|
||||
client.perform_handshake().await?;
|
||||
|
||||
// Send registration request
|
||||
client
|
||||
.send_registration_request(
|
||||
&exit_keys,
|
||||
&exit_identity,
|
||||
&**bandwidth_controller,
|
||||
TicketType::V1WireguardExit,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Receive registration response
|
||||
let gateway_data = client.receive_registration_response().await?;
|
||||
|
||||
// Convert to transport for ongoing communication
|
||||
let transport = client.into_transport()?;
|
||||
|
||||
Ok::<(LpTransport, _), LpClientError>((transport, gateway_data))
|
||||
}
|
||||
};
|
||||
|
||||
// Execute registrations in parallel
|
||||
let (entry_result, exit_result) =
|
||||
Box::pin(async { tokio::join!(entry_fut, exit_fut) }).await;
|
||||
|
||||
// Handle entry gateway result
|
||||
// Note: entry_transport is dropped here, closing the LP connection
|
||||
let (_entry_transport, entry_gateway_data) =
|
||||
entry_result.map_err(|source| RegistrationClientError::EntryGatewayRegisterLp {
|
||||
gateway_id: self.config.entry.node.identity.to_base58_string(),
|
||||
lp_address: entry_lp_address,
|
||||
source: Box::new(source),
|
||||
})?;
|
||||
|
||||
// Handle exit gateway result
|
||||
// Note: exit_transport is dropped here, closing the LP connection
|
||||
let (_exit_transport, exit_gateway_data) =
|
||||
exit_result.map_err(|source| RegistrationClientError::ExitGatewayRegisterLp {
|
||||
gateway_id: self.config.exit.node.identity.to_base58_string(),
|
||||
lp_address: exit_lp_address,
|
||||
source: Box::new(source),
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
"LP registration successful for both gateways (LP connections will be closed)"
|
||||
);
|
||||
|
||||
// LP is registration-only. All data flows through WireGuard after this point.
|
||||
// The LP transports have been dropped, automatically closing TCP connections.
|
||||
Ok(RegistrationResult::Lp(Box::new(LpRegistrationResult {
|
||||
entry_gateway_data,
|
||||
exit_gateway_data,
|
||||
bw_controller: self.bandwidth_controller,
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn register(self) -> Result<RegistrationResult, RegistrationClientError> {
|
||||
self.cancel_token
|
||||
.clone()
|
||||
.run_until_cancelled(async {
|
||||
if self.config.two_hops {
|
||||
self.register_wg().await
|
||||
} else {
|
||||
self.register_mix_exit().await
|
||||
match self.config.mode {
|
||||
RegistrationMode::Mixnet => self.register_mix_exit().await,
|
||||
RegistrationMode::Wireguard => self.register_wg().await,
|
||||
RegistrationMode::Lp => self.register_lp().await,
|
||||
}
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -0,0 +1,699 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! LP (Lewes Protocol) registration client for direct gateway connections.
|
||||
|
||||
use super::config::LpConfig;
|
||||
use super::error::{LpClientError, Result};
|
||||
use super::transport::LpTransport;
|
||||
use bytes::BytesMut;
|
||||
use nym_bandwidth_controller::{BandwidthTicketProvider, DEFAULT_TICKETS_TO_SPEND};
|
||||
use nym_credentials_interface::TicketType;
|
||||
use nym_crypto::asymmetric::{ed25519, x25519};
|
||||
use nym_lp::LpPacket;
|
||||
use nym_lp::codec::{parse_lp_packet, serialize_lp_packet};
|
||||
use nym_lp::keypair::{Keypair, PublicKey};
|
||||
use nym_lp::state_machine::{LpAction, LpInput, LpStateMachine};
|
||||
use nym_registration_common::{GatewayData, LpRegistrationRequest, LpRegistrationResponse};
|
||||
use nym_wireguard_types::PeerPublicKey;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
/// LP (Lewes Protocol) registration client for direct gateway connections.
|
||||
///
|
||||
/// This client manages:
|
||||
/// - TCP connection to the gateway's LP listener
|
||||
/// - Noise protocol handshake via LP state machine
|
||||
/// - Registration request/response exchange
|
||||
/// - Encrypted transport after handshake
|
||||
///
|
||||
/// # Example Flow
|
||||
/// ```ignore
|
||||
/// let client = LpRegistrationClient::new(...);
|
||||
/// client.connect().await?; // nym-78: Establish TCP
|
||||
/// client.perform_handshake().await?; // nym-79: Noise handshake
|
||||
/// let response = client.register(...).await?; // nym-80: Send registration
|
||||
/// ```
|
||||
pub struct LpRegistrationClient {
|
||||
/// TCP stream connection to the gateway.
|
||||
/// Created during `connect()`, None before connection is established.
|
||||
tcp_stream: Option<TcpStream>,
|
||||
|
||||
/// Client's LP keypair for Noise protocol.
|
||||
local_keypair: Arc<Keypair>,
|
||||
|
||||
/// Gateway's public key for Noise protocol.
|
||||
gateway_public_key: PublicKey,
|
||||
|
||||
/// Gateway LP listener address (host:port, e.g., "1.1.1.1:41264").
|
||||
gateway_lp_address: SocketAddr,
|
||||
|
||||
/// LP state machine for managing connection lifecycle.
|
||||
/// Created during handshake initiation (nym-79).
|
||||
state_machine: Option<LpStateMachine>,
|
||||
|
||||
/// Client's IP address for registration metadata.
|
||||
client_ip: IpAddr,
|
||||
|
||||
/// Configuration for timeouts and TCP parameters (nym-87, nym-102, nym-104).
|
||||
config: LpConfig,
|
||||
}
|
||||
|
||||
impl LpRegistrationClient {
|
||||
/// Creates a new LP registration client.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `local_keypair` - Client's LP keypair for Noise protocol
|
||||
/// * `gateway_public_key` - Gateway's public key
|
||||
/// * `gateway_lp_address` - Gateway's LP listener socket address
|
||||
/// * `client_ip` - Client IP address for registration
|
||||
/// * `config` - Configuration for timeouts and TCP parameters (use `LpConfig::default()`)
|
||||
///
|
||||
/// # Note
|
||||
/// This creates the client but does not establish the connection.
|
||||
/// Call `connect()` to establish the TCP connection.
|
||||
/// PSK is derived automatically during handshake using ECDH + Blake3 KDF (nym-109).
|
||||
pub fn new(
|
||||
local_keypair: Arc<Keypair>,
|
||||
gateway_public_key: PublicKey,
|
||||
gateway_lp_address: SocketAddr,
|
||||
client_ip: IpAddr,
|
||||
config: LpConfig,
|
||||
) -> Self {
|
||||
Self {
|
||||
tcp_stream: None,
|
||||
local_keypair,
|
||||
gateway_public_key,
|
||||
gateway_lp_address,
|
||||
state_machine: None,
|
||||
client_ip,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new LP registration client with default configuration.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `local_keypair` - Client's LP keypair for Noise protocol
|
||||
/// * `gateway_public_key` - Gateway's public key
|
||||
/// * `gateway_lp_address` - Gateway's LP listener socket address
|
||||
/// * `client_ip` - Client IP address for registration
|
||||
///
|
||||
/// Uses default config (LpConfig::default()) with sane timeout and TCP parameters.
|
||||
/// PSK is derived automatically during handshake using ECDH + Blake3 KDF (nym-109).
|
||||
/// For custom config, use `new()` directly.
|
||||
pub fn new_with_default_psk(
|
||||
local_keypair: Arc<Keypair>,
|
||||
gateway_public_key: PublicKey,
|
||||
gateway_lp_address: SocketAddr,
|
||||
client_ip: IpAddr,
|
||||
) -> Self {
|
||||
Self::new(
|
||||
local_keypair,
|
||||
gateway_public_key,
|
||||
gateway_lp_address,
|
||||
client_ip,
|
||||
LpConfig::default(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Establishes TCP connection to the gateway's LP listener.
|
||||
///
|
||||
/// This must be called before attempting handshake or registration.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns `LpClientError::TcpConnection` if the connection fails or times out.
|
||||
///
|
||||
/// # Implementation Note
|
||||
/// This is implemented in nym-78. The handshake (nym-79) and registration
|
||||
/// (nym-80, nym-81) will be added in subsequent tasks.
|
||||
/// Timeout and TCP parameters added in nym-102 and nym-104.
|
||||
pub async fn connect(&mut self) -> Result<()> {
|
||||
// Apply connect timeout (nym-102)
|
||||
let stream = tokio::time::timeout(
|
||||
self.config.connect_timeout,
|
||||
TcpStream::connect(self.gateway_lp_address),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| LpClientError::TcpConnection {
|
||||
address: self.gateway_lp_address.to_string(),
|
||||
source: std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
format!("Connection timeout after {:?}", self.config.connect_timeout),
|
||||
),
|
||||
})?
|
||||
.map_err(|source| LpClientError::TcpConnection {
|
||||
address: self.gateway_lp_address.to_string(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
// Apply TCP_NODELAY (nym-104)
|
||||
stream
|
||||
.set_nodelay(self.config.tcp_nodelay)
|
||||
.map_err(|source| LpClientError::TcpConnection {
|
||||
address: self.gateway_lp_address.to_string(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
"Successfully connected to gateway LP listener at {} (timeout={:?}, nodelay={})",
|
||||
self.gateway_lp_address,
|
||||
self.config.connect_timeout,
|
||||
self.config.tcp_nodelay
|
||||
);
|
||||
|
||||
self.tcp_stream = Some(stream);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns a reference to the TCP stream if connected.
|
||||
pub fn tcp_stream(&self) -> Option<&TcpStream> {
|
||||
self.tcp_stream.as_ref()
|
||||
}
|
||||
|
||||
/// Returns whether the client is currently connected via TCP.
|
||||
pub fn is_connected(&self) -> bool {
|
||||
self.tcp_stream.is_some()
|
||||
}
|
||||
|
||||
/// Returns the gateway LP address this client is configured for.
|
||||
pub fn gateway_address(&self) -> SocketAddr {
|
||||
self.gateway_lp_address
|
||||
}
|
||||
|
||||
/// Returns the client's IP address.
|
||||
pub fn client_ip(&self) -> IpAddr {
|
||||
self.client_ip
|
||||
}
|
||||
|
||||
/// Performs the LP Noise protocol handshake with the gateway.
|
||||
///
|
||||
/// This establishes a secure encrypted session using the Noise protocol.
|
||||
/// Must be called after `connect()` and before attempting registration.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if:
|
||||
/// - Not connected via TCP
|
||||
/// - State machine creation fails
|
||||
/// - Handshake protocol fails
|
||||
/// - Network communication fails
|
||||
/// - Handshake times out (see LpConfig::handshake_timeout)
|
||||
///
|
||||
/// # Implementation
|
||||
/// This implements the Noise protocol handshake as the initiator:
|
||||
/// 1. Creates LP state machine with client as initiator
|
||||
/// 2. Sends initial handshake packet
|
||||
/// 3. Exchanges handshake messages until complete
|
||||
/// 4. Stores the established session in the state machine
|
||||
///
|
||||
/// Timeout applied in nym-102.
|
||||
pub async fn perform_handshake(&mut self) -> Result<()> {
|
||||
// Apply handshake timeout (nym-102)
|
||||
tokio::time::timeout(
|
||||
self.config.handshake_timeout,
|
||||
self.perform_handshake_inner(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
LpClientError::Transport(format!(
|
||||
"Handshake timeout after {:?}",
|
||||
self.config.handshake_timeout
|
||||
))
|
||||
})?
|
||||
}
|
||||
|
||||
/// Internal handshake implementation without timeout.
|
||||
async fn perform_handshake_inner(&mut self) -> Result<()> {
|
||||
let stream = self.tcp_stream.as_mut().ok_or_else(|| {
|
||||
LpClientError::Transport("Cannot perform handshake: not connected".to_string())
|
||||
})?;
|
||||
|
||||
tracing::debug!("Starting LP handshake as initiator");
|
||||
|
||||
// Step 1: Generate ClientHelloData with fresh salt (timestamp + nonce)
|
||||
let client_hello_data = nym_lp::ClientHelloData::new_with_fresh_salt(
|
||||
self.local_keypair.public_key().to_bytes(),
|
||||
1, // protocol_version
|
||||
);
|
||||
let salt = client_hello_data.salt;
|
||||
|
||||
tracing::trace!(
|
||||
"Generated ClientHello with timestamp: {}",
|
||||
client_hello_data.extract_timestamp()
|
||||
);
|
||||
|
||||
// Step 2: Send ClientHello as first packet (before Noise handshake)
|
||||
let client_hello_header = nym_lp::packet::LpHeader::new(
|
||||
0, // session_id not yet established
|
||||
0, // counter starts at 0
|
||||
);
|
||||
let client_hello_packet = nym_lp::LpPacket::new(
|
||||
client_hello_header,
|
||||
nym_lp::LpMessage::ClientHello(client_hello_data),
|
||||
);
|
||||
Self::send_packet(stream, &client_hello_packet).await?;
|
||||
tracing::debug!("Sent ClientHello packet");
|
||||
|
||||
// Step 3: Derive PSK using ECDH + Blake3 KDF
|
||||
let psk = nym_lp::derive_psk(
|
||||
self.local_keypair.private_key(),
|
||||
&self.gateway_public_key,
|
||||
&salt,
|
||||
);
|
||||
tracing::trace!("Derived PSK from identity keys and salt");
|
||||
|
||||
// Step 4: Create state machine as initiator with derived PSK
|
||||
let mut state_machine = LpStateMachine::new(
|
||||
true, // is_initiator
|
||||
&*self.local_keypair,
|
||||
&self.gateway_public_key,
|
||||
&psk,
|
||||
)?;
|
||||
|
||||
// Start handshake - client (initiator) sends first
|
||||
if let Some(action) = state_machine.process_input(LpInput::StartHandshake) {
|
||||
match action? {
|
||||
LpAction::SendPacket(packet) => {
|
||||
tracing::trace!("Sending initial handshake packet");
|
||||
Self::send_packet(stream, &packet).await?;
|
||||
}
|
||||
other => {
|
||||
return Err(LpClientError::Transport(format!(
|
||||
"Unexpected action at handshake start: {:?}",
|
||||
other
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Continue handshake until complete
|
||||
loop {
|
||||
// Read incoming packet from gateway
|
||||
let packet = Self::receive_packet(stream).await?;
|
||||
tracing::trace!("Received handshake packet");
|
||||
|
||||
// Process the received packet
|
||||
if let Some(action) = state_machine.process_input(LpInput::ReceivePacket(packet)) {
|
||||
match action? {
|
||||
LpAction::SendPacket(response_packet) => {
|
||||
tracing::trace!("Sending handshake response packet");
|
||||
Self::send_packet(stream, &response_packet).await?;
|
||||
}
|
||||
LpAction::HandshakeComplete => {
|
||||
tracing::info!("LP handshake completed successfully");
|
||||
break;
|
||||
}
|
||||
other => {
|
||||
tracing::trace!("Received action during handshake: {:?}", other);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store the state machine (with established session) for later use
|
||||
self.state_machine = Some(state_machine);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends an LP packet over the TCP stream with length-prefixed framing.
|
||||
///
|
||||
/// Format: 4-byte big-endian u32 length + packet bytes
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if serialization or network transmission fails.
|
||||
async fn send_packet(stream: &mut TcpStream, packet: &LpPacket) -> Result<()> {
|
||||
// Serialize the packet
|
||||
let mut packet_buf = BytesMut::new();
|
||||
serialize_lp_packet(packet, &mut packet_buf)
|
||||
.map_err(|e| LpClientError::Transport(format!("Failed to serialize packet: {}", e)))?;
|
||||
|
||||
// Send 4-byte length prefix (u32 big-endian)
|
||||
let len = packet_buf.len() as u32;
|
||||
stream.write_all(&len.to_be_bytes()).await.map_err(|e| {
|
||||
LpClientError::Transport(format!("Failed to send packet length: {}", e))
|
||||
})?;
|
||||
|
||||
// Send the actual packet data
|
||||
stream
|
||||
.write_all(&packet_buf)
|
||||
.await
|
||||
.map_err(|e| LpClientError::Transport(format!("Failed to send packet data: {}", e)))?;
|
||||
|
||||
// Flush to ensure data is sent immediately
|
||||
stream
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| LpClientError::Transport(format!("Failed to flush stream: {}", e)))?;
|
||||
|
||||
tracing::trace!(
|
||||
"Sent LP packet ({} bytes + 4 byte header)",
|
||||
packet_buf.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Receives an LP packet from the TCP stream with length-prefixed framing.
|
||||
///
|
||||
/// Format: 4-byte big-endian u32 length + packet bytes
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if:
|
||||
/// - Network read fails
|
||||
/// - Packet size exceeds maximum (64KB)
|
||||
/// - Packet parsing fails
|
||||
async fn receive_packet(stream: &mut TcpStream) -> Result<LpPacket> {
|
||||
// Read 4-byte length prefix (u32 big-endian)
|
||||
let mut len_buf = [0u8; 4];
|
||||
stream.read_exact(&mut len_buf).await.map_err(|e| {
|
||||
LpClientError::Transport(format!("Failed to read packet length: {}", e))
|
||||
})?;
|
||||
|
||||
let packet_len = u32::from_be_bytes(len_buf) as usize;
|
||||
|
||||
// Sanity check to prevent huge allocations
|
||||
const MAX_PACKET_SIZE: usize = 65536; // 64KB max
|
||||
if packet_len > MAX_PACKET_SIZE {
|
||||
return Err(LpClientError::Transport(format!(
|
||||
"Packet size {} exceeds maximum {}",
|
||||
packet_len, MAX_PACKET_SIZE
|
||||
)));
|
||||
}
|
||||
|
||||
// Read the actual packet data
|
||||
let mut packet_buf = vec![0u8; packet_len];
|
||||
stream
|
||||
.read_exact(&mut packet_buf)
|
||||
.await
|
||||
.map_err(|e| LpClientError::Transport(format!("Failed to read packet data: {}", e)))?;
|
||||
|
||||
// Parse the packet
|
||||
let packet = parse_lp_packet(&packet_buf)
|
||||
.map_err(|e| LpClientError::Transport(format!("Failed to parse packet: {}", e)))?;
|
||||
|
||||
tracing::trace!("Received LP packet ({} bytes + 4 byte header)", packet_len);
|
||||
Ok(packet)
|
||||
}
|
||||
|
||||
/// Sends an encrypted registration request to the gateway.
|
||||
///
|
||||
/// This must be called after a successful handshake. The registration request
|
||||
/// includes the client's WireGuard public key, bandwidth credential, and other
|
||||
/// registration metadata.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `wg_keypair` - Client's WireGuard x25519 keypair
|
||||
/// * `gateway_identity` - Gateway's ed25519 identity for credential verification
|
||||
/// * `bandwidth_controller` - Provider for bandwidth credentials
|
||||
/// * `ticket_type` - Type of bandwidth ticket to use
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if:
|
||||
/// - No connection is established
|
||||
/// - Handshake has not been completed
|
||||
/// - Credential acquisition fails
|
||||
/// - Request serialization fails
|
||||
/// - Encryption or network transmission fails
|
||||
///
|
||||
/// # Implementation Note (nym-80)
|
||||
/// This implements the LP registration request sending:
|
||||
/// 1. Acquires bandwidth credential from controller
|
||||
/// 2. Constructs LpRegistrationRequest with dVPN mode
|
||||
/// 3. Serializes request to bytes using bincode
|
||||
/// 4. Encrypts via LP state machine (LpInput::SendData)
|
||||
/// 5. Sends encrypted packet to gateway
|
||||
pub async fn send_registration_request(
|
||||
&mut self,
|
||||
wg_keypair: &x25519::KeyPair,
|
||||
gateway_identity: &ed25519::PublicKey,
|
||||
bandwidth_controller: &dyn BandwidthTicketProvider,
|
||||
ticket_type: TicketType,
|
||||
) -> Result<()> {
|
||||
// Ensure we have a TCP connection
|
||||
let stream = self.tcp_stream.as_mut().ok_or_else(|| {
|
||||
LpClientError::Transport("Cannot send registration: not connected".to_string())
|
||||
})?;
|
||||
|
||||
// Ensure handshake is complete (state machine exists and is in Transport state)
|
||||
let state_machine = self.state_machine.as_mut().ok_or_else(|| {
|
||||
LpClientError::Transport(
|
||||
"Cannot send registration: handshake not completed".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::debug!("Acquiring bandwidth credential for registration");
|
||||
|
||||
// 1. Get bandwidth credential from controller
|
||||
let credential = bandwidth_controller
|
||||
.get_ecash_ticket(ticket_type, *gateway_identity, DEFAULT_TICKETS_TO_SPEND)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
LpClientError::SendRegistrationRequest(format!(
|
||||
"Failed to acquire bandwidth credential: {}",
|
||||
e
|
||||
))
|
||||
})?
|
||||
.data;
|
||||
|
||||
// 2. Build registration request
|
||||
let wg_public_key = PeerPublicKey::new(wg_keypair.public_key().to_bytes().into());
|
||||
let request =
|
||||
LpRegistrationRequest::new_dvpn(wg_public_key, credential, ticket_type, self.client_ip);
|
||||
|
||||
tracing::trace!("Built registration request: {:?}", request);
|
||||
|
||||
// 3. Serialize the request
|
||||
let request_bytes = bincode::serialize(&request).map_err(|e| {
|
||||
LpClientError::SendRegistrationRequest(format!("Failed to serialize request: {}", e))
|
||||
})?;
|
||||
|
||||
tracing::debug!(
|
||||
"Sending registration request ({} bytes)",
|
||||
request_bytes.len()
|
||||
);
|
||||
|
||||
// 4. Encrypt and prepare packet via state machine
|
||||
let action = state_machine
|
||||
.process_input(LpInput::SendData(request_bytes))
|
||||
.ok_or_else(|| {
|
||||
LpClientError::Transport("State machine returned no action".to_string())
|
||||
})?
|
||||
.map_err(|e| {
|
||||
LpClientError::SendRegistrationRequest(format!(
|
||||
"Failed to encrypt registration request: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
// 5. Send the encrypted packet
|
||||
match action {
|
||||
LpAction::SendPacket(packet) => {
|
||||
Self::send_packet(stream, &packet).await?;
|
||||
tracing::info!("Successfully sent registration request to gateway");
|
||||
Ok(())
|
||||
}
|
||||
other => Err(LpClientError::Transport(format!(
|
||||
"Unexpected action when sending registration data: {:?}",
|
||||
other
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Receives and processes the registration response from the gateway.
|
||||
///
|
||||
/// This must be called after sending a registration request. The method:
|
||||
/// 1. Receives an encrypted response packet from the gateway
|
||||
/// 2. Decrypts it using the established LP session
|
||||
/// 3. Deserializes the LpRegistrationResponse
|
||||
/// 4. Validates the response and extracts GatewayData
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(GatewayData)` - Gateway configuration data on successful registration
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if:
|
||||
/// - No connection is established
|
||||
/// - Handshake has not been completed
|
||||
/// - Network reception fails
|
||||
/// - Decryption fails
|
||||
/// - Response deserialization fails
|
||||
/// - Gateway rejected the registration (success=false)
|
||||
/// - Response is missing gateway_data
|
||||
/// - Response times out (see LpConfig::registration_timeout)
|
||||
///
|
||||
/// # Implementation Note (nym-81)
|
||||
/// This implements the LP registration response processing:
|
||||
/// 1. Receives length-prefixed packet from TCP stream
|
||||
/// 2. Processes via state machine (LpInput::ReceivePacket)
|
||||
/// 3. Extracts decrypted data from LpAction::DeliverData
|
||||
/// 4. Deserializes as LpRegistrationResponse
|
||||
/// 5. Validates and returns GatewayData
|
||||
///
|
||||
/// Timeout applied in nym-102.
|
||||
pub async fn receive_registration_response(&mut self) -> Result<GatewayData> {
|
||||
// Apply registration timeout (nym-102)
|
||||
tokio::time::timeout(
|
||||
self.config.registration_timeout,
|
||||
self.receive_registration_response_inner(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
LpClientError::ReceiveRegistrationResponse(format!(
|
||||
"Registration response timeout after {:?}",
|
||||
self.config.registration_timeout
|
||||
))
|
||||
})?
|
||||
}
|
||||
|
||||
/// Internal registration response implementation without timeout.
|
||||
async fn receive_registration_response_inner(&mut self) -> Result<GatewayData> {
|
||||
// Ensure we have a TCP connection
|
||||
let stream = self.tcp_stream.as_mut().ok_or_else(|| {
|
||||
LpClientError::Transport(
|
||||
"Cannot receive registration response: not connected".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Ensure handshake is complete (state machine exists)
|
||||
let state_machine = self.state_machine.as_mut().ok_or_else(|| {
|
||||
LpClientError::Transport(
|
||||
"Cannot receive registration response: handshake not completed".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::debug!("Waiting for registration response from gateway");
|
||||
|
||||
// 1. Receive the response packet
|
||||
let packet = Self::receive_packet(stream).await?;
|
||||
|
||||
tracing::trace!("Received registration response packet");
|
||||
|
||||
// 2. Decrypt via state machine
|
||||
let action = state_machine
|
||||
.process_input(LpInput::ReceivePacket(packet))
|
||||
.ok_or_else(|| {
|
||||
LpClientError::Transport("State machine returned no action".to_string())
|
||||
})?
|
||||
.map_err(|e| {
|
||||
LpClientError::ReceiveRegistrationResponse(format!(
|
||||
"Failed to decrypt registration response: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
// 3. Extract decrypted data
|
||||
let response_data = match action {
|
||||
LpAction::DeliverData(data) => data,
|
||||
other => {
|
||||
return Err(LpClientError::Transport(format!(
|
||||
"Unexpected action when receiving registration response: {:?}",
|
||||
other
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
// 4. Deserialize the response
|
||||
let response: LpRegistrationResponse =
|
||||
bincode::deserialize(&response_data).map_err(|e| {
|
||||
LpClientError::ReceiveRegistrationResponse(format!(
|
||||
"Failed to deserialize registration response: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
tracing::debug!(
|
||||
"Received registration response: success={}, session_id={}",
|
||||
response.success,
|
||||
response.session_id
|
||||
);
|
||||
|
||||
// 5. Validate and extract GatewayData
|
||||
if !response.success {
|
||||
let error_msg = response
|
||||
.error
|
||||
.unwrap_or_else(|| "Unknown error".to_string());
|
||||
tracing::warn!("Gateway rejected registration: {}", error_msg);
|
||||
return Err(LpClientError::RegistrationRejected { reason: error_msg });
|
||||
}
|
||||
|
||||
// Extract gateway_data
|
||||
let gateway_data = response.gateway_data.ok_or_else(|| {
|
||||
LpClientError::ReceiveRegistrationResponse(
|
||||
"Gateway response missing gateway_data despite success=true".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
"LP registration successful! Session ID: {}, Allocated bandwidth: {} bytes",
|
||||
response.session_id,
|
||||
response.allocated_bandwidth
|
||||
);
|
||||
|
||||
Ok(gateway_data)
|
||||
}
|
||||
|
||||
/// Converts this client into an LpTransport for ongoing post-handshake communication.
|
||||
///
|
||||
/// This consumes the client and transfers ownership of the TCP stream and state machine
|
||||
/// to a new LpTransport instance, which can be used for arbitrary data transfer.
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(LpTransport)` - Transport handler for ongoing communication
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if:
|
||||
/// - No connection is established
|
||||
/// - Handshake has not been completed
|
||||
/// - State machine is not in Transport state
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// let mut client = LpRegistrationClient::new(...);
|
||||
/// client.connect().await?;
|
||||
/// client.perform_handshake().await?;
|
||||
/// // After registration is complete...
|
||||
/// let mut transport = client.into_transport()?;
|
||||
/// transport.send_data(b"hello").await?;
|
||||
/// ```
|
||||
///
|
||||
/// # Implementation Note (nym-82)
|
||||
/// This enables ongoing communication after registration by transferring
|
||||
/// the established LP session to a dedicated transport handler.
|
||||
pub fn into_transport(self) -> Result<LpTransport> {
|
||||
// Ensure connection exists
|
||||
let stream = self.tcp_stream.ok_or_else(|| {
|
||||
LpClientError::Transport(
|
||||
"Cannot create transport: no TCP connection established".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Ensure handshake completed
|
||||
let state_machine = self.state_machine.ok_or_else(|| {
|
||||
LpClientError::Transport("Cannot create transport: handshake not completed".to_string())
|
||||
})?;
|
||||
|
||||
// Create and return transport (validates state is Transport)
|
||||
LpTransport::from_handshake(stream, state_machine)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_client_creation() {
|
||||
let keypair = Arc::new(Keypair::default());
|
||||
let gateway_key = PublicKey::default();
|
||||
let address = "127.0.0.1:41264".parse().unwrap();
|
||||
let client_ip = "192.168.1.100".parse().unwrap();
|
||||
|
||||
let client =
|
||||
LpRegistrationClient::new_with_default_psk(keypair, gateway_key, address, client_ip);
|
||||
|
||||
assert!(!client.is_connected());
|
||||
assert_eq!(client.gateway_address(), address);
|
||||
assert_eq!(client.client_ip(), client_ip);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Configuration for LP (Lewes Protocol) client operations.
|
||||
//!
|
||||
//! Provides sane defaults for registration-only protocol. No user configuration needed.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// Configuration for LP (Lewes Protocol) connections.
|
||||
///
|
||||
/// This configuration is optimized for registration-only LP protocol with sane defaults
|
||||
/// based on real network conditions and typical registration flow timing.
|
||||
///
|
||||
/// # Default Values
|
||||
/// - `connect_timeout`: 10 seconds - reasonable for real network conditions
|
||||
/// - `handshake_timeout`: 15 seconds - allows for Noise handshake round-trips
|
||||
/// - `registration_timeout`: 30 seconds - includes credential verification and response
|
||||
/// - `tcp_nodelay`: true - lower latency for small registration messages
|
||||
/// - `tcp_keepalive`: None - not needed for short-lived registration connections
|
||||
///
|
||||
/// # Design
|
||||
/// Since LP is registration-only (connections close after registration completes),
|
||||
/// these defaults are chosen to:
|
||||
/// - Fail fast enough for good UX (no indefinite hangs)
|
||||
/// - Allow sufficient time for real network conditions
|
||||
/// - Optimize for latency over throughput (small messages)
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LpConfig {
|
||||
/// TCP connection timeout (nym-102).
|
||||
///
|
||||
/// Maximum time to wait for TCP connection establishment.
|
||||
/// Default: 10 seconds.
|
||||
pub connect_timeout: Duration,
|
||||
|
||||
/// Noise protocol handshake timeout (nym-102).
|
||||
///
|
||||
/// Maximum time to wait for Noise handshake completion (all round-trips).
|
||||
/// Default: 15 seconds.
|
||||
pub handshake_timeout: Duration,
|
||||
|
||||
/// Registration request/response timeout (nym-102).
|
||||
///
|
||||
/// Maximum time to wait for registration request send + response receive.
|
||||
/// Includes credential verification on gateway side.
|
||||
/// Default: 30 seconds.
|
||||
pub registration_timeout: Duration,
|
||||
|
||||
/// Enable TCP_NODELAY (disable Nagle's algorithm) (nym-104).
|
||||
///
|
||||
/// When true, disables Nagle's algorithm for lower latency.
|
||||
/// Recommended for registration messages which are small and latency-sensitive.
|
||||
/// Default: true.
|
||||
pub tcp_nodelay: bool,
|
||||
|
||||
/// TCP keepalive duration (nym-104).
|
||||
///
|
||||
/// When Some, enables TCP keepalive with specified interval.
|
||||
/// Since LP is registration-only with short-lived connections, keepalive is not needed.
|
||||
/// Default: None.
|
||||
pub tcp_keepalive: Option<Duration>,
|
||||
}
|
||||
|
||||
impl Default for LpConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// nym-102: Sane timeout defaults for real network conditions
|
||||
connect_timeout: Duration::from_secs(10),
|
||||
handshake_timeout: Duration::from_secs(15),
|
||||
registration_timeout: Duration::from_secs(30),
|
||||
|
||||
// nym-104: Optimized for registration-only protocol
|
||||
tcp_nodelay: true, // Lower latency for small messages
|
||||
tcp_keepalive: None, // Not needed for ephemeral connections
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = LpConfig::default();
|
||||
|
||||
assert_eq!(config.connect_timeout, Duration::from_secs(10));
|
||||
assert_eq!(config.handshake_timeout, Duration::from_secs(15));
|
||||
assert_eq!(config.registration_timeout, Duration::from_secs(30));
|
||||
assert_eq!(config.tcp_nodelay, true);
|
||||
assert_eq!(config.tcp_keepalive, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_clone() {
|
||||
let config = LpConfig::default();
|
||||
let cloned = config.clone();
|
||||
|
||||
assert_eq!(config, cloned);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Error types for LP (Lewes Protocol) client operations.
|
||||
|
||||
use nym_lp::LpError;
|
||||
use std::io;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors that can occur during LP client operations.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LpClientError {
|
||||
/// Failed to establish TCP connection to gateway
|
||||
#[error("Failed to connect to gateway at {address}: {source}")]
|
||||
TcpConnection {
|
||||
address: String,
|
||||
#[source]
|
||||
source: io::Error,
|
||||
},
|
||||
|
||||
/// Failed during LP handshake
|
||||
#[error("LP handshake failed: {0}")]
|
||||
HandshakeFailed(#[from] LpError),
|
||||
|
||||
/// Failed to send registration request
|
||||
#[error("Failed to send registration request: {0}")]
|
||||
SendRegistrationRequest(String),
|
||||
|
||||
/// Failed to receive registration response
|
||||
#[error("Failed to receive registration response: {0}")]
|
||||
ReceiveRegistrationResponse(String),
|
||||
|
||||
/// Registration was rejected by gateway
|
||||
#[error("Gateway rejected registration: {reason}")]
|
||||
RegistrationRejected { reason: String },
|
||||
|
||||
/// LP transport error
|
||||
#[error("LP transport error: {0}")]
|
||||
Transport(String),
|
||||
|
||||
/// Invalid LP address format
|
||||
#[error("Invalid LP address '{address}': {reason}")]
|
||||
InvalidAddress { address: String, reason: String },
|
||||
|
||||
/// Serialization/deserialization error
|
||||
#[error("Serialization error: {0}")]
|
||||
Serialization(#[from] bincode::Error),
|
||||
|
||||
/// Connection closed unexpectedly
|
||||
#[error("Connection closed unexpectedly")]
|
||||
ConnectionClosed,
|
||||
|
||||
/// Timeout waiting for response
|
||||
#[error("Timeout waiting for {operation}")]
|
||||
Timeout { operation: String },
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, LpClientError>;
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! LP (Lewes Protocol) client implementation for direct gateway registration.
|
||||
//!
|
||||
//! This module provides a client for registering with gateways using the Lewes Protocol,
|
||||
//! which offers direct TCP connections for improved performance compared to mixnet-based
|
||||
//! registration while maintaining security through Noise protocol handshakes and credential
|
||||
//! verification.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use nym_registration_client::lp_client::LpRegistrationClient;
|
||||
//!
|
||||
//! let client = LpRegistrationClient::new_with_default_psk(
|
||||
//! keypair,
|
||||
//! gateway_public_key,
|
||||
//! gateway_lp_address,
|
||||
//! client_ip,
|
||||
//! );
|
||||
//!
|
||||
//! // Establish TCP connection
|
||||
//! client.connect().await?;
|
||||
//!
|
||||
//! // Perform handshake (nym-79)
|
||||
//! client.perform_handshake().await?;
|
||||
//!
|
||||
//! // Register with gateway (nym-80, nym-81)
|
||||
//! let response = client.register(credential, ticket_type).await?;
|
||||
//! ```
|
||||
|
||||
mod client;
|
||||
mod config;
|
||||
mod error;
|
||||
mod transport;
|
||||
|
||||
pub use client::LpRegistrationClient;
|
||||
pub use config::LpConfig;
|
||||
pub use error::LpClientError;
|
||||
pub use transport::LpTransport;
|
||||
@@ -0,0 +1,267 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! LP transport layer for handling post-handshake communication.
|
||||
//!
|
||||
//! The transport layer manages data flow after a successful Noise protocol handshake,
|
||||
//! handling encryption, decryption, and reliable message delivery over the LP connection.
|
||||
|
||||
use super::error::{LpClientError, Result};
|
||||
use bytes::BytesMut;
|
||||
use nym_lp::LpPacket;
|
||||
use nym_lp::codec::{parse_lp_packet, serialize_lp_packet};
|
||||
use nym_lp::state_machine::{LpAction, LpInput, LpStateBare, LpStateMachine};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
/// Handles LP transport after successful handshake.
|
||||
///
|
||||
/// This struct manages encrypted data transmission using an established LP session,
|
||||
/// providing methods for sending and receiving arbitrary data over the secure channel.
|
||||
///
|
||||
/// # Usage
|
||||
/// ```ignore
|
||||
/// // After handshake and registration
|
||||
/// let transport = client.into_transport()?;
|
||||
///
|
||||
/// // Send arbitrary data
|
||||
/// transport.send_data(b"hello").await?;
|
||||
///
|
||||
/// // Receive data
|
||||
/// let response = transport.receive_data().await?;
|
||||
///
|
||||
/// // Close when done
|
||||
/// transport.close().await?;
|
||||
/// ```
|
||||
pub struct LpTransport {
|
||||
/// TCP stream for network I/O
|
||||
stream: TcpStream,
|
||||
|
||||
/// LP state machine managing encryption/decryption
|
||||
state_machine: LpStateMachine,
|
||||
}
|
||||
|
||||
impl LpTransport {
|
||||
/// Creates a new LP transport handler from an established connection.
|
||||
///
|
||||
/// This should be called after a successful Noise protocol handshake.
|
||||
/// The state machine must be in Transport state.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `stream` - The TCP stream connected to the gateway
|
||||
/// * `state_machine` - The LP state machine in Transport state
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the state machine is not in Transport state.
|
||||
pub fn from_handshake(stream: TcpStream, state_machine: LpStateMachine) -> Result<Self> {
|
||||
// Validate that handshake is complete
|
||||
match state_machine.bare_state() {
|
||||
LpStateBare::Transport => Ok(Self {
|
||||
stream,
|
||||
state_machine,
|
||||
}),
|
||||
other => Err(LpClientError::Transport(format!(
|
||||
"Cannot create transport: state machine is in {:?} state, expected Transport",
|
||||
other
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends arbitrary encrypted data over the LP connection.
|
||||
///
|
||||
/// The data is encrypted using the established LP session and sent with
|
||||
/// length-prefixed framing (4-byte big-endian u32 length + packet data).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - The plaintext data to send
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if:
|
||||
/// - Encryption fails
|
||||
/// - Network transmission fails
|
||||
/// - State machine returns unexpected action
|
||||
pub async fn send_data(&mut self, data: &[u8]) -> Result<()> {
|
||||
tracing::trace!("Sending {} bytes over LP transport", data.len());
|
||||
|
||||
// Encrypt via state machine
|
||||
let action = self
|
||||
.state_machine
|
||||
.process_input(LpInput::SendData(data.to_vec()))
|
||||
.ok_or_else(|| {
|
||||
LpClientError::Transport(
|
||||
"State machine returned no action for SendData".to_string(),
|
||||
)
|
||||
})?
|
||||
.map_err(|e| LpClientError::Transport(format!("Failed to encrypt data: {}", e)))?;
|
||||
|
||||
// Extract and send packet
|
||||
match action {
|
||||
LpAction::SendPacket(packet) => {
|
||||
self.send_packet(&packet).await?;
|
||||
tracing::trace!("Successfully sent encrypted data packet");
|
||||
Ok(())
|
||||
}
|
||||
other => Err(LpClientError::Transport(format!(
|
||||
"Unexpected action when sending data: {:?}",
|
||||
other
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Receives and decrypts data from the LP connection.
|
||||
///
|
||||
/// Reads a length-prefixed packet, decrypts it using the LP session,
|
||||
/// and returns the plaintext data.
|
||||
///
|
||||
/// # Returns
|
||||
/// The decrypted plaintext data as a Vec<u8>
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if:
|
||||
/// - Network reception fails
|
||||
/// - Packet parsing fails
|
||||
/// - Decryption fails
|
||||
/// - State machine returns unexpected action
|
||||
pub async fn receive_data(&mut self) -> Result<Vec<u8>> {
|
||||
tracing::trace!("Waiting to receive data over LP transport");
|
||||
|
||||
// Receive packet from network
|
||||
let packet = self.receive_packet().await?;
|
||||
|
||||
// Decrypt via state machine
|
||||
let action = self
|
||||
.state_machine
|
||||
.process_input(LpInput::ReceivePacket(packet))
|
||||
.ok_or_else(|| {
|
||||
LpClientError::Transport(
|
||||
"State machine returned no action for ReceivePacket".to_string(),
|
||||
)
|
||||
})?
|
||||
.map_err(|e| LpClientError::Transport(format!("Failed to decrypt data: {}", e)))?;
|
||||
|
||||
// Extract decrypted data
|
||||
match action {
|
||||
LpAction::DeliverData(data) => {
|
||||
tracing::trace!("Successfully received and decrypted {} bytes", data.len());
|
||||
Ok(data.to_vec())
|
||||
}
|
||||
other => Err(LpClientError::Transport(format!(
|
||||
"Unexpected action when receiving data: {:?}",
|
||||
other
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gracefully closes the LP connection.
|
||||
///
|
||||
/// Sends a close signal to the peer and shuts down the TCP stream.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the close operation fails.
|
||||
pub async fn close(mut self) -> Result<()> {
|
||||
tracing::debug!("Closing LP transport");
|
||||
|
||||
// Signal close to state machine
|
||||
if let Some(action_result) = self.state_machine.process_input(LpInput::Close) {
|
||||
match action_result {
|
||||
Ok(LpAction::ConnectionClosed) => {
|
||||
tracing::debug!("LP connection closed by state machine");
|
||||
}
|
||||
Ok(other) => {
|
||||
tracing::warn!("Unexpected action when closing connection: {:?}", other);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Error closing LP connection: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown TCP stream
|
||||
if let Err(e) = self.stream.shutdown().await {
|
||||
tracing::warn!("Error shutting down TCP stream: {}", e);
|
||||
}
|
||||
|
||||
tracing::info!("LP transport closed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Checks if the transport is in a valid state for data transfer.
|
||||
///
|
||||
/// Returns true if the state machine is in Transport state.
|
||||
pub fn is_connected(&self) -> bool {
|
||||
matches!(self.state_machine.bare_state(), LpStateBare::Transport)
|
||||
}
|
||||
|
||||
/// Sends an LP packet over the TCP stream with length-prefixed framing.
|
||||
///
|
||||
/// Format: 4-byte big-endian u32 length + packet bytes
|
||||
async fn send_packet(&mut self, packet: &LpPacket) -> Result<()> {
|
||||
// Serialize the packet
|
||||
let mut packet_buf = BytesMut::new();
|
||||
serialize_lp_packet(packet, &mut packet_buf)
|
||||
.map_err(|e| LpClientError::Transport(format!("Failed to serialize packet: {}", e)))?;
|
||||
|
||||
// Send 4-byte length prefix (u32 big-endian)
|
||||
let len = packet_buf.len() as u32;
|
||||
self.stream
|
||||
.write_all(&len.to_be_bytes())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
LpClientError::Transport(format!("Failed to send packet length: {}", e))
|
||||
})?;
|
||||
|
||||
// Send the actual packet data
|
||||
self.stream
|
||||
.write_all(&packet_buf)
|
||||
.await
|
||||
.map_err(|e| LpClientError::Transport(format!("Failed to send packet data: {}", e)))?;
|
||||
|
||||
// Flush to ensure data is sent immediately
|
||||
self.stream
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| LpClientError::Transport(format!("Failed to flush stream: {}", e)))?;
|
||||
|
||||
tracing::trace!(
|
||||
"Sent LP packet ({} bytes + 4 byte header)",
|
||||
packet_buf.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Receives an LP packet from the TCP stream with length-prefixed framing.
|
||||
///
|
||||
/// Format: 4-byte big-endian u32 length + packet bytes
|
||||
async fn receive_packet(&mut self) -> Result<LpPacket> {
|
||||
// Read 4-byte length prefix (u32 big-endian)
|
||||
let mut len_buf = [0u8; 4];
|
||||
self.stream.read_exact(&mut len_buf).await.map_err(|e| {
|
||||
LpClientError::Transport(format!("Failed to read packet length: {}", e))
|
||||
})?;
|
||||
|
||||
let packet_len = u32::from_be_bytes(len_buf) as usize;
|
||||
|
||||
// Sanity check to prevent huge allocations
|
||||
const MAX_PACKET_SIZE: usize = 65536; // 64KB max
|
||||
if packet_len > MAX_PACKET_SIZE {
|
||||
return Err(LpClientError::Transport(format!(
|
||||
"Packet size {} exceeds maximum {}",
|
||||
packet_len, MAX_PACKET_SIZE
|
||||
)));
|
||||
}
|
||||
|
||||
// Read the actual packet data
|
||||
let mut packet_buf = vec![0u8; packet_len];
|
||||
self.stream
|
||||
.read_exact(&mut packet_buf)
|
||||
.await
|
||||
.map_err(|e| LpClientError::Transport(format!("Failed to read packet data: {}", e)))?;
|
||||
|
||||
// Parse the packet
|
||||
let packet = parse_lp_packet(&packet_buf)
|
||||
.map_err(|e| LpClientError::Transport(format!("Failed to parse packet: {}", e)))?;
|
||||
|
||||
tracing::trace!("Received LP packet ({} bytes + 4 byte header)", packet_len);
|
||||
Ok(packet)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use nym_sdk::mixnet::{EventReceiver, MixnetClient};
|
||||
pub enum RegistrationResult {
|
||||
Mixnet(Box<MixnetRegistrationResult>),
|
||||
Wireguard(Box<WireguardRegistrationResult>),
|
||||
Lp(Box<LpRegistrationResult>),
|
||||
}
|
||||
|
||||
pub struct MixnetRegistrationResult {
|
||||
@@ -25,3 +26,24 @@ pub struct WireguardRegistrationResult {
|
||||
pub authenticator_listener_handle: AuthClientMixnetListenerHandle,
|
||||
pub bw_controller: Box<dyn BandwidthTicketProvider>,
|
||||
}
|
||||
|
||||
/// Result of LP (Lewes Protocol) registration with entry and exit gateways.
|
||||
///
|
||||
/// LP is used only for registration. After successful registration, all data flows
|
||||
/// through WireGuard tunnels established using the returned gateway configuration.
|
||||
/// The LP connections are automatically closed after registration completes.
|
||||
///
|
||||
/// # Fields
|
||||
/// * `entry_gateway_data` - WireGuard configuration from entry gateway
|
||||
/// * `exit_gateway_data` - WireGuard configuration from exit gateway
|
||||
/// * `bw_controller` - Bandwidth ticket provider for credential management
|
||||
pub struct LpRegistrationResult {
|
||||
/// Gateway configuration data from entry gateway
|
||||
pub entry_gateway_data: GatewayData,
|
||||
|
||||
/// Gateway configuration data from exit gateway
|
||||
pub exit_gateway_data: GatewayData,
|
||||
|
||||
/// Bandwidth controller for credential management
|
||||
pub bw_controller: Box<dyn BandwidthTicketProvider>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user